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:

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

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

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

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

  5. 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 pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import seaborn as sns
from scipy import stats
from scipy.stats import kruskal, mannwhitneyu, chi2_contingency, pointbiserialr
import warnings
warnings.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')
        if len(df) > 2 and 'Verification type' in df.columns:
            df['Client'] = sheet
            all_dfs.append(df)
    except:
        pass

combined = 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'
    if any(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 client
df['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 variables
df['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.year
df['Month']       = df['Invoice Date'].dt.month
df['Quarter']     = df['Invoice Date'].dt.quarter
df['Log_Quantity'] = np.log1p(df['Quantity (X)'])
df['Log_Total']    = np.log1p(df['Total Amount (A)'])

# Summary table
summary = 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())
=== Numeric Variable Distributions ===
       Quantity (X)  Cost Per Verification  Total Amount (A)  Completion_Rate
count        553.00                 531.00            551.00           553.00
mean           3.86                6100.35          17372.17             0.72
std           11.15                6838.65          38157.24             0.42
min            1.00                   0.00              0.00             0.00
25%            1.00                3000.00           4000.00             0.50
50%            1.00                5000.00           8000.00             1.00
75%            2.00                6500.00          16350.00             1.00
max          124.00               55450.00         434000.00             1.00

5 Exploratory Data Analysis (EDA)

5.1 Data Quality Assessment

Code
# Missing value analysis
missing = 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 Quantity
Q1 = df['Quantity (X)'].quantile(0.25)
Q3 = df['Quantity (X)'].quantile(0.75)
IQR = Q3 - Q1
outliers_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 Verification
print(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 Quantity
print(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 category
cat_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 in zip(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 category
rev_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 in zip(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).index
colors_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 in zip(bp['boxes'], colors_order):
    patch.set_facecolor(color)
    patch.set_alpha(0.85)

# Overlay mean dots
means = [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.
Code
df_2024 = df[df['Year'] == 2024].copy()
monthly = df_2024.groupby('Month').agg(
    Transactions=('Quantity (X)', 'count'),
    Avg_Completion=('Completion_Rate', 'mean')
).reset_index()

month_labels = ['Jan','Feb','Mar','Apr','May','Jun',
                'Jul','Aug','Sep','Oct','Nov','Dec']
monthly['Month_Label'] = monthly['Month'].apply(lambda x: month_labels[x-1])

fig, ax1 = plt.subplots(figsize=(12, 5))
ax2 = ax1.twinx()

bars = ax1.bar(monthly['Month_Label'], monthly['Transactions'],
               color='#2E86C1', alpha=0.75, label='Transaction Count')
line = ax2.plot(monthly['Month_Label'], monthly['Avg_Completion'],
                color='#D35400', marker='o', linewidth=2.5, markersize=7,
                label='Avg Completion Rate')

ax1.set_ylabel('Number of Transactions', color='#2E86C1')
ax2.set_ylabel('Average Completion Rate', color='#D35400')
ax2.set_ylim(0, 1.1)
ax1.set_title('Monthly Operational Activity — 2024\n(Bars = Volume | Line = Completion Rate)',
              fontweight='bold')

lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left', fontsize=9)

plt.tight_layout()
plt.show()
print()
print("Insight: Q3 2024 (Jul–Sep) shows the highest transaction volumes. Completion rates dip")
print("in high-volume months, suggesting capacity constraints when orders surge.")
Figure 3: Figure 3: Monthly Transaction Volume and Completion Rate Trend (2024)

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 in enumerate(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, shapiro

cats = df['Verification_Category'].unique()

print("=== Normality Check (Shapiro-Wilk) ===")
for cat in sorted(cats):
    sub = df[df['Verification_Category']==cat]['Completion_Rate'].dropna()
    if len(sub) >= 8:
        stat, p = shapiro(sub[:500])
        normal = "Normal" if p > 0.05 else "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 in sorted(cats) if len(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.05 else '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_comparisons
    print(f"Bonferroni α = 0.05 / {n_comparisons} = {alpha_bonf:.4f}")
    print()
    for i in range(len(cat_list)):
        for j in range(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()
            if len(g1) >= 4 and len(g2) >= 4:
                u, p = mannwhitneyu(g1, g2, alternative='two-sided')
                sig = "***" if p < alpha_bonf else ("*" if p < 0.05 else "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 size
df['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.05 else 'FAIL TO REJECT H₀'} at α = 0.05")
print()

# Effect size — Cramér's V
n = 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.3 else ('medium' if cramers_v < 0.5 else 'large')
print(f"Effect magnitude: {effect}")
print()

# Outstanding rate by group
out_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.

7 Correlation Analysis

Code
# Select variables for correlation
corr_vars = ['Quantity (X)', 'Cost Per Verification', 'Total Amount (A)',
             'Total Cost Incurred (B)', 'Completion_Rate', 'Has_Outstanding',
             'Commission & Logistics']

df_corr = df[corr_vars].copy()
df_corr = df_corr.dropna()

print(f"Correlation analysis on {len(df_corr)} complete-case rows")
print()

# Pearson correlation matrix
pearson_corr = df_corr.corr(method='pearson')
spearman_corr = df_corr.corr(method='spearman')

fig, axes = plt.subplots(1, 2, figsize=(15, 6))

for ax, corr_mat, method in zip(axes, [pearson_corr, spearman_corr], ['Pearson', 'Spearman']):
    mask = np.triu(np.ones_like(corr_mat, dtype=bool))
    sns.heatmap(corr_mat, mask=mask, annot=True, fmt='.2f',
                cmap='RdBu_r', vmin=-1, vmax=1, center=0,
                square=True, ax=ax, linewidths=0.5,
                cbar_kws={'shrink': 0.8},
                xticklabels=['Quantity','Cost/Unit','Total Amt',
                              'Cost Incurred','Compl. Rate','Outstanding','Commission'],
                yticklabels=['Quantity','Cost/Unit','Total Amt',
                              'Cost Incurred','Compl. Rate','Outstanding','Commission'])
    ax.set_title(f'{method} Correlation Matrix', fontweight='bold')

plt.tight_layout()
plt.show()

print("=== Top Correlations (Pearson) ===")
corr_pairs = pearson_corr.unstack().reset_index()
corr_pairs.columns = ['Var1','Var2','r']
corr_pairs = corr_pairs[corr_pairs['Var1'] < corr_pairs['Var2']]
corr_pairs = corr_pairs.reindex(corr_pairs['r'].abs().sort_values(ascending=False).index)
print(corr_pairs.head(10).to_string(index=False))
Correlation analysis on 106 complete-case rows

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 category
print()
print("=== Spearman Correlation: Completion Rate by Category ===")
for cat in sorted(df['Verification_Category'].unique()):
    sub = df[df['Verification_Category']==cat][['Quantity (X)','Completion_Rate']].dropna()
    if len(sub) >= 10:
        rho, p = stats.spearmanr(sub['Quantity (X)'], sub['Completion_Rate'])
        sig = '*' if p < 0.05 else '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.

Code
from scipy.special import expit
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import (classification_report, confusion_matrix, 
                               roc_auc_score, roc_curve, ConfusionMatrixDisplay)
from sklearn.preprocessing import StandardScaler

# Prepare modelling dataset
model_cats = ['Education/Credential','Criminal','Address','Guarantor','Credit']
df_model = df[df['Verification_Category'].isin(model_cats)].copy()
df_model = df_model[['Has_Outstanding','Verification_Category',
                       'Quantity (X)','Cost Per Verification',
                       'Commission & Logistics']].dropna(subset=['Has_Outstanding','Quantity (X)'])

# One-hot encode category (drop Education/Credential as reference)
dummies = pd.get_dummies(df_model['Verification_Category'], drop_first=False)
dummies = dummies.drop('Education/Credential', axis=1, errors='ignore')

X = pd.concat([np.log1p(df_model[['Quantity (X)']]).rename(
                   columns={'Quantity (X)':'Log_Quantity'}), dummies], axis=1)
y = df_model['Has_Outstanding']

print(f"Model dataset: {len(X)} rows | Outcome balance: {y.mean():.1%} outstanding")
print(f"Features: {list(X.columns)}")
print()

# Train/test split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y)

# Scale Log_Quantity
scaler = StandardScaler()
X_train_s = X_train.copy()
X_test_s  = X_test.copy()
X_train_s['Log_Quantity'] = scaler.fit_transform(X_train[['Log_Quantity']])
X_test_s['Log_Quantity']  = scaler.transform(X_test[['Log_Quantity']])

# Fit model
model = LogisticRegression(random_state=42, max_iter=500, C=1.0)
model.fit(X_train_s, y_train)

# Predictions
y_pred  = model.predict(X_test_s)
y_proba = model.predict_proba(X_test_s)[:, 1]

print("=== Model Coefficients ===")
coef_df = pd.DataFrame({
    'Feature':    X.columns,
    'Coefficient': model.coef_[0],
    'Odds Ratio':  np.exp(model.coef_[0])
}).sort_values('Coefficient', ascending=False)
print(coef_df.round(4).to_string(index=False))
print(f"\nIntercept: {model.intercept_[0]:.4f}")
print()

print("=== Classification Report ===")
print(classification_report(y_test, y_pred, target_names=['No Outstanding','Has Outstanding']))

auc = roc_auc_score(y_test, y_proba)
print(f"ROC-AUC Score: {auc:.4f}")
Model dataset: 549 rows | Outcome balance: 32.1% outstanding
Features: ['Log_Quantity', 'Address', 'Credit', 'Criminal', 'Guarantor']

=== Model Coefficients ===
     Feature  Coefficient  Odds Ratio
Log_Quantity       0.3463      1.4138
      Credit       0.1502      1.1621
   Guarantor      -0.7397      0.4772
    Criminal      -0.9472      0.3878
     Address      -0.9783      0.3759

Intercept: -0.2779

=== Classification Report ===
                 precision    recall  f1-score   support

 No Outstanding       0.71      0.99      0.83        94
Has Outstanding       0.86      0.14      0.24        44

       accuracy                           0.72       138
      macro avg       0.78      0.56      0.53       138
   weighted avg       0.76      0.72      0.64       138

ROC-AUC Score: 0.7579
Code
fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# Confusion Matrix
cm = 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 Curve
fpr, 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 plot
colors_coef = ['#D35400' if c > 0 else '#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'] < 0 else "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.")
Figure 6: Figure 7: Logistic Regression Diagnostics

=== 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:

  1. 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).

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

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

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

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

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

  4. Single-firm data: Findings are specific to this firm’s operational context. Comparative benchmarking against industry peers would strengthen external validity.

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