uc_salary_aggregator/analyze_asst_prof_comp.py
2026-09-10 21:08:44 +02:00

166 lines
6.6 KiB
Python

"""
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()