Turn the free-text titles on IRS Form 990 Part VII into a structured taxonomy of nonprofit leadership.
Part VII lists every officer, director, trustee, key employee and highly paid person at a nonprofit, each with a title the filer typed: “Pres. & CEO”, “Sec/Treas”, “Board Member (thru 6/2023)”, “Exec. V.P.”. titleclassifier cleans and standardizes those titles, places each in a taxonomy (functional domain, occupation code, employee level or board role), and resolves who each person really is in the organization: the chief executive, an officer, a manager, a professional, staff, or a board seat.
| Filed title | Standard title | Domain | Role |
|---|---|---|---|
| Pres. & CEO | CEO | executive / general management | CEO |
| Sec/Treas | BOARD SECRETARY + BOARD TREASURER | governance / board | BOARD (SECRETARY) |
| Board Member (thru 6/2023) | BOARD MEMBER, flagged partial year | governance / board | BOARD (MEMBER) |
| Exec. V.P. | EXECUTIVE VICE PRESIDENT | executive / general management | OFFICER |
| Dir of Development | DIRECTOR OF DEVELOPMENT | operations / fundraising and membership | MANAGER |
| Staff Physician | DOCTOR | industry-specific / medical | PROFESSIONAL |
The role depends on the filing as well as the title: a PRESIDENT is the CEO when paid to run the organization and the board chair when not. Roles above are for a typical filer.
Installation
# install.packages("pak")
pak::pak("Nonprofit-Open-Data-Collective/titleclassifier")To classify 990-EZ returns with the role model, also install xgboost (optional; without it the rules decide):
pak::pak("xgboost")Get a Part VII table
Part VII tables are published in the NCCS efile store, one file per tax year. The panel990 package downloads and reads them:
pak::pak("Nonprofit-Open-Data-Collective/panel990")
library(panel990)
dl <- download_tables(years = 2022, tables = "F9-P07-T01-COMPENSATION")
pvii <- read_tables(dl)$tables[[1]]
dim(pvii)
#> [1] 5073368 37
# the fields titleclassifier uses, for one 990-EZ row and one paid officer on a 990
keep <- c("EIN2", "OBJECTID", "RETURN_TYPE", "TAX_YEAR", grep("^F9_07_COMP_DTK_(TITLE|AVE|POS|COMP|EMPL)", names(pvii), value = TRUE))
i <- c(which(pvii$RETURN_TYPE == "990EZ")[1],
which(pvii$RETURN_TYPE == "990" & pvii$F9_07_COMP_DTK_POS_OFF_X %in% "X" & pvii$F9_07_COMP_DTK_COMP_ORG > 0)[1])
str(pvii[i, keep])
#> 'data.frame': 2 obs. of 17 variables:
#> $ EIN2 : chr "EIN-01-0015091" "EIN-01-0018922"
#> $ OBJECTID : chr "OID-202301429349201060" "OID-202510359349300206"
#> $ RETURN_TYPE : chr "990EZ" "990"
#> $ TAX_YEAR : int 2022 2022
#> $ F9_07_COMP_DTK_TITLE : chr "President" "Secretary"
#> $ F9_07_COMP_DTK_AVE_HOUR_WEEK : num 6 37.5
#> $ F9_07_COMP_DTK_AVE_HOUR_WEEK_RL : num NA 0
#> $ F9_07_COMP_DTK_POS_INDIV_TRUST_X: chr NA NA
#> $ F9_07_COMP_DTK_POS_INST_TRUST_X : chr NA NA
#> $ F9_07_COMP_DTK_POS_OFF_X : chr NA "X"
#> $ F9_07_COMP_DTK_POS_KEY_EMPL_X : chr NA NA
#> $ F9_07_COMP_DTK_POS_HIGH_COMP_X : chr NA NA
#> $ F9_07_COMP_DTK_POS_FORMER_X : chr NA NA
#> $ F9_07_COMP_DTK_COMP_ORG : num 0 39301
#> $ F9_07_COMP_DTK_COMP_RLTD : num NA 0
#> $ F9_07_COMP_DTK_COMP_OTH : num 0 0
#> $ F9_07_COMP_DTK_EMPL_BEN : num 0 NAEach row is one person on one filing (OBJECTID); the 2022 table has 5.07 million rows from 555,197 filings. The fields are the title, hours at the organization and at related organizations (_RL), the six position checkboxes (trustee, institutional trustee, officer, key employee, highly compensated, former), and four pay components: from the organization, from related organizations, other compensation and benefits. A 990-EZ has no checkboxes. Names and organization fields are also in the table.
titleclassifier also ships a demo slice in the same schema, tinypartvii, and get_partvii() reads a year directly from the store.
Classify the titles
The pipeline is nine steps, each a single function:
library(titleclassifier)
df <- pvii |>
standardize_df() |> # 01 clean the table: titles, numeric pay and hours, 0/1 boxes
remove_dates() |> # 02 strip and flag dates ("thru 6/2023")
standardize_conj() |> # 03 one separator between titles ("Sec/Treas" -> SEC & TREAS)
split_titles() |> # 04 one row per title
standardize_spelling() |> # 05 expand abbreviations ("Exec. V.P." -> EXECUTIVE VICE PRESIDENT)
gen_status_codes() |> # 06 flag and remove FORMER, INTERIM, EX OFFICIO, ...
standardize_titles() |> # 07 map to a standard title (crosswalk, then pattern rules)
categorize_titles() |> # 08 attach the taxonomy and per-filing features
conditional_logic() # 09 resolve each person's roleThe result has one row per person and title, with the title at every step, title.standard, the taxonomy fields, status flags, pay and hours features, and the resolved role (role.final, role.board, role.ceo). role.primary marks one row per person.
Learn more
- Workflow: an end-to-end run.
- Pipeline steps: one article per step, with before-and-after examples and the crosswalks each step uses.
- Rules and models: how roles are decided, and when the model is used.
- Taxonomy: domains, roles, employee levels, board roles, strata and status flags.
- Performance: coverage and accuracy against hand labels.