Skip to contents

titleclassifier turns the free-text officer and key-employee titles reported on IRS Form 990 Part VII into a structured, analysis-ready taxonomy. The work is done by a nine-step pipeline. Each step is a single function you can run on its own, and each has its own article on this site, with before-and-after examples and the crosswalks it uses.

Step Function What it does Assets
01 standardize_df() Clean the raw table: titles, numeric pay and hours, 0/1 checkboxes. Part VII schema
02 remove_dates() Strip and flag dates. date.words
03 standardize_conj() One separator (&) between titles. likely.titles
04 split_titles() One row per title.
05 standardize_spelling() Expand abbreviations, fix spelling. fix_* rules, possible.titles, likely.subjects, hunspell
06 gen_status_codes() Flag and remove FORMER, INTERIM, EX OFFICIO, … status.codes, number.words
07 standardize_titles() Map to a standard title. title.xwalk, title.patterns
08 categorize_titles() Attach the taxonomy; add per-filing features. title.taxonomy
09 conditional_logic() Resolve each person’s role from checkboxes, pay, hours and title. decision rules; the role model for 990-EZ

Rules and models explains how the rule-based steps and the 990-EZ role model differ. Taxonomy defines every output category, and Performance reports coverage and accuracy.

Getting the data

Part VII compensation tables (F9-P07-T01-COMPENSATION) are published in the public NCCS efile store, one file per tax year. The panel990 package downloads and reads them, caching each file on disk:

library(panel990)
dl   <- download_tables(years = 2022, tables = "F9-P07-T01-COMPENSATION")
pvii <- read_tables(dl)$tables[[1]]   # 5.07 million rows, 37 columns

titleclassifier has its own small helpers for the same files:

library(titleclassifier)

# fetch + read the 2023 Part VII table in one call
d <- get_partvii(2023)

# or split the two steps (download once, re-read from cache)
paths <- fetch_partvii(2023, dest = "efdata")
d     <- read_partvii(paths)

For examples the package ships a demo slice, tinypartvii, already in the expected schema.

library(titleclassifier)
library(dplyr)

data(tinypartvii)
set.seed(1234)
d <- dplyr::sample_n(tinypartvii, 300)
dim(d)
#> [1] 300  33

Running the pipeline

The steps compose with the base pipe. The title-standardization, status-code, and taxonomy crosswalks are package data, so a run needs no network access.

df <- d |>
  standardize_df()    |>   # 01
  remove_dates()      |>   # 02
  standardize_conj()  |>   # 03
  split_titles()      |>   # 04
  standardize_spelling() |> # 05
  gen_status_codes()  |>   # 06
  standardize_titles() |>  # 07
  categorize_titles() |>   # 08
  conditional_logic()      # 09
#> [OK] standardize df step complete
#> [OK] remove dates step complete
#> [OK] standardize conjunctions step complete
#> [OK] split titles step complete
#> [OK] standardize spelling step complete
#> [OK] generate status codes step complete
#> [OK] standardize titles step complete
#> [OK] categorize titles step complete
#> 
#> [OK] conditional logic step complete

dim(df)
#> [1] 315 112

The result

Each row is one standardized title for one person, carrying the raw text, the successive cleaned versions (title.v2 … title.v7), the canonical title.standard, taxonomy fields, status flags, organization-level features (pay/hours ranks, leadership counts), and the person’s resolved role.

df |>
  select(title.raw, title.standard, domain.label, tot.comp, tot.hours, role.final, role.ceo) |>
  arrange(desc(tot.comp)) |>
  head(10)
#>                     title.raw          title.standard       domain.label
#> 1                   PRESIDENT         BOARD PRESIDENT              board
#> 2                   SECRETARY         BOARD SECRETARY              board
#> 3                  CONTROLLER             COMPTROLLER            finance
#> 4                    DIRECTOR            BOARD MEMBER              board
#> 5            STAFF PHARMACIST              PHARMACIST            medical
#> 6            SR ADMINISTRATOR           ADMINISTRATOR     administration
#> 7          DIRECTOR OF EVENTS                DIRECTOR     administration
#> 8                    EMPLOYEE                EMPLOYEE     administration
#> 9  INTERIM EXECUTIVE DIRECTOR      EXECUTIVE DIRECTOR general management
#> 10    CHIEF FINANCIAL OFFICER CHIEF FINANCIAL OFFICER            finance
#>    tot.comp tot.hours   role.final role.ceo
#> 1   2389606      40.0          CEO  imputed
#> 2    444028      53.0          CEO  imputed
#> 3    235319      37.5          CEO  imputed
#> 4    217880      55.0          CEO  imputed
#> 5    203361      42.0 PROFESSIONAL     <NA>
#> 6    190745      40.0          CEO  imputed
#> 7    188401      40.0          CEO  imputed
#> 8    132955      40.0        STAFF     <NA>
#> 9    115500      40.0          CEO  interim
#> 10   108251      40.0          CEO  imputed

role.primary marks one row per person, so people (not titles) are counted with it:

people <- filter(df, role.primary)
count(people, formtype, role.final)
#>   formtype   role.final   n
#> 1      990        BOARD 192
#> 2      990          CEO  20
#> 3      990      OFFICER   2
#> 4      990 PROFESSIONAL   1
#> 5      990        STAFF   2
#> 6    990EZ        BOARD  79
#> 7    990EZ          CEO   2
#> 8    990EZ      OFFICER   1
#> 9    990EZ        STAFF   1

The successive title versions make the cleaning transparent — you can see exactly what each step changed:

df |>
  select(title.raw, title.v3, title.v5, title.v6, title.standard) |>
  distinct() |>
  head(12)
#>             title.raw              title.v3              title.v5
#> 1         H&W TRUSTEE       H AND W TRUSTEE       H AND W TRUSTEE
#> 2              MEMBER                MEMBER                MEMBER
#> 3            DIRECTOR              DIRECTOR              DIRECTOR
#> 4  2ND VICE PRESIDENT SECOND VICE PRESIDENT SECOND VICE PRESIDENT
#> 5             Officer               OFFICER               OFFICER
#> 6                 CEO                   CEO                   CEO
#> 7           President             PRESIDENT             PRESIDENT
#> 8           Secretary             SECRETARY             SECRETARY
#> 9           treasurer             TREASURER             TREASURER
#> 10          Treasurer             TREASURER             TREASURER
#> 11           Director              DIRECTOR              DIRECTOR
#> 12     Vice President        VICE PRESIDENT        VICE PRESIDENT
#>           title.v6       title.standard
#> 1  H AND W TRUSTEE         BOARD MEMBER
#> 2           MEMBER         BOARD MEMBER
#> 3         DIRECTOR         BOARD MEMBER
#> 4   VICE PRESIDENT BOARD VICE PRESIDENT
#> 5          OFFICER         BOARD MEMBER
#> 6              CEO                  CEO
#> 7        PRESIDENT      BOARD PRESIDENT
#> 8        SECRETARY      BOARD SECRETARY
#> 9        TREASURER      BOARD TREASURER
#> 10       TREASURER      BOARD TREASURER
#> 11        DIRECTOR         BOARD MEMBER
#> 12  VICE PRESIDENT BOARD VICE PRESIDENT

Editing the crosswalks

The crosswalks are package data built from the CSV tables in data-raw/crosswalks/. To change one, edit its table in a source checkout and rebuild:

# from the package root
source("data-raw/crosswalks/build-crosswalks.R")
xwalk <- get_title_xwalk()

See the Dictionaries and crosswalks article for what each crosswalk contains.