uc_salary_aggregator/classifier.py
Miloš Jovanović 99d5108d76 add medical_center flag: clinical/HCOMP faculty and medical-center clinical staff
Orthogonal boolean column alongside category, computed in classifier.py's
new is_medical_center(). Always flags MED CTR-tagged titles and the
existing HS CLIN PROF/-HCOMP patterns regardless of campus; additionally
flags generic clinical-workforce titles (physicians, nurses, dentists,
pharmacists, therapists, techs, etc.) only at the five campuses that
operate a UC-owned academic medical center (UCSF, UCLA, UCD, UCI, UCSD),
since those titles are indistinguishable from Student Health Services
staff elsewhere. SHS-tagged titles are always excluded.

Threaded through as a --group-by dimension for --aggregate-output and
as a column on --top-earners-output rows, alongside the per-employee CSV.
2026-08-04 22:52:58 +00:00

444 lines
17 KiB
Python

"""
classifier.py
==============
UC employee title classification engine: sorts every employee into one of
8 analytical categories based on job title (and, for non-academic staff,
gross pay). Independent of any particular data source — feed it a raw
title string and (optionally) a gross pay figure.
CATEGORIES (8 total)
---------------------
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
Pay thresholds for SENIOR_PROF_STAFF vs PROF_STAFF vs SUPPORT_STAFF are
applied to gross_pay and can be overridden per-call via classify_title()'s
senior_threshold / support_threshold arguments.
"""
import re
CATEGORIES = [
"SENATE_FACULTY",
"NON_SENATE_ACADEMIC",
"SENIOR_MANAGEMENT",
"ATHLETICS",
"STUDENT_TRAINEE",
"SENIOR_PROF_STAFF",
"PROF_STAFF",
"SUPPORT_STAFF",
]
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)",
}
# Pay thresholds (adjustable per-call)
SENIOR_STAFF_THRESHOLD = 100_000
SUPPORT_STAFF_THRESHOLD = 40_000
def _fmt_threshold(v: float) -> str:
if v == int(v) and v % 1000 == 0:
return f"${int(v) // 1000}k"
return f"${v:,.0f}"
def category_labels(senior_threshold: float = SENIOR_STAFF_THRESHOLD,
support_threshold: float = SUPPORT_STAFF_THRESHOLD) -> dict:
"""CATEGORY_LABELS with the staff-tier labels reflecting the actual
thresholds passed to classify_title(), instead of the defaults."""
labels = dict(CATEGORY_LABELS)
labels["SENIOR_PROF_STAFF"] = f"Senior Prof/Staff (>={_fmt_threshold(senior_threshold)})"
labels["PROF_STAFF"] = (f"Professional Staff ({_fmt_threshold(support_threshold)}"
f"-{_fmt_threshold(senior_threshold)})")
labels["SUPPORT_STAFF"] = f"Support Staff (<{_fmt_threshold(support_threshold)})"
return labels
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"
# ---------------------------------------------------------------------------
# MEDICAL CENTER FLAG
# ---------------------------------------------------------------------------
# Orthogonal to classify_title()'s category: marks employees working at a UC
# academic medical center (clinical/HCOMP faculty, physicians, nurses, and
# the broader allied-health/hospital-operations workforce), regardless of
# which of the 8 categories they land in above.
#
# Built from actual UC-wages title text, not guessed. Two signals:
#
# 1. ALWAYS (any campus): titles that are unambiguous wherever they appear.
# - "MED CTR" literal substring -- only ever seen on hospital-building
# facilities/exec titles (MED CTR COOK, CEO MED CTR, etc.), never on
# clinical professional titles, never at non-medical-center campuses.
# - HS CLIN PROF / HS CLIN INSTR series (Health Sciences clinical
# faculty -- same titles already routed to NON_SENATE_ACADEMIC above).
# - Bare "-HCOMP" ladder titles (Health Sciences Compensation Plan --
# same titles already routed to SENATE_FACULTY above).
#
# 2. ONLY at the 5 campuses that operate a UC-owned academic medical center
# (UCSF, UCLA, UCD, UCI, UCSD -- UC Riverside has a medical school but no
# UC-owned teaching hospital of its own): generic clinical-workforce
# titles (physicians, nurses, dentists, pharmacists, therapists, techs,
# etc.). These same titles show up identically at every campus (student
# health services exists everywhere), so outside these 5 campuses they
# can't be told apart from student health staff by title text alone.
#
# EXCLUDED everywhere: any title containing "SHS" (Student Health Services)
# -- present even at UCSF (e.g. "HC ADM SHS MGR 1"), a distinct campus-wide
# operation from the hospital system.
MEDICAL_CENTER_CAMPUSES = frozenset({"UCSF", "UCLA", "UCD", "UCI", "UCSD"})
_MEDICAL_ALWAYS_RX = _rx(
r"MED CTR",
r"^HS\s+(?:ASST\s+|ASSOC\s+)?CLIN (?:PROF|INSTR)\b",
r"-HCOMP\b",
)
_MEDICAL_CLINICAL_STAFF_RX = _rx(
r"\bPHYSICIAN\b", r"\bPHYSCN\b", r"\bNURSE\b", r"\bANESTHET",
r"\bDENTIST\b", r"\bPHARM", r"\bTHERAPIST\b", r"\bRESP THER",
r"\bPERFUSION", r"\bMIDWIFE\b", r"\bAUDIOLOG", r"\bOPTOMETR",
r"\bDIETIT", r"\bCLIN SOCIAL", r"\bRADIOLOG", r"\bCLIN LAB",
r"\bPHLEBOTOM", r"\bMED TCHN\b", r"\bNUC MED TCHNO\b", r"\bDIALYSIS\b",
r"\bULTRASOUND\b", r"\bECHOCARD", r"\bEEG\b", r"\bCATH LAB\b",
r"\bPSYCHIAT",
)
_MEDICAL_EXCLUDE_RX = _rx(r"\bSHS\b")
def is_medical_center(raw_title: str, campus: str) -> bool:
t = normalise(raw_title)
if any(rx.search(t) for rx in _MEDICAL_EXCLUDE_RX):
return False
if any(rx.search(t) for rx in _MEDICAL_ALWAYS_RX):
return True
if campus in MEDICAL_CENTER_CAMPUSES:
return any(rx.search(t) for rx in _MEDICAL_CLINICAL_STAFF_RX)
return False