916 lines
36 KiB
Python
916 lines
36 KiB
Python
"""
|
||
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()
|