| .gitignore | ||
| classifier.py | ||
| Dockerfile | ||
| README.md | ||
| uc_salary_pipeline.py | ||
| uc_wages_downloader.py | ||
UC Salary Pipeline
Downloads University of California employee wage data and classifies every employee into one of 8 analytical categories (Senate Faculty, Athletics, Senior Management, etc.), producing per-employee, aggregate, and top-earner CSVs.
What it does
- Downloads raw wage JSON for every requested campus/year from ucinvestments/UC-wages, a public dataset (not an API) sourced from UC Annual Wage, covering 2010 onward for all 10 campuses plus UCOP, ASUCLA, and UC SF Law.
- Classifies every employee record by job title (and, for non-academic staff, gross pay) into one of 8 categories, and separately flags whether they work at a UC medical center.
- Writes CSVs: always a per-employee CSV, plus optional aggregate and top-earners CSVs, plus a formatted summary table printed to the terminal.
Architecture
Three files, each with one job:
uc_wages_downloader.py ──▶ classifier.py ──▶ uc_salary_pipeline.py
(data acquisition) (classification engine) (orchestrator / CLI)
-
classifier.py— the classification engine, and only the classification engine. It has no knowledge of downloading, file formats, or CSVs — it's a pure function library: give it a job title string (and optionally a gross pay figure and campus code), get back a category and/or a medical-center flag. This is deliberate: it's the part of the tool that encodes UC-specific domain knowledge (Academic Senate membership rules, which job codes mean what), and it's kept importable on its own —python -c "import classifier"works with zero other dependencies. See Classification logic below for how it works. -
uc_wages_downloader.py— fetches raw per-campus/per-year JSON files from the UC-wages GitHub repo into a local cache directory (uc_wages_data/by default). Knows nothing about classification. Also runnable standalone (python uc_wages_downloader.py) if you just want the raw data. -
uc_salary_pipeline.py— the orchestrator and CLI entry point. Calls the downloader, reads the cached JSON, maps UC-wages' field names onto whatclassifier.pyexpects, callsclassifier.classify_title()/classifier.is_medical_center()per record, and writes the output CSVs. This is the script you actually run.
Quick start
# Download every campus, every available year, and classify everything
# (~1.3GB download, multi-million-row output — see --years/--campuses to scope it down):
python uc_salary_pipeline.py
# A specific slice:
python uc_salary_pipeline.py --years 2023 2024 --campuses UCB UCLA
# Re-classify already-downloaded data without hitting the network:
python uc_salary_pipeline.py --skip-download
# Full example with all optional outputs:
python uc_salary_pipeline.py --years 2024 --campuses UCB UCLA UCSF \
--aggregate-output agg.csv --group-by year campus medical_center \
--top-earners-output top.csv --top-n 20
Downloaded JSON is cached in uc_wages_data/ (gitignored) and reused on
every subsequent run — nothing is re-downloaded unless you delete it.
uc_salary_pipeline.py flags
| Flag | Default | What it does |
|---|---|---|
--years Y [Y ...] |
2010–current year |
Years to process. |
--campuses C [C ...] |
all | Campuses to process. Choices: ASUCLA UCB UCD UCI UCLA UCM UCR UCSD UCSF UCSB UCSC UCOP UC_SF_LAW. |
--data-dir DIR |
uc_wages_data |
Local cache directory for downloaded JSON. |
--output PATH |
uc_salary_classified.csv |
Per-employee output CSV path. |
--aggregate-output PATH |
(off) | If given, also write a category-level rollup CSV (employee count, total/mean/median gross pay). |
--group-by [DIM ...] |
year |
Extra breakdown dimensions for --aggregate-output and --top-earners-output, alongside category. Choices: year, campus, medical_center. Pass none to collapse to one row per category; pass all three for the finest breakdown. |
--top-earners-output PATH |
(off) | If given, also write the top --top-n gross-pay earners per category (respects --group-by). |
--top-n N |
10 |
How many top earners per group, for --top-earners-output. |
--skip-download |
off | Classify whatever's already cached in --data-dir; never hits the network. |
--quiet |
off | Suppress the formatted per-year summary table normally printed to stdout. |
--senior-threshold N |
100000 |
Gross pay at/above which non-academic staff are SENIOR_PROF_STAFF instead of PROF_STAFF. |
--support-threshold N |
40000 |
Gross pay below which non-academic staff are SUPPORT_STAFF instead of PROF_STAFF. |
-h, --help |
— | Full usage (argparse-generated). |
Category labels in every output (e.g. "Senior Prof/Staff (>=$100k)")
automatically reflect whatever --senior-threshold/--support-threshold
you actually pass — they're not hardcoded strings.
uc_wages_downloader.py flags
Only needed directly if you want to pre-fetch data without classifying it
yet (uc_salary_pipeline.py calls this automatically unless you pass
--skip-download).
| Flag | Default | What it does |
|---|---|---|
--years Y [Y ...] |
2010–current year |
Years to download. |
--campuses C [C ...] |
all | Campuses to download (same choices as above). |
--out-dir DIR |
uc_wages_data |
Where to write the JSON files. |
--dry-run |
off | Print the URLs that would be fetched; download nothing. |
--timeout N |
60 |
HTTP timeout in seconds per request. |
--retries N |
3 |
Retry attempts per file on transient failure. |
Downloads go straight to raw.githubusercontent.com (no GitHub API calls,
so no rate-limit risk). A campus/year combination that doesn't exist
upstream (e.g. ASUCLA before 2018) returns a 404 and is logged and skipped,
not treated as an error. Already-downloaded files are never re-fetched.
Output files
Per-employee CSV (--output, always written)
One row per employee per year:
year, campus, medical_center, title, category, category_label,
gross_pay, regular_pay, overtime_pay, other_pay, first_name, last_name
Aggregate CSV (--aggregate-output, optional)
One row per category (further broken down by --group-by):
[year] [campus] [medical_center] category, category_label,
employee_count, total_gross_pay, mean_gross_pay, median_gross_pay
Top-earners CSV (--top-earners-output, optional)
The top --top-n gross-pay earners per category (also broken down by
--group-by), ranked:
category, category_label, rank, year, campus, medical_center, title,
gross_pay, regular_pay, overtime_pay, other_pay, first_name, last_name
Console summary (default; suppress with --quiet)
A formatted table printed to stdout, one per year processed, showing employee count / total pay / median pay per category — a quick sanity check without opening any CSV.
Classification logic
All in classifier.py. Two independent things are computed per employee:
1. Category (classify_title())
Every employee falls into exactly one of 8 categories, organized around whether a title carries UC Academic Senate membership (Regents Standing Order 105.1), tested in this order (earlier patterns win):
STUDENT_TRAINEE— TAs, GSRs, postdocs, residents, interns, student workersATHLETICS— coaches and athletics staff (split out so bonus-heavy coaching pay doesn't distort management totals)SENIOR_MANAGEMENT— Chancellor/Provost/VP/Dean/Executive Director tierSENATE_FACULTY— ladder professors, instructors, and other Senate-membership titlesNON_SENATE_ACADEMIC— lecturers, adjuncts, HS clinical professors, project scientists, librarians, academic coordinators, etc.SENIOR_PROF_STAFF/PROF_STAFF/SUPPORT_STAFF— everyone else (non-academic staff), split purely by gross pay against--senior-threshold/--support-threshold
Titles are matched with regex against a normalized (uppercased,
whitespace-collapsed, abbreviation-expanded) form of the raw title string —
see normalise(). The category, its human-readable label, and the pay
thresholds are all defined at the top of classifier.py.
2. Medical center flag (is_medical_center())
Orthogonal to category — an employee can be SENATE_FACULTY and
medical_center=True (e.g. a med-school ladder professor on the HCOMP pay
plan). Two-tier rule, built from actually surveying UC-wages title text
rather than guessed:
- Always (any campus): titles containing
MED CTR(hospital facilities/exec titles), the HS Clinical Professor/Instructor series, or bare-HCOMPladder titles. - Only at the five campuses that operate a UC-owned academic medical
center (
UCSF,UCLA,UCD,UCI,UCSD): generic clinical-workforce titles — physicians, nurses, dentists, pharmacists, therapists, lab/rad techs, etc. These titles are identical at every campus (Student Health Services exists everywhere), so outside the 5 hospital campuses they can't be reliably told apart from student health staff by title text alone. - Always excluded: any title containing
SHS(Student Health Services), even at UCSF — it's a distinct, campus-wide operation from the hospital system.
This is a title-text heuristic against a data source with no
department/org-unit field, not a perfect org-chart lookup — it won't catch
things like a bare DEAN title that happens to be the Dean of Medicine
(indistinguishable from any other school's dean by title text alone).
Data notes
- UC-wages has no
benefitsfield (unlike the old California State Controller GCC data this tool previously used), sogross_payis the only pay figure available —regular_pay + overtime_pay + other_pay. - Employee names are pre-redacted by the upstream source for some records
(
firstname/lastnameshow as*****) — this is not something this tool does. - The full dataset is ~1.3GB across ~170 files.
uc_wages_data/is gitignored; nothing here commits downloaded data to the repo.