""" analyze_asst_prof_comp.py ========================== One-off analysis: median total compensation (salary + benefits) for non-medical Assistant Professors across the whole UC system, 2024. Data source: CA State Controller's Government Compensation in California (GCC) dataset, restored from this repo's git history (gcc_data/GCC_*_2024.csv) because the UC-wages source uc_salary_pipeline.py normally uses has no benefits field at all. "Total compensation" = TotalWages (cash pay) + TotalRetirementAndHealthContribution (employer-paid health/dental/vision + retirement + deferred comp) -- the standard total-comp figure used for this dataset (e.g. by Transparent California). "Non-medical" reuses classifier.py's existing SENATE_FACULTY / is_medical_center logic, which already flags HCOMP / HS-CLIN-PROF / clinical-workforce titles as medical regardless of campus. "Assistant Professor" is a rank-level filter this repo doesn't otherwise do (classify_title() only buckets at the Senate-faculty-vs-not level) -- _ASST_RANK_RX below mirrors classifier.py's own _SENATE_RX patterns, restricted to the assistant-rank variants. """ import csv import glob import re import statistics from classifier import classify_title, is_medical_center, normalise EMPLOYER_TO_CAMPUS = { "University of California, Berkeley": "UCB", "University of California, Los Angeles": "UCLA", "University of California, Davis": "UCD", "University of California, Irvine": "UCI", "University of California, Santa Barbara": "UCSB", "University of California, Riverside": "UCR", "University of California, Santa Cruz": "UCSC", "University of California, Merced": "UCM", "University of California, Office of the President": "UCOP", "University of California, San Diego": "UCSD", "University of California, San Francisco": "UCSF", } # Assistant-rank Senate faculty titles only -- mirrors classifier.py's # _SENATE_RX but excludes bare/ASSOC/full-professor variants. # "ASST ACT PROF" (reversed order = non-Senate, per classifier.py's own # docstring) is NOT matched here and is excluded anyway by the # SENATE_FACULTY-only filter in main(). _ASST_RANK_RX = [re.compile(p, re.IGNORECASE) for p in ( r"^ASST\s+PROF-(?:AY|FY|HCOMP|SFT-VM)\b", r"^ASST\s+PROF-AY-", r"^ASST\s+PROF-FY-", r"^ASST\s+PROF OF TEACH\b", r"^ASST\s+PROF OF TEACH-", r"^ASST\s+PROF OF CLIN\b", r"^ASST\s+PROF OF CLIN-", r"^ASST\s+PROF IN RES\b", r"^ASST\s+PROF IN RES-", r"^ACT\s+ASST\s+PROF\b", r"^ACT\s+ASST\s+PROF-", r"^ACT\s+ASST\s+PROF OF TEACH\b", r"^ACT\s+ASST\s+PROF OF TEACH-", r"^ACT\s+ASST\s+PROF OF CLIN\b", r"^ACT\s+ASST\s+PROF OF CLIN-", r"^ACT\s+ASST\s+PROF IN RES\b", )] # Other Senate-faculty patterns (non-assistant) so we can flag anything # that matched SENATE_FACULTY but neither this nor the assistant patterns # recognise -- a diagnostic for titles the rank regex list above missed. _NON_ASST_SENATE_RX = [re.compile(p, re.IGNORECASE) for p in ( r"^(?:ASSOC\s+)?PROF-(?:AY|FY|HCOMP|SFT-VM)\b", r"^(?:ASSOC\s+)?PROF-AY-", r"^(?:ASSOC\s+)?PROF-FY-", r"^(?:ASSOC\s+)?PROF OF TEACH\b", r"^(?:ASSOC\s+)?PROF OF TEACH-", r"^(?:ASSOC\s+)?PROF OF CLIN\b", r"^(?:ASSOC\s+)?PROF OF CLIN-", r"^(?:ASSOC\s+)?PROF IN RES\b", r"^(?:ASSOC\s+)?PROF IN RES-", 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+PROF OF TEACH\b", r"^ACT\s+PROF OF TEACH-", r"^ACT\s+ASSOC\s+PROF OF TEACH\b", r"^ACT\s+ASSOC\s+PROF OF TEACH-", r"^ACT\s+PROF OF CLIN\b", r"^ACT\s+PROF OF CLIN-", r"^ACT\s+ASSOC\s+PROF OF CLIN\b", r"^ACT\s+ASSOC\s+PROF OF CLIN-", r"^ACT\s+PROF IN RES\b", r"^ACT\s+ASSOC\s+PROF IN RES\b", r"^INSTR\b", r"^INSTR-", r"^INSTRUCTOR\b", r"^(?:ASSOC\s+)?INSTR IN RES\b", r"^REGENTS'? PROF\b", r"\bPROF(?:ESSOR)?[-\s]EMERITUS\b", r"\bPROF(?:ESSOR)?\s+EMERITUS\b", r"^RECALL FACULTY\b", r"^(?:ASSOC\s+)?AGRON AES(?:-AY|-FY|-B/E/E|-SFT-VM)?$", r"^(?:ASSOC\s+)?ASTRONOMER(?:-AY|-FY)?$", )] def is_assistant_rank(raw_title: str) -> bool: t = normalise(raw_title) return any(rx.search(t) for rx in _ASST_RANK_RX) def is_recognised_non_assistant_senate(raw_title: str) -> bool: t = normalise(raw_title) return any(rx.search(t) for rx in _NON_ASST_SENATE_RX) def parse_money(s: str) -> float: s = (s or "").replace(",", "").replace("$", "").strip() return float(s) if s else 0.0 def main(): rows = [] unrecognised_senate_titles = set() for path in sorted(glob.glob("gcc_data/GCC_*_2024.csv")): if path.endswith("GCC_ALL_2024.csv"): continue with open(path, newline="", encoding="utf-8-sig") as f: for row in csv.DictReader(f): employer = row["EmployerName"] campus = EMPLOYER_TO_CAMPUS.get(employer) if campus is None: raise ValueError(f"Unmapped EmployerName: {employer!r} in {path}") title = row["Position"] if classify_title(title) != "SENATE_FACULTY": continue if is_medical_center(title, campus): continue if not is_assistant_rank(title): if not is_recognised_non_assistant_senate(title): unrecognised_senate_titles.add(title) continue total_comp = (parse_money(row["TotalWages"]) + parse_money(row["TotalRetirementAndHealthContribution"])) rows.append({"campus": campus, "title": title, "total_comp": total_comp}) if unrecognised_senate_titles: print("NOTE: Senate-faculty titles not matched by any rank pattern " "(not counted as assistant, but check these aren't mis-missed asst titles):") for t in sorted(unrecognised_senate_titles): print(f" - {t}") print() all_comp = [r["total_comp"] for r in rows] print(f"Non-medical Assistant Professors, UC system-wide, 2024: n={len(rows)}") print(f"Median total compensation (salary + benefits): ${statistics.median(all_comp):,.2f}") print(f"Mean total compensation: ${statistics.mean(all_comp):,.2f}") print() print(f"{'Campus':<8}{'N':>6}{'Median':>16}") for campus in sorted(EMPLOYER_TO_CAMPUS.values()): campus_comp = [r["total_comp"] for r in rows if r["campus"] == campus] if campus_comp: print(f"{campus:<8}{len(campus_comp):>6}{statistics.median(campus_comp):>16,.2f}") else: print(f"{campus:<8}{0:>6}{'--':>16}") if __name__ == "__main__": main()