Auditing Decision Drift Over Time

Subir Hait, Michigan State University

Disclaimer: AIGovernance is a statistical and documentation support tool. It does not provide legal advice or certify compliance.

Why a single-period audit is not enough

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:

  1. A decomposition of the change in each decision rate into case mix, allocation of cases to decision units, and within-unit rule change.
  2. Net drift \(\Delta_g\): the average change in the decision rule for comparable cases, over a fixed reference case mix.
  3. Absolute drift \(A_g\): the average size of the change in the rule, so that increases and decreases do not cancel. A large \(A_g\) with a small \(\Delta_g\) is masked drift.

Example data

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.417

Selection fell for every group. The question is why.

Single-period audits

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.

Drift audit

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.122

The 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.

Categorical decisions

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.0536

The “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.

Monitoring several periods

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.

Evaluating the audit by simulation

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 none

What the audit does not establish

Drift 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.

References

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.