uc_salary_aggregator/uc_salary_pipeline.py
Miloš Jovanović 9fafcb649f initial commit
2026-07-28 10:50:27 -07:00

916 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
uc_salary_pipeline.py
=====================
All-in-one pipeline: scrape UCOP Annual Wage data, classify every employee
into one of 11 analytical categories, and produce a summary CSV.
USAGE
-----
# Download raw data AND classify:
python uc_salary_pipeline.py --years 2015 2024 --scrape --output summary.csv
# Classify already-downloaded CSVs (skips scraping):
python uc_salary_pipeline.py --files UCOP_Data_2015.csv UCOP_Data_2024.csv --output summary.csv
# Classify Transparent California CSVs instead:
python uc_salary_pipeline.py --files tc_2015.csv tc_2024.csv --source tc --output summary.csv
DATA ACQUISITION
----------------
Scraping is delegated to the upstream tool:
https://github.com/raleighlittles/UC_Employee_Salary_Data
On first run with --scrape, this script:
1. Checks for ./UC_Employee_Salary_Data/.git
2. If absent, git clones the repo (git must be on PATH)
3. Runs uc_employee_salary_downloader.py -y <YEAR> for each year
4. Uses the produced UCOP_Data_<YEAR>.csv files for classification
Subsequent runs reuse existing CSVs without re-scraping.
Pass --files to skip acquisition entirely.
CATEGORIES (11 total)
---------------------
1. LADDER_FACULTY Senate professorial series (PROF-AY/FY/HCOMP, all ranks)
+ Professor of Teaching / SOE/PSOE (Senate)
+ AES tenure Agronomists, Astronomers
2. HS_CLINICAL Health Sciences Compensation Plan clinical series:
HS CLIN PROF, HS ASSOC CLIN PROF, HS ASST CLIN PROF
(these are ladder-equivalent but on HCOMP; analytically
distinct because of large "other pay" from clinical income)
3. ACTING_PROFESSOR Acting ranks (both Senate ACT PROF and non-Senate ACT ASST PROF)
and Visiting Professors / Recall appointments
4. PROF_IN_RESIDENCE Professor in Residence series (CTO 311); campus-funded
non-tenure research faculty
5. OTHER_ACADEMIC Unit 18 lecturers, Adjunct Professors, Prof of Clinical
(non-HS), research series (Proj Sci, Specialist, Coop Ext),
Librarians, Academic Coordinators/Administrators, Postdocs
6. SENIOR_MANAGEMENT Chancellor, Provost, Vice Chancellor, Vice President,
Dean (all levels), Executive Director, Head Coach
7. STUDENT_TRAINEE TAs, GSRs, Associates (GSHIP), Interns, Residents,
Postdoctoral Scholars
8. SENIOR_PROF_STAFF High-compensation non-academic staff (gross pay ≥ $100k):
Physicians, Nurse Practitioners, Senior IT/Analysts,
Senior Administrators, Engineers, Attorneys, Finance
9. PROF_STAFF Mid-range professional/administrative staff ($40k–$100k)
10. SUPPORT_STAFF Lower-wage operational staff (< $40k): food service,
custodial, parking, entry-level clerical, security
11. ATHLETICS Athletic coaches and athletics admin (separated from SMG
because their "other pay" bonuses massively distort SMG totals)
Pay thresholds for SENIOR_PROF_STAFF vs PROF_STAFF vs SUPPORT_STAFF are
applied to gross_pay and can be adjusted via --senior-threshold and
--support-threshold flags.
"""
import argparse
import csv
import json
import logging
import os
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# LOGGING
# ---------------------------------------------------------------------------
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s",
level=logging.INFO,
handlers=[
logging.FileHandler("uc_salary_pipeline.log", encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# UPSTREAM SCRAPER DEPENDENCY
# ---------------------------------------------------------------------------
SCRAPER_REPO = "https://github.com/raleighlittles/UC_Employee_Salary_Data"
SCRAPER_DIR = Path("UC_Employee_Salary_Data")
SCRAPER_SCRIPT = SCRAPER_DIR / "src" / "uc_employee_salary_downloader.py"
UCOP_COLUMNS = ["id", "year", "location", "first name", "last name",
"title", "gross pay", "regular pay", "overtime pay", "other pay"]
def _patch_scraper() -> None:
"""
Apply fixes to the upstream scraper after cloning, in-place.
Idempotent: a sentinel comment is checked before each run.
Fix 1 - stale hardcoded Cookie header (causes HTTP 400)
Remove the baked-in JSESSIONID/AWSALB tokens. Instead of a
bare session.get() warm-up, inject a _warm_up_session() helper
that retries on timeout with exponential backoff.
Fix 2 - pdb.set_trace() crash on HTTP error
Replace with a plain raise so errors surface cleanly.
"""
import re as _re
script = SCRAPER_SCRIPT
src = script.read_text(encoding="utf-8")
SENTINEL = "# patched-by-uc_salary_pipeline"
if SENTINEL in src:
log.info("Upstream scraper already patched.")
return
log.info("Patching upstream scraper (%s) ...", script)
# Fix 1a: remove the stale Cookie line
src, n1 = _re.subn(r'[ \t]*"Cookie"\s*:.*\n', "", src, count=1)
if n1 == 0:
log.warning("Cookie header line not found - upstream may have changed.")
# Fix 1b: replace bare session creation with _warm_up_session() call
src, n2 = _re.subn(
r'([ \t]*session = requests\.Session\(\)\n)',
r'\1 _warm_up_session(session, base_url, request_headers)\n',
src, count=1,
)
if n2 == 0:
log.warning("session warm-up insertion point not found - upstream may have changed.")
# Fix 2: replace pdb.set_trace() with a plain raise
src, n3 = _re.subn(
r'[ \t]*import pdb\s*;\s*pdb\.set_trace\(\)\n',
" raise\n",
src, count=1,
)
if n3 == 0:
log.warning("pdb.set_trace() not found - upstream may have changed.")
# Prepend sentinel + _warm_up_session helper as a plain string
# (no triple-quote nesting issues this way)
helper_lines = [
'def _warm_up_session(session, base_url, headers,\n',
' retries=5, timeout=120, backoff=15):\n',
' # GET the UCOP wage page to obtain a fresh session cookie.\n',
' # Retries on ReadTimeout with linear backoff.\n',
' import time as _time\n',
' url = base_url + "/wage/"\n',
' for attempt in range(1, retries + 1):\n',
' try:\n',
' session.get(url, headers=headers, timeout=timeout)\n',
' return\n',
' except Exception as exc:\n',
' if attempt == retries:\n',
' raise RuntimeError(\n',
' f"Could not reach {url} after {retries} attempts: {exc}"\n',
' ) from exc\n',
' wait = backoff * attempt\n',
' print(f"[warm-up] attempt {attempt} failed ({exc}); "\n',
' f"retrying in {wait}s ...")\n',
' _time.sleep(wait)\n',
'\n',
]
src = SENTINEL + "\n" + "".join(helper_lines) + src
script.write_text(src, encoding="utf-8")
log.info("Upstream scraper patched.")
def ensure_scraper() -> None:
"""
Guarantee the upstream scraper repo is present and patched.
Steps:
1. If UC_Employee_Salary_Data/.git is absent, git clone the repo.
2. Apply _patch_scraper() to fix the stale cookie and pdb crash.
3. Install requests via pip only if not already importable
(on Arch it arrives via pacman python-requests, not pip).
"""
git_marker = SCRAPER_DIR / ".git"
if not git_marker.exists():
log.info("Scraper repo not found - cloning %s ...", SCRAPER_REPO)
try:
subprocess.run(
["git", "clone", "--depth=1", SCRAPER_REPO, str(SCRAPER_DIR)],
check=True,
)
except FileNotFoundError:
log.error("git not found on PATH. Install git and retry.")
sys.exit(1)
except subprocess.CalledProcessError as exc:
log.error("git clone failed: %s", exc)
sys.exit(1)
else:
log.info("Scraper repo already present at %s", SCRAPER_DIR)
# Always call - idempotent via sentinel comment
_patch_scraper()
import importlib.util
if importlib.util.find_spec("requests") is None:
log.info("requests not found - installing via pip ...")
subprocess.run(
[sys.executable, "-m", "pip", "install", "requests", "--quiet"],
check=True,
)
else:
log.info("requests already available - skipping pip install")
log.info("Scraper ready at %s", SCRAPER_SCRIPT)
def run_scraper(year: int, output_dir: Path) -> Path:
"""
Run the upstream scraper for one year and return the path to the
produced UCOP_Data_<year>.csv.
The upstream script writes its CSV to the process working directory,
so we execute it with cwd=output_dir.
If the CSV already exists in output_dir, scraping is skipped.
"""
csv_path = output_dir / f"UCOP_Data_{year}.csv"
if csv_path.exists():
log.info("CSV already exists for %s, skipping scrape: %s", year, csv_path)
return csv_path
output_dir.mkdir(parents=True, exist_ok=True)
log.info("Running upstream scraper for year %s ...", year)
log.info(" Expect 20-60 minutes (220k+ rows with polite rate limiting).")
log.info(" Upstream progress goes to ucop.log in the working directory.")
try:
subprocess.run(
[sys.executable, str(SCRAPER_SCRIPT.resolve()), "-y", str(year)],
check=True,
cwd=str(output_dir),
)
except subprocess.CalledProcessError as exc:
log.error("Scraper failed for year %s: %s", year, exc)
sys.exit(1)
if not csv_path.exists():
log.error("Scraper finished but expected CSV not found: %s", csv_path)
sys.exit(1)
log.info("Scraper produced: %s", csv_path)
return csv_path
# ---------------------------------------------------------------------------
# CLASSIFICATION ENGINE
# ---------------------------------------------------------------------------
# Categories are organised around the UC Academic Senate membership rules
# (Regents Standing Order 105.1), as listed at senate.ucla.edu/senate-membership
#
# SENATE_FACULTY
# All titles that carry Academic Senate membership (excluding administrative
# officers such as Chancellor, Dean etc. which are kept in SENIOR_MANAGEMENT):
# - Professor series (all ranks: Full/Assoc/Asst, all pay plans: AY/FY/HCOMP/
# B/E/E/SFT-VM/Law/1-9 etc.)
# - Professor of Teaching / SOE / PSOE (Senate members)
# - Professor of Clinical X (all ranks) -- Senate per SO 105.1
# - Professor-in-Residence (all ranks) -- Senate per SO 105.1
# - Acting Professor and Acting Professor of Teaching (all ranks) -- Senate
# - Instructor and Instructor-in-Residence -- Senate
# - Emeritus/Recall faculty (retired from Senate title)
# - AES Agronomists and Astronomers (tenure series)
#
# NON_SENATE_ACADEMIC
# Academic titles that do NOT carry Senate membership:
# - Health Sciences Clinical Professor series (HS CLIN PROF) -- explicitly
# listed as non-Senate; kept visible because their HCOMP supplements make
# total pay structurally different from regular faculty
# - Adjunct Professor series
# - Visiting Professor series (including Vis Asst/Assoc Prof)
# - Assistant Acting Professor (different from Acting Assistant Professor,
# which IS Senate)
# - Clinical Associate series
# - Non-Senate Instructional / Lecturers (all variants including LECT-AY,
# LECT-FY, LECT-AY-1/9, Summer Session, Continuing etc.)
# - Professional Research series
# - Project Scientist series
# - Specialist / Cooperative Extension series
# - Visiting Professional Research / Visiting Scholars
# - Volunteer Clinical Professor
# - Librarians, Academic Coordinators, Academic Administrators
# - Postdoctoral Scholars (non-student, non-Senate)
#
# SENIOR_MANAGEMENT -- kept as-is (anchored patterns to avoid false positives)
# ATHLETICS -- kept as-is (separated to avoid inflating SMG totals)
# STUDENT_TRAINEE -- TAs, GSRs, Readers, Residents, Interns, Student workers
# SENIOR_PROF_STAFF -- non-academic staff, gross pay >= $100k
# PROF_STAFF -- non-academic staff, $40k <= gross pay < $100k
# SUPPORT_STAFF -- non-academic staff, gross pay < $40k
CATEGORIES = [
"SENATE_FACULTY",
"NON_SENATE_ACADEMIC",
"SENIOR_MANAGEMENT",
"ATHLETICS",
"STUDENT_TRAINEE",
"SENIOR_PROF_STAFF",
"PROF_STAFF",
"SUPPORT_STAFF",
]
# Pay thresholds (adjustable via CLI)
SENIOR_STAFF_THRESHOLD = 100_000
SUPPORT_STAFF_THRESHOLD = 40_000
def _rx(*patterns):
return [re.compile(p, re.IGNORECASE) for p in patterns]
# ---------------------------------------------------------------------------
# Pattern blocks — tested in the order listed in classify_title()
# ---------------------------------------------------------------------------
# 1. STUDENT / TRAINEE
# Tested first because some student titles contain "PROF" or "INSTR".
# Medical residents are trainees, not staff.
_STUDENT_RX = _rx(
r"\bGSR\b",
r"\bGRAD(?:UATE)? STUDENT RES",
r"\bTEACH(?:ING)? ASST\b",
r"[-\s]GSHIP\b",
r"\bASSOC IN ", # Associate-in-[dept]-GSHIP student series
r"\bREMEDIAL TUTOR\b",
r"\bTUT\b", # Tutor (GSHIP and non-GSHIP)
r"\bREADER\b", # Reader / Special Reader
r"\bPOSTDOC",
r"\bPOST[ -]?DOCTORAL",
r"\bINTERN(?!AL)", # Intern but not Internal
r"\bRESID(?:ENT)?\s+PHYS", # Resident Physician (medical trainees)
r"\bPOST D\.?D\.?S\b",
r"\bCLIN(?:ICAL)? TRAINEE\b",
r"\bVET(?:ERINARY)?.*(?:INTERN|RESIDENT)\b",
r"^STDT\b", # Student worker prefix
r"^STUDENT\b", # Student 1/2/3/4, Student Researcher etc.
r"\bSTUDENT RESEARCHER\b",
r"\bTEACHING FELLOW\b",
)
# 2. ATHLETICS
# Tested before SMG so coaches don't match DEAN/DIR patterns.
_ATHLETICS_RX = _rx(
r"\bHEAD COACH\b",
r"\bATH(?:LETIC)? COACH\b",
r"\bINTERCOL ATH\b",
r"\bATH DIR\b",
r"\bATHLETICS DIR",
r"\bATH ASSOC DIR\b",
r"\bASC HEAD COACH\b", # Associate Head Coach (GCC abbreviation)
r"^COACH (?:ASSISTANT|SPEC)\b", # Assistant/Specialist Coach series (2024)
r"^RECR COACH\b", # Recreation Coach series
r"^ATH (?:MANAGER|TRAINER|SUPERVISOR|PROFL)\b", # Athletics staff series
r"^SPORTS MEDICAL ATH\b", # Sports Medical Athletic Trainer
r"\bATH PROFL\b", # Ath Profl series
)
# 3. SENIOR MANAGEMENT
# Anchored/specific patterns to avoid false positives like
# "Assistant TO Dean" or "Director of Nursing".
_SMG_RX = _rx(
r"^CHANCELLOR\b",
r"^CHANC\b",
r"\bVICE CHANC\b",
r"\bVICE[ -]CHANC\b",
r"\bASSOC VICE CHANC\b",
r"\bASST VICE CHANC\b",
r"^PROVOST\b",
r"\bVICE PROVOST\b",
r"\bASSOC VICE PROVOST\b",
r"\bASST VICE PROVOST\b",
r"^EXEC VICE AND PROVOST\b",
r"\bPROVOST\b", # catches Executive Vice and Provost, Senior VP variants
r"\bVICE PRES\b",
r"^PRESIDENT\b",
r"^DEAN\b", # Anchored: DEAN at start, not "Assistant to Dean"
r"^DEAN AST\b", # Assistant Dean (2015 GCC form)
r"^DEAN ASC\b", # Associate Dean (2015 GCC form)
r"^ACT/INTERIM DEAN\b",
r"^DEAN UNIV EXT\b",
r"\bDIR EXEC\b",
r"\bEXEC DIR\b",
r"\bEXECUTIVE DIRECTOR\b",
r"\bLAB(?:ORATORY)? DIRECTOR\b",
)
# 4. SENATE FACULTY
# All titles conferring UC Academic Senate membership under SO 105.1,
# excluding administrative officers (Chancellor, Dean etc.) which are in SMG.
#
# Key inclusions confirmed from senate.ucla.edu/senate-membership:
# Professor (all ranks + pay plans)
# Professor of Teaching (SOE/PSOE) -- Senate
# Professor of Clinical X (all ranks) -- Senate (NOT the HS series)
# Professor-in-Residence (all ranks) -- Senate
# Acting Professor / Acting Professor of Teaching (all ranks) -- Senate
# Instructor / Instructor-in-Residence -- Senate
# Emeritus (retired from Senate title) / Recall Faculty
_SENATE_RX = _rx(
# --- Core professorial series (all ranks, all pay plans) ---
# Anchored at ^ to avoid matching "ADJ PROF", "VIS PROF", "HS CLIN PROF" etc.
r"^(?:ASST\s+|ASSOC\s+)?PROF-(?:AY|FY|HCOMP|SFT-VM)\b",
r"^(?:ASST\s+|ASSOC\s+)?PROF-AY-", # B/E/E, 1/9, 1/10, LAW variants
r"^(?:ASST\s+|ASSOC\s+)?PROF-FY-",
r"^(?:ASST\s+|ASSOC\s+)?PROF-SFT-VM\b",
r"^(?:ASST\s+|ASSOC\s+)?PROF-HCOMP\b", # bare HCOMP (GCC full-prof titles)
# --- Professor of Teaching / SOE / PSOE (Senate) ---
r"^(?:ASST\s+|ASSOC\s+)?PROF OF TEACH\b",
r"^(?:ASST\s+|ASSOC\s+)?PROF OF TEACH-",
r"PROF(?:\s+OF)?\s+TEACH.*EMERITUS",
# --- Professor of Clinical X (all ranks) -- Senate per SO 105.1 ---
# NB: "HS CLIN PROF" (Health Sciences series) is NON-Senate and is
# caught in NON_SENATE_ACADEMIC. This pattern matches the non-HS variant.
r"^(?:ASST\s+|ASSOC\s+)?PROF OF CLIN\b",
r"^(?:ASST\s+|ASSOC\s+)?PROF OF CLIN-",
# --- Professor-in-Residence (all ranks) -- Senate ---
r"^(?:ASST\s+|ASSOC\s+)?PROF IN RES\b",
r"^(?:ASST\s+|ASSOC\s+)?PROF IN RES-",
# --- Acting Professor and Acting Professor of Teaching -- Senate ---
# "Acting Professor" IS Senate; "Assistant Acting Professor" is NOT.
# Pattern: ACT [ASSOC|ASST] PROF (without "ASST ACT" order which = non-Senate)
r"^ACT\s+PROF\b",
r"^ACT\s+PROF-",
r"^ACT\s+ASSOC\s+PROF\b",
r"^ACT\s+ASSOC\s+PROF-",
r"^ACT\s+ASST\s+PROF\b",
r"^ACT\s+ASST\s+PROF-",
r"^ACT\s+(?:ASSOC\s+|ASST\s+)?PROF OF TEACH\b",
r"^ACT\s+(?:ASSOC\s+|ASST\s+)?PROF OF TEACH-",
r"^ACT\s+(?:ASSOC\s+|ASST\s+)?PROF OF CLIN\b",
r"^ACT\s+(?:ASSOC\s+|ASST\s+)?PROF OF CLIN-",
r"^ACT\s+(?:ASSOC\s+|ASST\s+)?PROF IN RES\b",
r"^ACT\s+PROF-AY-LAW\b", # Acting Prof Law series
# --- Instructor / Instructor-in-Residence -- Senate ---
r"^INSTR\b",
r"^INSTR-",
r"^INSTRUCTOR\b",
r"^(?:ASST\s+|ASSOC\s+)?INSTR IN RES\b",
# --- Regents' Professor (endowed Senate title) ---
r"^REGENTS'? PROF\b",
# --- Emeritus / Recall (retired from Senate title) ---
r"\bPROF(?:ESSOR)?[-\s]EMERITUS\b",
r"\bPROF(?:ESSOR)?\s+EMERITUS\b",
r"^RECALL FACULTY\b",
# --- Specialty tenure series (Senate) ---
r"^(?:ASSOC\s+|ASST\s+)?AGRON AES(?:-AY|-FY|-B/E/E|-SFT-VM)?$",
r"^(?:ASSOC\s+|ASST\s+)?ASTRONOMER(?:-AY|-FY)?$",
)
# 5. NON-SENATE ACADEMIC
# Academic titles WITHOUT Senate membership per SO 105.1 and senate.ucla.edu.
_NON_SENATE_RX = _rx(
# --- Health Sciences Clinical Professor series (explicitly non-Senate) ---
r"^HS\s+(?:ASST\s+|ASSOC\s+)?CLIN PROF\b",
r"^HS\s+(?:ASST\s+|ASSOC\s+)?CLIN PROF-",
r"^HS\s+CLIN INSTR\b", # HS Clinical Instructor
r"^HS\s+CLIN INSTR-",
# --- Adjunct Professor series ---
r"\bADJ(?:UNCT)?\s+PROF\b",
r"\bADJ\s+INSTR\b",
# --- Visiting Professor series (non-Senate) ---
# "VIS PROF", "VIS ASST PROF", "VIS ASSOC PROF"
r"\bVIS\s+(?:ASST\s+|ASSOC\s+)?PROF\b",
r"\bVIS\s+(?:ASST\s+|ASSOC\s+)?PROF-",
# --- Assistant Acting Professor (non-Senate; reversed word order matters) ---
r"^ASST\s+ACT\s+PROF\b",
r"^ASST\s+ACT\s+PROF-",
# --- Clinical Associate series ---
r"^(?:ASST\s+|ASSOC\s+)?CLIN PROF\b", # non-HS clinical (no HS prefix)
r"^(?:ASST\s+|ASSOC\s+)?CLIN PROF-",
r"\bCLIN PROF-VOL\b", # Volunteer Clinical Professor
# --- Non-Senate Instructional / Lecturers (all variants) ---
r"\bLECTURER\b",
r"\bLECT\b", # LECT-AY, LECT-FY, LECT-AY-1/9 etc.
r"^LECT-",
r"\bSR\s+LECT\b",
r"\bSENIOR\s+LECT\b",
r"\bLECT\s+IN\b", # Lect In Summer Session
r"\bCONT(?:INUING)?\s+EDUCATOR\b",
r"\bSUPV\s+OF\s+TEACH\s+ED\b",
# --- Professional Research series ---
r"\bPROF(?:ESSIONAL)?\s+RES(?:EARCHER)?\b",
r"\bSR\s+RES\b",
r"\bASSOC\s+RES\b",
r"^RES-(?:FY|AY)\b",
r"^ASST\s+RES-",
# --- Project Scientist series ---
r"\bPROJ(?:ECT)?\s+SCI(?:ENT)?",
r"\bVIS\s+PROJ\s+SCI", # Visiting Project Scientist
# --- Specialist / Cooperative Extension series ---
# Anchored to avoid "PHYSICIAN SPECIALIST", "IT SPECIALIST" staff titles
r"^(?:SR\s+|ASSOC\s+|ASST\s+)?SPECIALIST(?:\s+AES|\s+COOP|\s+NEX|\s*$)",
r"\bCOOP\s+EXT\s+(?:SPEC|ADVISOR|ADV)\b",
# --- Recall/Recall-type lecturer ---
r"\bLECT-RECALL\b",
r"\bPROF-RECALL\b", # Recalled ladder faculty (post-emeritus)
# --- Librarians (non-Senate academic series) ---
r"\bLIBRARIAN\b",
# --- Academic Coordinators / Administrators ---
r"\bACAD(?:EMIC)?\s+COORD\b",
r"\bACAD(?:EMIC)?\s+ADMIN\b",
)
# ---------------------------------------------------------------------------
# Normalisation
# GCC (State Controller) uses title case with full words:
# "Assistant Prof-Ay", "Associate Prof-Ay", "Visiting Prof", "Acting Prof-Ay"
# UCOP uses abbreviated upper case: "ASST PROF-AY", "ASSOC PROF-AY"
# Normalise to upper case + UCOP abbreviations so all patterns work on both.
# ---------------------------------------------------------------------------
_GCC_EXPANSIONS = [
(re.compile(r"\bASSISTANT\b"), "ASST"),
(re.compile(r"\bASSOCIATE\b"), "ASSOC"),
(re.compile(r"\bVISITING\b"), "VIS"),
(re.compile(r"\bACTING\b"), "ACT"),
# GCC 2024 uses "Professor-Ay" instead of "Prof-Ay".
# Also catches the UCSB corruption "Professoressor" (doubled suffix).
(re.compile(r"\bPROFESSOR(?:ESSOR)?\b"), "PROF"),
]
def normalise(t: str) -> str:
"""Upper-case, collapse whitespace, expand GCC full-word rank abbreviations."""
t = re.sub(r"\s+", " ", t.upper().strip())
for pattern, replacement in _GCC_EXPANSIONS:
t = pattern.sub(replacement, t)
return t
def classify_title(raw_title: str, gross_pay: float = 0.0,
senior_threshold: float = SENIOR_STAFF_THRESHOLD,
support_threshold: float = SUPPORT_STAFF_THRESHOLD) -> str:
t = normalise(raw_title)
if any(rx.search(t) for rx in _STUDENT_RX):
return "STUDENT_TRAINEE"
if any(rx.search(t) for rx in _ATHLETICS_RX):
return "ATHLETICS"
if any(rx.search(t) for rx in _SMG_RX):
return "SENIOR_MANAGEMENT"
# Senate faculty tested before non-Senate so that e.g. PROF OF CLIN
# (Senate) doesn't fall through to the CLIN PROF (non-Senate) pattern.
if any(rx.search(t) for rx in _SENATE_RX):
return "SENATE_FACULTY"
if any(rx.search(t) for rx in _NON_SENATE_RX):
return "NON_SENATE_ACADEMIC"
# Staff: split by pay level
if gross_pay >= senior_threshold:
return "SENIOR_PROF_STAFF"
elif gross_pay >= support_threshold:
return "PROF_STAFF"
else:
return "SUPPORT_STAFF"
# CSV PARSING
# ---------------------------------------------------------------------------
def _dollar(s) -> float:
try:
return float(re.sub(r"[$,\s]", "", str(s)))
except (ValueError, TypeError):
return 0.0
def parse_ucop_row(row: dict) -> dict | None:
title = (row.get("title") or row.get("Title") or "").strip()
if not title:
return None
gross = _dollar(row.get("gross pay") or row.get("gross_pay") or 0)
reg = _dollar(row.get("regular pay") or row.get("regular_pay") or 0)
over = _dollar(row.get("overtime pay") or row.get("overtime_pay") or 0)
other = _dollar(row.get("other pay") or row.get("other_pay") or 0)
campus = (row.get("location") or row.get("Location") or "").strip()
gross = gross if gross > 0 else (reg + over + other)
return {"title": title, "gross_pay": gross, "regular_pay": reg,
"overtime_pay": over, "other_pay": other,
"benefits": 0.0, "total_pay_benefits": gross,
"campus": campus}
def parse_tc_row(row: dict) -> dict | None:
title = (row.get("Job Title") or row.get("Job title") or
row.get("job_title") or "").strip()
if not title:
return None
total = _dollar(row.get("Total Pay") or row.get("Total pay") or 0)
benefits= _dollar(row.get("Benefits") or 0)
tpb = _dollar(row.get("Total Pay & Benefits") or
row.get("Total pay & benefits") or 0)
if tpb == 0 and total > 0:
tpb = total + benefits
return {"title": title, "gross_pay": total, "regular_pay": 0.0,
"overtime_pay": 0.0, "other_pay": 0.0,
"benefits": benefits, "total_pay_benefits": tpb,
"campus": ""}
def parse_gcc_row(row: dict) -> dict | None:
"""
Parse one row from a GCC (State Controller) CSV.
Confirmed column names from UCB 2015:
Position, RegularPay, OvertimePay, LumpSumPay, OtherPay, TotalWages,
DefinedBenefitPlanContribution, HealthDentalVision,
TotalRetirementAndHealthContribution, EmployerName
TotalWages = RegularPay + OvertimePay + LumpSumPay + OtherPay
Benefits = TotalRetirementAndHealthContribution
(= DefinedBenefitPlanContribution + HealthDentalVision)
LumpSumPay is one-time payouts (unused vacation etc); treated as part of
gross pay (it is included in TotalWages already).
Campus comes from the 'campus' column added by gcc_downloader.py when
combining files, or from 'EmployerName' in per-campus files.
"""
title = (row.get("Position") or "").strip()
if not title:
return None
gross = _dollar(row.get("TotalWages") or 0)
reg = _dollar(row.get("RegularPay") or 0)
overtime = _dollar(row.get("OvertimePay") or 0)
lump = _dollar(row.get("LumpSumPay") or 0)
other = _dollar(row.get("OtherPay") or 0)
benefits = _dollar(row.get("TotalRetirementAndHealthContribution") or 0)
# Fallback: recompute gross if TotalWages is blank
if gross == 0:
gross = reg + overtime + lump + other
total_pay_benefits = gross + benefits
# Campus: prefer the synthetic 'campus' column from the combined file,
# fall back to EmployerName for individual per-campus files.
campus = (row.get("campus") or row.get("EmployerName") or "").strip()
return {
"title": title,
"gross_pay": gross,
"regular_pay": reg,
"overtime_pay": overtime,
"other_pay": other + lump, # lump sum folded into other
"benefits": benefits,
"total_pay_benefits": total_pay_benefits,
"campus": campus,
}
# ---------------------------------------------------------------------------
# AGGREGATION
# ---------------------------------------------------------------------------
def aggregate_csv(filepath: Path, source: str,
senior_threshold: float,
support_threshold: float) -> dict:
totals = {
cat: {"count": 0, "gross_pay": 0.0, "benefits": 0.0,
"total_pay_benefits": 0.0, "other_pay": 0.0}
for cat in CATEGORIES
}
unmatched: Counter = Counter()
if source == "ucop":
parse_fn = parse_ucop_row
elif source == "gcc":
parse_fn = parse_gcc_row
else:
parse_fn = parse_tc_row
with open(filepath, newline="", encoding="utf-8-sig") as fh:
reader = csv.DictReader(fh)
for row in reader:
parsed = parse_fn(row)
if parsed is None:
continue
cat = classify_title(
parsed["title"],
gross_pay=parsed["gross_pay"],
senior_threshold=senior_threshold,
support_threshold=support_threshold,
)
totals[cat]["count"] += 1
totals[cat]["gross_pay"] += parsed["gross_pay"]
totals[cat]["benefits"] += parsed["benefits"]
totals[cat]["total_pay_benefits"] += parsed["total_pay_benefits"]
totals[cat]["other_pay"] += parsed["other_pay"]
if cat in ("SENIOR_PROF_STAFF", "PROF_STAFF", "SUPPORT_STAFF", "NON_SENATE_ACADEMIC"):
unmatched[normalise(parsed["title"])] += 1
totals["_unmatched"] = unmatched
return totals
# ---------------------------------------------------------------------------
# OUTPUT
# ---------------------------------------------------------------------------
CATEGORY_LABELS = {
"SENATE_FACULTY": "Senate Faculty",
"NON_SENATE_ACADEMIC": "Non-Senate Academic",
"SENIOR_MANAGEMENT": "Senior Management (SMG)",
"ATHLETICS": "Athletics",
"STUDENT_TRAINEE": "Student / Trainee",
"SENIOR_PROF_STAFF": "Senior Prof/Staff (>=$100k)",
"PROF_STAFF": "Professional Staff ($40k-$100k)",
"SUPPORT_STAFF": "Support Staff (<$40k)",
}
def _M(v: float) -> str:
return f"${v / 1_000_000:,.2f}M"
def print_summary(year_results: dict) -> None:
years = sorted(year_results.keys())
cw = 24
header = f"{'Category':<34}" + "".join(f"{y:>{cw}}" for y in years)
sep = "=" * len(header)
print(f"\n{sep}")
print("UC SALARY SUMMARY - Gross Pay (millions USD)")
print(sep)
print(header)
print("-" * len(header))
for cat in CATEGORIES:
label = CATEGORY_LABELS[cat]
vals = [_M(year_results[y].get(cat, {}).get("gross_pay", 0)) for y in years]
print(f"{label:<34}" + "".join(f"{v:>{cw}}" for v in vals))
print("-" * len(header))
totals = [_M(sum(year_results[y].get(c, {}).get("gross_pay", 0)
for c in CATEGORIES)) for y in years]
print(f"{'TOTAL':<34}" + "".join(f"{v:>{cw}}" for v in totals))
print(f"\n{sep}")
print("UC SALARY SUMMARY - Employee Counts")
print(sep)
print(header)
print("-" * len(header))
for cat in CATEGORIES:
label = CATEGORY_LABELS[cat]
vals = [f"{year_results[y].get(cat, {}).get('count', 0):,}" for y in years]
print(f"{label:<34}" + "".join(f"{v:>{cw}}" for v in vals))
print(sep)
def write_summary_csv(year_results: dict, outpath: Path) -> None:
with open(outpath, "w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["year", "category", "category_label",
"count", "gross_pay", "benefits",
"total_pay_benefits", "other_pay"])
for year in sorted(year_results.keys()):
for cat in CATEGORIES:
d = year_results[year].get(cat, {})
w.writerow([
year, cat, CATEGORY_LABELS[cat],
d.get("count", 0),
round(d.get("gross_pay", 0), 2),
round(d.get("benefits", 0), 2),
round(d.get("total_pay_benefits", 0), 2),
round(d.get("other_pay", 0), 2),
])
log.info("Summary CSV → %s", outpath)
def write_staff_audit(year_results: dict, outpath: Path) -> None:
"""
Write a ranked list of every job title that landed in one of the three
staff buckets. Useful for spotting misclassified academic titles.
"""
combined: Counter = Counter()
for yr_data in year_results.values():
combined.update(yr_data.get("_unmatched", {}))
with open(outpath, "w", encoding="utf-8") as fh:
fh.write("# Staff title audit - titles classified as "
"SENIOR_PROF_STAFF / PROF_STAFF / SUPPORT_STAFF\n")
fh.write("# count\ttitle\n\n")
for title, cnt in combined.most_common():
fh.write(f"{cnt}\t{title}\n")
log.info("Staff audit → %s", outpath)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Scrape and/or classify UC salary data into 11 categories.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
src = parser.add_mutually_exclusive_group(required=True)
src.add_argument("--scrape", action="store_true",
help="Download data from UCOP for the given --years.")
src.add_argument("--files", nargs="+", metavar="CSV",
help="Pre-downloaded CSV files to classify.")
parser.add_argument("--years", nargs="+", type=int,
help="Year(s) to scrape (required with --scrape).")
parser.add_argument("--scrape-dir", default=".",
help="Directory where scraped CSVs are written "
"(default: current directory).")
parser.add_argument("--source", choices=["ucop", "tc", "gcc"], default="ucop",
help="CSV format: 'ucop' (default), 'tc' "
"(Transparent California), or 'gcc' "
"(State Controller GCC combined CSV).")
parser.add_argument("--output", default="uc_salary_summary.csv",
help="Output summary CSV path.")
parser.add_argument("--staff-audit", default="staff_title_audit.txt",
help="Output path for staff-bucket title audit.")
parser.add_argument("--senior-threshold", type=float,
default=SENIOR_STAFF_THRESHOLD,
help=f"Gross pay threshold for SENIOR_PROF_STAFF "
f"(default ${SENIOR_STAFF_THRESHOLD:,}).")
parser.add_argument("--support-threshold", type=float,
default=SUPPORT_STAFF_THRESHOLD,
help=f"Gross pay below this → SUPPORT_STAFF "
f"(default ${SUPPORT_STAFF_THRESHOLD:,}).")
args = parser.parse_args()
year_results: dict = {}
if args.scrape:
if not args.years:
parser.error("--scrape requires --years")
# Step 1: ensure the upstream scraper repo is cloned
ensure_scraper()
# Step 2: run it for each requested year; skip if CSV already exists
scrape_dir = Path(args.scrape_dir)
for year in args.years:
csv_path = run_scraper(year, scrape_dir)
log.info("Classifying %s ...", csv_path)
year_results[str(year)] = aggregate_csv(
csv_path, "ucop", args.senior_threshold, args.support_threshold)
else: # --files
if not args.years:
# infer from filenames
years = []
for f in args.files:
m = re.search(r"\d{4}", Path(f).name)
years.append(m.group(0) if m else Path(f).stem)
else:
years = [str(y) for y in args.years]
if len(years) != len(args.files):
parser.error("--years count must match --files count")
for filepath, year in zip(args.files, years):
p = Path(filepath)
if not p.exists():
log.warning("File not found, skipping: %s", p)
continue
log.info("Classifying %s (year=%s) …", p, year)
result = aggregate_csv(p, args.source,
args.senior_threshold,
args.support_threshold)
total_rows = sum(result[c]["count"] for c in CATEGORIES)
log.info(" → %d rows processed", total_rows)
year_results[year] = result
if not year_results:
log.error("No data processed.")
sys.exit(1)
print_summary(year_results)
write_summary_csv(year_results, Path(args.output))
write_staff_audit(year_results, Path(args.staff_audit))
if __name__ == "__main__":
main()