Understanding Operational Performance in Background Verification Services: An Exploratory and Inferential Analysis
Author
Deborah Yemi-Oladayo
Published
May 14, 2026
1 Executive Summary
This study applies exploratory and inferential analytics to real operational data extracted from the activity ledger of a Nigerian background verification firm. The dataset comprises 553 verified transactions across 43 corporate clients and 6 verification categories — Guarantor, Address, Criminal, Education/Credential, Credit, and Identity — spanning 2019 to 2025, with the bulk of activity in 2024.
The central business problem is this: which factors drive completion rate in background verification engagements, and what can operational data tell us about service delivery performance? Understanding this is critical for resource allocation, client SLA management, and revenue optimisation.
Key findings reveal that Address and Criminal checks achieve the highest mean completion rates (83% and 82% respectively), while Education/Credential checks — the most frequently requested verification type — have the lowest completion rate (61%), representing a significant operational gap. Hypothesis testing confirms that completion rates differ significantly across verification categories (Kruskal-Wallis H = 24.3, p < 0.001). Correlation analysis shows that total invoice amount and quantity ordered are strongly positively correlated (r = 0.97), while completion rate is negatively correlated with order size, suggesting that bulk orders are harder to fulfil completely. A logistic regression model predicts whether a transaction has outstanding work, with verification category and order quantity as significant predictors.
The integrated recommendation is clear: the firm should invest in strengthening its Education/Credential verification process — particularly for Nigerian university certifications — through deeper institutional partnerships, as this category drives the most volume but delivers the weakest completion performance.
2 Professional Disclosure
Job Title: Managing Director
Organisation Type/Sector: The author leads three affiliated firms: an HR Consulting firm, a Background Checks and Verification company, and a Tech Services firm — all operating within Nigeria’s human capital development sector.
Technique Justification:
Exploratory Data Analysis (EDA): As Managing Director of a background checks firm, I regularly need to understand the shape and quality of our operational data before making decisions. EDA is the foundation of every operational review — identifying which verification types dominate our portfolio, where data quality issues exist, and how cost and quantity are distributed across service lines.
Data Visualisation: Our client-facing reporting and internal performance reviews depend on clear visual communication. Grammar-of-graphics principles applied here directly replicate the kind of visualisation I would present to a client or board, making this technique directly operationally relevant.
Hypothesis Testing: A recurring management question in our firm is whether certain verification types take longer or have significantly different completion rates. Formal hypothesis testing allows me to move from intuition to evidence-based operational decisions — for instance, whether to price Education checks differently given their lower completion performance.
Correlation Analysis: Understanding the relationship between order size, cost, and completion rate helps us forecast revenue, identify over-promising on large contracts, and optimise commission structures — all decisions I make regularly as MD.
Logistic Regression: Predicting whether a given transaction will have outstanding work at invoice date allows our operations team to proactively flag high-risk orders. This directly informs our service delivery model and client communication strategy.
3 Data Collection & Sampling
3.1 Source and Collection Method
The dataset was extracted directly from the proprietary Activity Ledger of the background verification business, maintained in Microsoft Excel across individual client sheets. Each sheet corresponds to one corporate client and records all verification transactions invoiced to that client. The data was collected by the author in his capacity as Managing Director, with full organisational authorisation.
3.2 Variables
The dataset contains the following key variables:
Variable
Type
Description
Invoice Date
Date
Date transaction was invoiced
Client
Categorical
Client identifier (anonymised to sheet code)
Verification_Category
Categorical
Standardised verification type
Quantity (X)
Numeric
Number of verifications ordered
Cost Per Verification
Numeric
Unit cost charged to client (₦)
Total Amount (A)
Numeric
Total invoice value (₦)
Number of Job Completed (Y)
Numeric
Jobs completed at invoice date
Total Cost Incurred (B)
Numeric
Actual operational cost incurred
Completion_Rate
Numeric
Computed: Y/X, clipped to [0,1]
Has_Outstanding
Binary
1 if any jobs remain incomplete
Year / Month / Quarter
Numeric
Derived temporal variables
3.3 Sampling Frame and Period
Population: All background verification transactions invoiced by the firm
Sample: 553 transactions with complete, parseable date and quantity data
Period: September 2019 to December 2025 (predominantly 2024)
Clients: 43 corporate clients across various industries
3.4 Ethical Statement
All client identifiers have been retained as internal codes (e.g. “HE”, “AF”, “FL”) and no personally identifiable information about individual verification subjects is included. The data is used with the author’s organisational authority as Managing Director. The firm’s data is not publicly shared; this document will be anonymised before any public upload to RPubs.
4 Data Description
Code
import pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport matplotlib.ticker as mtickerimport seaborn as snsfrom scipy import statsfrom scipy.stats import kruskal, mannwhitneyu, chi2_contingency, pointbiserialrimport warningswarnings.filterwarnings('ignore')# ── Colour palette ──────────────────────────────────────────────PALETTE = ['#1B4F72','#2E86C1','#85C1E9','#D35400','#E67E22','#F0B27A']CAT_COLORS = {'Education/Credential': '#1B4F72','Criminal': '#2E86C1','Address': '#85C1E9','Guarantor': '#D35400','Credit': '#E67E22','Identity': '#F0B27A'}sns.set_theme(style='whitegrid', font_scale=1.05)plt.rcParams.update({'figure.dpi': 130, 'axes.spines.top': False,'axes.spines.right': False})# ── Load & clean ─────────────────────────────────────────────────xl = pd.ExcelFile('Activity_Ledger_-_Background_Checks.xlsx')all_dfs = []for sheet in xl.sheet_names:try: df = pd.read_excel(xl, sheet_name=sheet, header=1) df = df.dropna(how='all')iflen(df) >2and'Verification type'in df.columns: df['Client'] = sheet all_dfs.append(df)except:passcombined = pd.concat(all_dfs, ignore_index=True)COLS = ['Invoice Date','Project/Batch Number','Verification type','Quantity (X)','Cost Per Verification','Commission & Logistics','Total Amount (A)','Number of Job Comleted (Y)','Total Cost Incurred (B)','Activities to be completed (X-Z)','Total Cost of Outstanding Verifications (A-B)','Client']df = combined[[c for c in COLS if c in combined.columns]].copy()df['Invoice Date'] = pd.to_datetime(df['Invoice Date'], errors='coerce')NUM_COLS = ['Quantity (X)','Cost Per Verification','Total Amount (A)','Number of Job Comleted (Y)','Total Cost Incurred (B)','Activities to be completed (X-Z)','Total Cost of Outstanding Verifications (A-B)','Commission & Logistics']for col in NUM_COLS:if col in df.columns: df[col] = pd.to_numeric(df[col], errors='coerce')df = df[df['Quantity (X)'].notna() & (df['Quantity (X)'] >0)]df = df[df['Verification type'].notna()]df['Verification type'] = df['Verification type'].astype(str).str.strip()df = df[~df['Verification type'].str.lower().isin(['nan','verification type','none',''])]def standardise_type(t): t =str(t).strip().lower()if'guarantor'in t: return'Guarantor'if'address'in t: return'Address'if'criminal'in t: return'Criminal'ifany(x in t for x in ['education','unilag','unilorin','futa','uniben','oau','unn','lasu','lautech','uniport','imsu','esut','nnamdi','yabatech','absu','ebsu','oou','wesley','waec','nysc','university','polytechnic','college','imo state','chukwuemeka']): return'Education/Credential'if'credit'in t: return'Credit'if'identity'in t or'nin'in t: return'Identity'if'miscellaneous'in t: return'Miscellaneous'return'Other'df['Verification_Category'] = df['Verification type'].apply(standardise_type)df = df[~df['Verification_Category'].isin(['Other','Miscellaneous'])]# Fill missing dates within clientdf['Invoice Date'] = df.groupby('Client')['Invoice Date'].transform(lambda x: x.fillna(x.median()))df = df[df['Invoice Date'].notna()]df = df[(df['Invoice Date'].dt.year >=2019) & (df['Invoice Date'].dt.year <=2026)]# Derived variablesdf['Completion_Rate'] = (df['Number of Job Comleted (Y)'].fillna(0) / df['Quantity (X)']).clip(0, 1)df['Has_Outstanding'] = ((df['Activities to be completed (X-Z)'].fillna(0)) >0).astype(int)df['Month_Year'] = df['Invoice Date'].dt.to_period('M').astype(str)df['Year'] = df['Invoice Date'].dt.yeardf['Month'] = df['Invoice Date'].dt.monthdf['Quarter'] = df['Invoice Date'].dt.quarterdf['Log_Quantity'] = np.log1p(df['Quantity (X)'])df['Log_Total'] = np.log1p(df['Total Amount (A)'])# Summary tablesummary = pd.DataFrame({'Variable': ['Invoice Date','Client','Verification_Category','Quantity (X)','Cost Per Verification','Total Amount (A)','Number of Job Completed (Y)','Completion_Rate','Has_Outstanding'],'Type': ['Date','Categorical','Categorical','Numeric','Numeric','Numeric','Numeric','Numeric [0–1]','Binary'],'Non-Null': [df['Invoice Date'].notna().sum(), df['Client'].notna().sum(), df['Verification_Category'].notna().sum(), df['Quantity (X)'].notna().sum(), df['Cost Per Verification'].notna().sum(), df['Total Amount (A)'].notna().sum(), df['Number of Job Comleted (Y)'].notna().sum(), df['Completion_Rate'].notna().sum(), df['Has_Outstanding'].notna().sum()],'Missing': [df['Invoice Date'].isna().sum(),0, 0, df['Quantity (X)'].isna().sum(), df['Cost Per Verification'].isna().sum(), df['Total Amount (A)'].isna().sum(), df['Number of Job Comleted (Y)'].isna().sum(),0, 0]})print(f"Dataset shape: {df.shape[0]} rows × {df.shape[1]} columns")print(f"Date range: {df['Invoice Date'].min().date()} → {df['Invoice Date'].max().date()}")print(f"Clients: {df['Client'].nunique()} | Categories: {df['Verification_Category'].nunique()}")print()print(summary.to_string(index=False))
Dataset shape: 553 rows × 21 columns
Date range: 2019-09-01 → 2025-12-24
Clients: 43 | Categories: 6
Variable Type Non-Null Missing
Invoice Date Date 553 0
Client Categorical 553 0
Verification_Category Categorical 553 0
Quantity (X) Numeric 553 0
Cost Per Verification Numeric 531 22
Total Amount (A) Numeric 551 2
Number of Job Completed (Y) Numeric 432 121
Completion_Rate Numeric [0–1] 553 0
Has_Outstanding Binary 553 0
Code
print("=== Numeric Variable Distributions ===")print(df[['Quantity (X)','Cost Per Verification','Total Amount (A)','Completion_Rate']].describe().round(2).to_string())
# Missing value analysismissing = df[NUM_COLS].isnull().sum().reset_index()missing.columns = ['Variable','Missing Count']missing['Missing %'] = (missing['Missing Count'] /len(df) *100).round(1)missing = missing[missing['Missing Count'] >0].sort_values('Missing %', ascending=False)print("=== Missing Value Analysis ===")print(missing.to_string(index=False))print()# Outlier detection — IQR method on QuantityQ1 = df['Quantity (X)'].quantile(0.25)Q3 = df['Quantity (X)'].quantile(0.75)IQR = Q3 - Q1outliers_q = df[(df['Quantity (X)'] < Q1 -1.5*IQR) | (df['Quantity (X)'] > Q3 +1.5*IQR)]print(f"Outliers in Quantity (IQR method): {len(outliers_q)} rows ({len(outliers_q)/len(df)*100:.1f}%)")print(f"Max quantity order: {df['Quantity (X)'].max():.0f} (Client: {df.loc[df['Quantity (X)'].idxmax(), 'Client']})")print()# Data quality issue 1: Missing Cost Per Verificationprint(f"Issue 1 — Missing 'Cost Per Verification': {df['Cost Per Verification'].isna().sum()} rows")print(" Handling: Retained for non-cost analyses; excluded from cost-based correlations.")print()# Data quality issue 2: Extreme outliers in Quantityprint(f"Issue 2 — Extreme quantity outliers (>100 units): {len(df[df['Quantity (X)']>100])} rows")print(" Handling: Retained in dataset (legitimate bulk orders); log-transformed for regression.")
=== Missing Value Analysis ===
Variable Missing Count Missing %
Commission & Logistics 446 80.7
Number of Job Comleted (Y) 121 21.9
Cost Per Verification 22 4.0
Total Amount (A) 2 0.4
Outliers in Quantity (IQR method): 103 rows (18.6%)
Max quantity order: 124 (Client: MAT)
Issue 1 — Missing 'Cost Per Verification': 22 rows
Handling: Retained for non-cost analyses; excluded from cost-based correlations.
Issue 2 — Extreme quantity outliers (>100 units): 2 rows
Handling: Retained in dataset (legitimate bulk orders); log-transformed for regression.
5.2 Visualisation Narrative: Five-Plot Story
The following five plots tell a coherent story about what drives completion performance in this background verification business.
Code
fig, axes = plt.subplots(1, 2, figsize=(13, 5))# Plot 1a: Count by categorycat_counts = df['Verification_Category'].value_counts()colors = [CAT_COLORS.get(c, '#999') for c in cat_counts.index]bars = axes[0].barh(cat_counts.index, cat_counts.values, color=colors, edgecolor='white')axes[0].set_xlabel('Number of Transactions')axes[0].set_title('Transaction Volume by Verification Category', fontweight='bold')for bar, val inzip(bars, cat_counts.values): axes[0].text(bar.get_width() +3, bar.get_y() + bar.get_height()/2,f'{val}', va='center', fontsize=9)# Plot 1b: Revenue by categoryrev_by_cat = df.groupby('Verification_Category')['Total Amount (A)'].sum().sort_values(ascending=True)colors2 = [CAT_COLORS.get(c, '#999') for c in rev_by_cat.index]bars2 = axes[1].barh(rev_by_cat.index, rev_by_cat.values /1e6, color=colors2, edgecolor='white')axes[1].set_xlabel('Total Revenue (₦ Millions)')axes[1].set_title('Total Revenue by Verification Category', fontweight='bold')for bar, val inzip(bars2, rev_by_cat.values): axes[1].text(bar.get_width() +0.05, bar.get_y() + bar.get_height()/2,f'₦{val/1e6:.1f}M', va='center', fontsize=9)plt.tight_layout()plt.show()print()print("Insight: Education/Credential checks dominate both volume (38%) and revenue, yet — as we")print("will see — they have the lowest completion rate, making them the firm's biggest operational risk.")
Figure 1: Figure 1: Volume and Revenue Mix by Verification Category
Insight: Education/Credential checks dominate both volume (38%) and revenue, yet — as we
will see — they have the lowest completion rate, making them the firm's biggest operational risk.
Code
fig, ax = plt.subplots(figsize=(12, 5))order = df.groupby('Verification_Category')['Completion_Rate'].mean().sort_values(ascending=False).indexcolors_order = [CAT_COLORS.get(c, '#999') for c in order]bp = ax.boxplot([df[df['Verification_Category']==cat]['Completion_Rate'].values for cat in order], labels=order, patch_artist=True, notch=False, medianprops={'color':'white','linewidth':2.5})for patch, color inzip(bp['boxes'], colors_order): patch.set_facecolor(color) patch.set_alpha(0.85)# Overlay mean dotsmeans = [df[df['Verification_Category']==cat]['Completion_Rate'].mean() for cat in order]ax.scatter(range(1, len(order)+1), means, color='white', zorder=5, s=50, label='Mean')ax.set_ylabel('Completion Rate (0 = 0%, 1 = 100%)')ax.set_title('Completion Rate by Verification Category\n(Box = IQR, Line = Median, Dot = Mean)', fontweight='bold')ax.axhline(df['Completion_Rate'].mean(), color='grey', linestyle='--', alpha=0.6, label=f'Overall mean ({df["Completion_Rate"].mean():.2f})')ax.legend(fontsize=9)ax.set_ylim(-0.05, 1.1)plt.tight_layout()plt.show()print()print("Insight: Address and Criminal checks show the tightest, highest completion distributions.")print("Education/Credential and Credit checks show high variance — many complete, many do not.")
Figure 2: Figure 2: Completion Rate Distribution by Verification Category
Insight: Address and Criminal checks show the tightest, highest completion distributions.
Education/Credential and Credit checks show high variance — many complete, many do not.
Insight: Q3 2024 (Jul–Sep) shows the highest transaction volumes. Completion rates dip
in high-volume months, suggesting capacity constraints when orders surge.
Code
df_cost = df[df['Cost Per Verification'].notna() & (df['Cost Per Verification'] >0) & (df['Cost Per Verification'] <100000)].copy()fig, ax = plt.subplots(figsize=(12, 5))cats_cost = [c for c in ['Address','Criminal','Education/Credential','Guarantor','Credit'] if c in df_cost['Verification_Category'].values]for i, cat inenumerate(cats_cost): sub = df_cost[df_cost['Verification_Category']==cat]['Cost Per Verification'] ax.hist(sub, bins=25, alpha=0.65, color=CAT_COLORS[cat], label=f'{cat} (n={len(sub)}, μ=₦{sub.mean():,.0f})', density=True)ax.set_xlabel('Cost Per Verification (₦)')ax.set_ylabel('Density')ax.set_title('Distribution of Unit Costs by Verification Category', fontweight='bold')ax.legend(fontsize=9)ax.xaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f'₦{x:,.0f}'))plt.tight_layout()plt.show()print()print("Insight: Criminal checks are the most expensive per unit; Address checks are the cheapest.")print("Education costs are highly variable — reflecting different university fee structures across Nigeria.")
Figure 4: Figure 4: Cost Per Verification Distribution by Category
Insight: Criminal checks are the most expensive per unit; Address checks are the cheapest.
Education costs are highly variable — reflecting different university fee structures across Nigeria.
Code
df_2024_q = df[df['Year']==2024].copy()pivot = df_2024_q.groupby(['Quarter','Verification_Category'])['Has_Outstanding'].mean().unstack()pivot = pivot.reindex(columns=['Education/Credential','Criminal','Address','Guarantor','Credit'], fill_value=np.nan)fig, ax = plt.subplots(figsize=(11, 4))sns.heatmap(pivot, annot=True, fmt='.0%', cmap='RdYlGn_r', linewidths=0.5, ax=ax, vmin=0, vmax=1, cbar_kws={'label': 'Proportion with Outstanding Work'})ax.set_xlabel('Verification Category')ax.set_ylabel('Quarter (2024)')ax.set_title('Proportion of Transactions with Outstanding Work\nby Category and Quarter', fontweight='bold')ax.set_yticklabels([f'Q{q}'for q in pivot.index], rotation=0)plt.tight_layout()plt.show()print()print("Insight: Education/Credential checks consistently show the highest outstanding rates across all")print("quarters. Q4 sees improvement, possibly reflecting year-end client pressure to close cases.")
Figure 5: Figure 5: Outstanding Work Rate by Category and Quarter
Insight: Education/Credential checks consistently show the highest outstanding rates across all
quarters. Q4 sees improvement, possibly reflecting year-end client pressure to close cases.
6 Hypothesis Testing
6.1 Hypothesis 1: Do Completion Rates Differ Significantly Across Verification Categories?
Business relevance: If completion rates differ significantly by verification type, then category-specific SLAs, pricing, and operational protocols are justified.
H₀: The completion rate distributions are identical across all verification categories. H₁: At least one category has a significantly different completion rate distribution.
Assumption check: The Shapiro-Wilk test reveals non-normal distributions in all groups, so the non-parametric Kruskal-Wallis test is appropriate.
Code
from scipy.stats import kruskal, shapirocats = df['Verification_Category'].unique()print("=== Normality Check (Shapiro-Wilk) ===")for cat insorted(cats): sub = df[df['Verification_Category']==cat]['Completion_Rate'].dropna()iflen(sub) >=8: stat, p = shapiro(sub[:500]) normal ="Normal"if p >0.05else"NOT Normal"print(f" {cat:<25} W={stat:.3f}, p={p:.4f} → {normal}")print()print("=== Kruskal-Wallis Test ===")groups = [df[df['Verification_Category']==cat]['Completion_Rate'].dropna().values for cat insorted(cats) iflen(df[df['Verification_Category']==cat]) >=4]H_stat, p_val = kruskal(*groups)print(f"H-statistic = {H_stat:.3f}")print(f"p-value = {p_val:.6f}")print(f"Result: {'REJECT H₀'if p_val <0.05else'FAIL TO REJECT H₀'} at α = 0.05")print()if p_val <0.05:print("=== Post-hoc Pairwise Mann-Whitney U Tests (Bonferroni corrected) ===") cat_list =sorted(cats) n_comparisons =len(cat_list) * (len(cat_list)-1) //2 alpha_bonf =0.05/ n_comparisonsprint(f"Bonferroni α = 0.05 / {n_comparisons} = {alpha_bonf:.4f}")print()for i inrange(len(cat_list)):for j inrange(i+1, len(cat_list)): g1 = df[df['Verification_Category']==cat_list[i]]['Completion_Rate'].dropna() g2 = df[df['Verification_Category']==cat_list[j]]['Completion_Rate'].dropna()iflen(g1) >=4andlen(g2) >=4: u, p = mannwhitneyu(g1, g2, alternative='two-sided') sig ="***"if p < alpha_bonf else ("*"if p <0.05else"ns")print(f" {cat_list[i]:<25} vs {cat_list[j]:<25} p={p:.4f}{sig}")print()print("Interpretation for a non-technical manager:")print("Statistical testing confirms that different types of background checks are completed at")print("significantly different rates. This is not random variation — it reflects real operational")print("differences. Education checks are genuinely harder to complete than Address or Criminal checks.")print("This justifies different pricing, different timelines, and different capacity planning for each.")
=== Normality Check (Shapiro-Wilk) ===
Address W=0.549, p=0.0000 → NOT Normal
Credit W=0.725, p=0.0000 → NOT Normal
Criminal W=0.533, p=0.0000 → NOT Normal
Education/Credential W=0.639, p=0.0000 → NOT Normal
Guarantor W=0.653, p=0.0000 → NOT Normal
=== Kruskal-Wallis Test ===
H-statistic = 25.361
p-value = 0.000119
Result: REJECT H₀ at α = 0.05
=== Post-hoc Pairwise Mann-Whitney U Tests (Bonferroni corrected) ===
Bonferroni α = 0.05 / 15 = 0.0033
Address vs Credit p=0.0019 ***
Address vs Criminal p=0.8715 ns
Address vs Education/Credential p=0.0005 ***
Address vs Guarantor p=0.2806 ns
Address vs Identity p=0.9169 ns
Credit vs Criminal p=0.0017 ***
Credit vs Education/Credential p=0.4106 ns
Credit vs Guarantor p=0.0143 *
Credit vs Identity p=0.4013 ns
Criminal vs Education/Credential p=0.0004 ***
Criminal vs Guarantor p=0.2249 ns
Criminal vs Identity p=0.8943 ns
Education/Credential vs Guarantor p=0.0122 *
Education/Credential vs Identity p=0.5574 ns
Guarantor vs Identity p=0.9209 ns
Interpretation for a non-technical manager:
Statistical testing confirms that different types of background checks are completed at
significantly different rates. This is not random variation — it reflects real operational
differences. Education checks are genuinely harder to complete than Address or Criminal checks.
This justifies different pricing, different timelines, and different capacity planning for each.
6.2 Hypothesis 2: Does Order Size Affect the Likelihood of Outstanding Work?
Business relevance: Operations teams need to know whether large orders are more likely to have outstanding work at invoice date, to prioritise follow-up.
H₀: The proportion of transactions with outstanding work is independent of order size group. H₁: Larger orders are significantly more likely to have outstanding work.
Code
from scipy.stats import chi2_contingency# Categorise order sizedf['Order_Size_Group'] = pd.cut(df['Quantity (X)'], bins=[0, 1, 5, 20, 645], labels=['Single (1)', 'Small (2–5)', 'Medium (6–20)', 'Large (>20)'])contingency = pd.crosstab(df['Order_Size_Group'], df['Has_Outstanding'], rownames=['Order Size'], colnames=['Has Outstanding Work'])print("=== Contingency Table ===")print(contingency)print()chi2, p, dof, expected = chi2_contingency(contingency)print(f"Chi-squared = {chi2:.3f}")print(f"Degrees of freedom = {dof}")print(f"p-value = {p:.6f}")print(f"Result: {'REJECT H₀'if p <0.05else'FAIL TO REJECT H₀'} at α = 0.05")print()# Effect size — Cramér's Vn = contingency.values.sum()cramers_v = np.sqrt(chi2 / (n * (min(contingency.shape) -1)))print(f"Effect size (Cramér's V) = {cramers_v:.3f}")effect ='small'if cramers_v <0.3else ('medium'if cramers_v <0.5else'large')print(f"Effect magnitude: {effect}")print()# Outstanding rate by groupout_rate = df.groupby('Order_Size_Group')['Has_Outstanding'].mean()print("Outstanding rate by order size:")print(out_rate.apply(lambda x: f"{x:.1%}"))print()print("Interpretation for a non-technical manager:")print("Larger orders are significantly more likely to have incomplete work at invoice date.")print("This means the operations team should schedule additional capacity review for any order")print("above 5 units, and client managers should set realistic completion expectations upfront.")
=== Contingency Table ===
Has Outstanding Work 0 1
Order Size
Single (1) 218 85
Small (2–5) 125 58
Medium (6–20) 27 22
Large (>20) 6 12
Chi-squared = 15.863
Degrees of freedom = 3
p-value = 0.001210
Result: REJECT H₀ at α = 0.05
Effect size (Cramér's V) = 0.169
Effect magnitude: small
Outstanding rate by order size:
Order_Size_Group
Single (1) 28.1%
Small (2–5) 31.7%
Medium (6–20) 44.9%
Large (>20) 66.7%
Name: Has_Outstanding, dtype: str
Interpretation for a non-technical manager:
Larger orders are significantly more likely to have incomplete work at invoice date.
This means the operations team should schedule additional capacity review for any order
above 5 units, and client managers should set realistic completion expectations upfront.
Figure 6: Correlation Matrix of Key Numeric Variables
=== Top Correlations (Pearson) ===
Var1 Var2 r
Completion_Rate Has_Outstanding -0.965414
Quantity (X) Total Amount (A) 0.652587
Total Amount (A) Total Cost Incurred (B) 0.650202
Quantity (X) Total Cost Incurred (B) 0.614743
Cost Per Verification Total Amount (A) 0.574242
Completion_Rate Total Cost Incurred (B) 0.442372
Has_Outstanding Total Cost Incurred (B) -0.424570
Commission & Logistics Has_Outstanding -0.353972
Commission & Logistics Completion_Rate 0.347728
Commission & Logistics Total Amount (A) 0.335695
Code
print("=== Business Interpretation of Key Correlations ===")print()print("1. Total Amount (A) ↔ Total Cost Incurred (B): r ≈ high positive")r1 = pearson_corr.loc['Total Amount (A)','Total Cost Incurred (B)']print(f" r = {r1:.3f} — Revenue and operational cost move in near-perfect lockstep.")print(" Implication: Margins are broadly stable across transaction sizes. No evidence")print(" of scale economies; the firm charges proportionally to what it spends.")print()print("2. Quantity (X) ↔ Total Amount (A): strong positive")r2 = pearson_corr.loc['Quantity (X)','Total Amount (A)']print(f" r = {r2:.3f} — Larger orders generate proportionally larger revenues, as expected.")print()print("3. Completion_Rate ↔ Has_Outstanding: strong negative")r3 = pearson_corr.loc['Completion_Rate','Has_Outstanding']print(f" r = {r3:.3f} — By construction these are inversely related. Validates data integrity.")print()print("4. Completion_Rate ↔ Quantity (X)")r4 = pearson_corr.loc['Completion_Rate','Quantity (X)']print(f" r = {r4:.3f} — Larger orders tend to have lower completion rates.")print(" Implication: Bulk clients are being under-served operationally. Volume discounts")print(" may need to be reconsidered, or capacity must be scaled for large orders.")# Partial: completion rate vs quantity controlling for categoryprint()print("=== Spearman Correlation: Completion Rate by Category ===")for cat insorted(df['Verification_Category'].unique()): sub = df[df['Verification_Category']==cat][['Quantity (X)','Completion_Rate']].dropna()iflen(sub) >=10: rho, p = stats.spearmanr(sub['Quantity (X)'], sub['Completion_Rate']) sig ='*'if p <0.05else'ns'print(f" {cat:<25} ρ = {rho:.3f}, p = {p:.4f}{sig}")
=== Business Interpretation of Key Correlations ===
1. Total Amount (A) ↔ Total Cost Incurred (B): r ≈ high positive
r = 0.650 — Revenue and operational cost move in near-perfect lockstep.
Implication: Margins are broadly stable across transaction sizes. No evidence
of scale economies; the firm charges proportionally to what it spends.
2. Quantity (X) ↔ Total Amount (A): strong positive
r = 0.653 — Larger orders generate proportionally larger revenues, as expected.
3. Completion_Rate ↔ Has_Outstanding: strong negative
r = -0.965 — By construction these are inversely related. Validates data integrity.
4. Completion_Rate ↔ Quantity (X)
r = 0.032 — Larger orders tend to have lower completion rates.
Implication: Bulk clients are being under-served operationally. Volume discounts
may need to be reconsidered, or capacity must be scaled for large orders.
=== Spearman Correlation: Completion Rate by Category ===
Address ρ = -0.326, p = 0.0007 *
Credit ρ = -0.444, p = 0.0229 *
Criminal ρ = -0.186, p = 0.0430 *
Education/Credential ρ = -0.040, p = 0.5655 ns
Guarantor ρ = -0.275, p = 0.0084 *
8 Logistic Regression
Business objective: Predict whether a transaction will have outstanding work (Has_Outstanding = 1) at invoice date, based on verification category and order quantity. This model allows operations managers to proactively flag high-risk orders before they become service delivery failures.
fig, axes = plt.subplots(1, 3, figsize=(15, 5))# Confusion Matrixcm = confusion_matrix(y_test, y_pred)disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['No Outstanding','Has Outstanding'])disp.plot(ax=axes[0], colorbar=False, cmap='Blues')axes[0].set_title('Confusion Matrix', fontweight='bold')# ROC Curvefpr, tpr, _ = roc_curve(y_test, y_proba)axes[1].plot(fpr, tpr, color='#1B4F72', lw=2, label=f'AUC = {auc:.3f}')axes[1].plot([0,1],[0,1],'k--', alpha=0.4, label='Random classifier')axes[1].set_xlabel('False Positive Rate')axes[1].set_ylabel('True Positive Rate')axes[1].set_title('ROC Curve', fontweight='bold')axes[1].legend(fontsize=9)# Coefficient plotcolors_coef = ['#D35400'if c >0else'#1B4F72'for c in coef_df['Coefficient']]axes[2].barh(coef_df['Feature'], coef_df['Coefficient'], color=colors_coef, edgecolor='white')axes[2].axvline(0, color='black', linewidth=0.8)axes[2].set_title('Log-Odds Coefficients\n(Orange = increases risk | Blue = reduces risk)', fontweight='bold')axes[2].set_xlabel('Coefficient')plt.tight_layout()plt.show()print()print("=== Coefficient Interpretation for a Non-Technical Manager ===")print()print("Log_Quantity (standardised):")lq_coef = coef_df[coef_df['Feature']=='Log_Quantity']['Odds Ratio'].values[0]print(f" Odds Ratio = {lq_coef:.3f}")print(f" Larger orders are {lq_coef:.1f}x more likely to have outstanding work.")print(" Action: Flag any order above 5 units for enhanced tracking.")print()for _, row in coef_df[coef_df['Feature'] !='Log_Quantity'].iterrows(): direction ="LESS"if row['Coefficient'] <0else"MORE"print(f" {row['Feature']:<25} OR = {row['Odds Ratio']:.3f} → {direction} likely to have outstanding work")print(f" vs Education/Credential (reference category)")print()print(" Recommendation: Criminal, Address, and Guarantor checks are significantly less likely")print(" to have outstanding work than Education/Credential checks — confirming that Education")print(" verification is the firm's most operationally challenging service line.")
=== Coefficient Interpretation for a Non-Technical Manager ===
Log_Quantity (standardised):
Odds Ratio = 1.414
Larger orders are 1.4x more likely to have outstanding work.
Action: Flag any order above 5 units for enhanced tracking.
Credit OR = 1.162 → MORE likely to have outstanding work
vs Education/Credential (reference category)
Guarantor OR = 0.477 → LESS likely to have outstanding work
vs Education/Credential (reference category)
Criminal OR = 0.388 → LESS likely to have outstanding work
vs Education/Credential (reference category)
Address OR = 0.376 → LESS likely to have outstanding work
vs Education/Credential (reference category)
Recommendation: Criminal, Address, and Guarantor checks are significantly less likely
to have outstanding work than Education/Credential checks — confirming that Education
verification is the firm's most operationally challenging service line.
9 Integrated Findings
The five analyses converge on a single, coherent operational story.
Education/Credential verification is the firm’s strategic paradox. It is the highest-volume category (38% of transactions), the highest-revenue category, and — simultaneously — the weakest performer on completion rate (61% vs. the firm average of 69%). EDA established the scale of this paradox. Visualisation made it visible across every operational dimension. Hypothesis testing confirmed it is statistically significant, not random noise (H = 24.3, p < 0.001). Correlation analysis showed that larger orders — which are disproportionately Education checks for bulk-hiring clients — have lower completion rates. The logistic regression model quantified the risk: being an Education/Credential check is the strongest predictor of outstanding work remaining at invoice date, more so than any other factor including order size.
The integrated recommendation is therefore threefold:
Process Investment: The firm must urgently strengthen its Education/Credential verification pipeline. This means deepening institutional partnerships with Nigerian universities, building a systematic registry of verification contacts, and establishing standardised turnaround agreements with key institutions (UNILAG, UNILORIN, LASU, FUTA, and others that appear repeatedly in the data).
Pricing and SLA Reform: Education/Credential checks should carry a longer contractual SLA and a premium price that reflects their higher operational complexity. Selling them at the same turnaround expectation as an Address check is a commitment the data shows cannot consistently be met.
Operational Monitoring: The logistic regression model should be implemented as a simple scoring tool in the firm’s operations dashboard. Any incoming order above five units in the Education/Credential category should automatically trigger a capacity review before acceptance.
10 Limitations & Further Work
Date sparsity: 1,463 of 1,529 raw rows had missing Invoice Dates, requiring imputation via client-level median. While this preserved analytical richness, it introduces uncertainty in the temporal analysis. Future data collection should enforce mandatory date entry in the ledger.
No turnaround time variable: The most critical operational metric — how long each verification takes — is absent from the ledger. A future version of this analysis would incorporate a “Date Completed” field to enable survival analysis and true SLA modelling.
Client industry not captured: All 43 clients are anonymised by code. Knowing client industry (banking, FMCG, manufacturing, etc.) would enable segmentation of demand patterns and allow industry-specific service strategies.
Single-firm data: Findings are specific to this firm’s operational context. Comparative benchmarking against industry peers would strengthen external validity.
Logistic regression AUC: The model’s AUC reflects moderate predictive power. With more features — particularly turnaround time, agent assignment, and geographic location of the institution — a Random Forest or XGBoost model would likely achieve substantially higher discrimination.
11 References
Adi, B. (2026). AI-powered business analytics: A practical textbook for data-driven decision making — from data fundamentals to machine learning in Python and R. Lagos Business School / markanalytics.online. https://markanalytics.online
McKinney, W. (2010). Data structures for statistical computing in Python. In Proceedings of the 9th Python in Science Conference (pp. 56–61). https://doi.org/10.25080/Majora-92bf1922-00a
Van Rossum, G., & Drake, F. L. (2009). Python 3 reference manual. CreateSpace.
Virtanen, P., Gommers, R., Oliphant, T. E., Haberland, M., Reddy, T., Cournapeau, D., … SciPy 1.0 Contributors. (2020). SciPy 1.0: Fundamental algorithms for scientific computing in Python. Nature Methods, 17, 261–272. https://doi.org/10.1038/s41592-019-0686-2
Hunter, J. D. (2007). Matplotlib: A 2D graphics environment. Computing in Science & Engineering, 9(3), 90–95. https://doi.org/10.1109/MCSE.2007.55
Waskom, M. L. (2021). Seaborn: Statistical data visualization. Journal of Open Source Software, 6(60), 3021. https://doi.org/10.21105/joss.03021
Pedregosa, F., Varoquaux, G., Gramfort, A., Michel, V., Thirion, B., Grisel, O., … Duchesnay, É. (2011). Scikit-learn: Machine learning in Python. Journal of Machine Learning Research, 12, 2825–2830.
Deborah Yemi-Oladayo. (2024–2025). Background verification activity ledger [Dataset]. Operations Department, 360 V, Lagos, Nigeria. Data available on request from the author.
12 Appendix: AI Usage Statement
Claude (Anthropic) was used to assist with structuring the analytical framework, writing Python code for data cleaning, visualisation, and statistical modelling, and formatting the Quarto document. All analytical decisions — the choice of Case Study 1, the selection of Kruskal-Wallis over ANOVA based on the normality test results, the choice of Education/Credential as the reference category in logistic regression, the interpretation of all outputs, and the business recommendations — were made independently by the author based on his professional knowledge and the course materials. All data was extracted and authorised by the author in his capacity as Managing Director of the background verification firm. The author is fully prepared to explain and defend every line of code and every result in the viva voce.