Disclaimer:
AIGovernanceis a statistical and documentation support tool. It does not provide legal advice or certify compliance.
An employer that selects 60% of applicants in two successive years may look stable. The stable rate can hide a rule that became more lenient for some applicants and stricter for others. A falling rate may come from a weaker applicant pool while the rule stays the same. Or the rate may change because cases are routed to different recruiters. The four-fifths rule and the NYC Local Law 144 impact ratio compare groups within one period, so they cannot separate these explanations.
aigov_drift() compares two periods and reports three
things:
hiring_panel contains 4,500 simulated screening
decisions by 24 recruiters over three years. It was built with known
mechanisms: weaker applicant pools, offsetting rule changes for
applicants with and without a degree, routing of strong applicants to
lenient recruiters, and a higher bar for “advance”.
library(AIGovernance)
data(hiring_panel)
round(with(hiring_panel, tapply(selected, list(group, period), mean)), 3)
#> 2024 2025 2026
#> A 0.610 0.531 0.437
#> B 0.559 0.469 0.375
#> C 0.594 0.483 0.417Selection fell for every group. The question is why.
gov <- aigov_build(hiring_panel[hiring_panel$period == 2026, ],
selected, group, ref_group = "A")
#> ✔ AIGovernance object built: 1500 records,
#> ℹ Disclaimer: AIGovernance is a statistical support tool.
gov <- aigov_adverse_impact(gov)
#>
#> ── EEOC Adverse Impact (4/5ths Rule) ──
#>
#> # A tibble: 3 × 8
#> group n n_selected selection_rate AIR fourfifths_flag p_value
#> <chr> <int> <int> <dbl> <dbl> <lgl> <dbl>
#> 1 A 749 327 0.437 1 FALSE NA
#> 2 B 437 164 0.375 0.860 FALSE 0.0387
#> 3 C 314 131 0.417 0.956 FALSE 0.560
#> # ℹ 1 more variable: small_n_flag <lgl>
#> ✔ Result: PASS — No group flagged for adverse impact (AIR >= 0.80 for all groups).
#> ℹ Disclaimer: The 4/5ths rule is a statistical rule of thumb.The impact ratios describe 2026 alone. They say nothing about what changed.
gov <- aigov_build(hiring_panel, selected, group, ref_group = "A")
#> ✔ AIGovernance object built: 4500 records,
#> ℹ Disclaimer: AIGovernance is a statistical support tool.
gov <- aigov_drift(gov, period = period,
covariates = c("qualification", "degree"),
unit = recruiter, baseline = 2024, current = 2026,
B = 50, seed = 1)
#>
#> ── Decision Drift Audit ──
#>
#> • Periods : 2024 (baseline) vs 2026 (current)
#> • Decision : selected [0 / 1], focal = 1
#> • Covariates : qualification, degree
#> • Units : recruiter
#> • Reference Q : pooled; overlap trim = 0.02; tolerance = 0.05
#> • Analysis n : 3000; bootstrap replicates used = 50
#> Decomposition of the change in P(D = 1):
#> group rate_baseline rate_current raw_change case_mix allocation rule_change
#> Overall 0.5907 0.4147 -0.1760 -0.0817 -0.0897 -0.0046
#> A 0.6100 0.4366 -0.1734 -0.0763 -0.1034 0.0062
#> B 0.5588 0.3753 -0.1835 -0.0862 -0.0726 -0.0247
#> C 0.5936 0.4172 -0.1764 -0.0931 -0.0807 -0.0027
#> Net and absolute drift over the reference case mix:
#> group net_drift net_lo net_hi abs_drift abs_drift_bc abs_lo
#> Overall -0.0783 -0.1058 -0.0539 0.1468 0.1441 0.1279
#> A -0.0835 -0.1217 -0.0533 0.1496 0.1464 0.1305
#> B -0.0780 -0.1129 -0.0395 0.1476 0.1467 0.1230
#> C -0.0658 -0.1105 -0.0383 0.1387 0.1343 0.1212
#> alert
#> net drift
#> net drift
#> conditional drift
#> conditional drift
#> Unit-level drift (24 units):
#> • Mean unit drift = -0.0405; between-unit SD (noise-corrected) = 0.0226;
#> reliability = 0.06
#> • Mean |shrunken unit drift| = 0.0398; share beyond tolerance = 0
#> • Units flagged (BH q < 0.05 and |shrunken drift| > tolerance): 0
#> ✖ Result: REVIEW — drift beyond tolerance detected; examine rule changes, coding, model versions and override practice.
#> ℹ Drift is conditional on recorded characteristics; it does not identify the causal effect of an AI system.Read the output in this order:
Because the data are simulated, the truth is known:
tr <- attr(hiring_panel, "truth")
tr$decomposition
#> # A tibble: 4 × 4
#> group true_case_mix true_allocation true_rule_change
#> <chr> <dbl> <dbl> <dbl>
#> 1 Overall -0.0935 -0.0726 0.00872
#> 2 A -0.0876 -0.0829 0.0134
#> 3 B -0.0978 -0.0592 0.00270
#> 4 C -0.107 -0.0666 0.00597
tr$drift[, c("group", "true_net_drift", "true_abs_drift")]
#> # A tibble: 4 × 3
#> group true_net_drift true_abs_drift
#> <chr> <dbl> <dbl>
#> 1 Overall -0.0494 0.125
#> 2 A -0.0557 0.128
#> 3 B -0.0380 0.123
#> 4 C -0.0512 0.122The estimates are close to the truth. The net drift estimate is about
two standard errors from its true value of -0.05, which is ordinary
sampling error at 1,500 cases per period. Repeated simulation with
aigov_simulate_drift() shows no systematic bias for this
design.
The binary outcome selected only records passing the
first screen. With the three-category decision the audit also sees
movement between “review” and “advance”.
gov <- aigov_drift(gov, period = period,
covariates = c("qualification", "degree"),
decision = decision, focal = "advance",
baseline = 2024, current = 2026, B = 50, seed = 1,
verbose = FALSE)
d <- gov$results$drift$decomposition
d[d$group == "Overall", c("category", "raw_change", "case_mix", "rule_change", "net_drift")]
#> # A tibble: 3 × 5
#> category raw_change case_mix rule_change net_drift
#> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 reject 0.176 0.0804 0.0956 0.0789
#> 2 review -0.0587 -0.0172 -0.0415 -0.0252
#> 3 advance -0.117 -0.0632 -0.0541 -0.0536The “reject” rate rose and both “review” and “advance” fell. A binary
audit of selected would miss the extra movement from
“advance” to “review” for applicants who still pass the first
screen.
gov <- aigov_monitor(gov, period = period,
covariates = c("qualification", "degree"),
B = 50, seed = 1)
#>
#> ── Decision Drift Monitoring ──
#>
#> 2 comparison(s), baseline design; net-drift p-values adjusted by holm.
#> comparison group net_drift net_p_tol_adj abs_drift_bc abs_lo
#> 2024 -> 2025 Overall -0.0400 1.0000 0.0860 0.0698
#> 2024 -> 2025 A -0.0377 1.0000 0.0873 0.0643
#> 2024 -> 2025 B -0.0395 1.0000 0.0850 0.0708
#> 2024 -> 2025 C -0.0465 1.0000 0.0840 0.0725
#> 2024 -> 2026 Overall -0.0783 0.4408 0.1417 0.1285
#> 2024 -> 2026 A -0.0835 0.7278 0.1447 0.1285
#> 2024 -> 2026 B -0.0780 0.9158 0.1434 0.1226
#> 2024 -> 2026 C -0.0658 1.0000 0.1316 0.1168
#> alert_adj units_flagged
#> masked drift NA
#> masked drift NA
#> masked drift NA
#> masked drift NA
#> conditional drift NA
#> conditional drift NA
#> conditional drift NA
#> conditional drift NA
#> ✖ Result: REVIEW — at least one comparison shows drift beyond tolerance.Each later period is compared with the baseline, and net-drift p-values are adjusted for the number of comparisons. In 2025 the net change is within tolerance but the absolute change is not: this is masked drift. By 2026 the absolute change is larger still. The net change is near the tolerance, and after adjustment for the number of comparisons it is not clearly beyond it, so those rows are reported as conditional drift. This is valid for prespecified comparisons. Repeated looks at accumulating data need sequential methods.
aigov_simulate_drift() generates data under known
mechanisms, so the false-alarm rate and power of any audit can be
studied. For example, a pure change in case mix should not raise an
alert:
sim <- aigov_simulate_drift(n = 1500, scenario = "case_mix", seed = 3)
g <- aigov_build(sim, selected, group, ref_group = "A")
#> ✔ AIGovernance object built: 3000 records,
#> ℹ Disclaimer: AIGovernance is a statistical support tool.
g <- aigov_drift(g, period = period, covariates = c("qualification", "degree"),
B = 50, seed = 1, verbose = FALSE)
g$results$drift$drift[, c("group", "net_drift", "abs_drift_bc", "alert")]
#> # A tibble: 4 × 4
#> group net_drift abs_drift_bc alert
#> <chr> <dbl> <dbl> <chr>
#> 1 Overall -0.0216 0.0257 none
#> 2 A -0.0154 0.0262 none
#> 3 B -0.0267 0.0262 none
#> 4 C -0.0292 0.0233 noneDrift is a change in decisions conditional on the recorded
characteristics. It does not identify the causal effect of an AI system
or of human discretion. A change in an unrecorded, decision-relevant
characteristic appears as drift (scenario "unobserved" in
the simulator). An alert is a reason to review rule changes, data
coding, model versions and override practice. It is not a finding of
harm.
Kitagawa, E. M. (1955). Components of a difference between two rates. Journal of the American Statistical Association, 50(272), 1168–1194.
Cai, T. T., Namkoong, H., & Yadlowsky, S. (2023). Diagnosing model performance under distribution shift. arXiv:2303.02011.