Skip to content

Modeling Call Center Operations with Probability Distributions

Call centers operate in a dynamic environment where:
1. Customer calls arrive randomly throughout the day.
2. Agents resolve issues with varying success rates.
3. Waiting times fluctuate based on traffic and staffing.

These characteristics make call centers an excellent real-world example for applying probability distributions. By modeling call arrivals, waiting times, and resolution patterns, we can:
1. Predict workload and staffing needs.
2. Optimize service levels.
3. Understand customer experience metrics.

In this blog, we’ll focus on three key distributions widely used in queueing theory and service analytics:
1. Poisson Distribution – For modeling the number of calls arriving in a fixed time interval.
2. Exponential Distribution – For modeling the time between consecutive calls.
3. Geometric Distribution – For modeling the number of failed attempts before a successful resolution.

Afterward, we’ll explore Goodness-of-Fit tests and validate the Central Limit Theorem.

import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import kagglehub

plt.style.use("seaborn-v0_8")
sns.set_context("talk")

The data is taken from Kaggle Call center dataset

# Download the data
path = kagglehub.dataset_download("akash1vishwakarma/call-center-dataset")

df = pd.read_excel(path+'/01 Call-Center-Dataset.xlsx')
df.columns = [c.strip().replace(" ", "_").replace("(Y/N)", "YN") for c in df.columns]
df
Call_Id Agent Date Time Topic Answered_YN Resolved Speed_of_answer_in_seconds AvgTalkDuration Satisfaction_rating
0 ID0001 Diane 2021-01-01 09:12:58 Contract related Y Y 109.0 00:02:23 3.0
1 ID0002 Becky 2021-01-01 09:12:58 Technical Support Y N 70.0 00:04:02 3.0
2 ID0003 Stewart 2021-01-01 09:47:31 Contract related Y Y 10.0 00:02:11 3.0
3 ID0004 Greg 2021-01-01 09:47:31 Contract related Y Y 53.0 00:00:37 2.0
4 ID0005 Becky 2021-01-01 10:00:29 Payment related Y Y 95.0 00:01:00 3.0
... ... ... ... ... ... ... ... ... ... ...
4995 ID4996 Jim 2021-03-31 16:37:55 Payment related Y Y 22.0 00:05:40 1.0
4996 ID4997 Diane 2021-03-31 16:45:07 Payment related Y Y 100.0 00:03:16 3.0
4997 ID4998 Diane 2021-03-31 16:53:46 Payment related Y Y 84.0 00:01:49 4.0
4998 ID4999 Jim 2021-03-31 17:02:24 Streaming Y Y 98.0 00:00:58 5.0
4999 ID5000 Diane 2021-03-31 17:39:50 Contract related N N NaN NaN NaN

5000 rows × 10 columns

Cleaning the dataset

Cleaning the time variables and sorting based on timestamp

# Clean date and time and create timestamp variable
df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
df['Time_td'] = pd.to_timedelta(df['Time'].astype(str)) # HH:MM:SS -> timedelta
df['timestamp'] = df['Date'] + df['Time_td']

# Sort events by timestamp. (All subsequent computations rely on chronological order.)
df = df.dropna(subset=['timestamp']).sort_values('timestamp').reset_index(drop=True)

Normalize flags

df['Answered_YN'] = df['Answered_YN'].apply(lambda x: 1 if x=='Y' else 0)
df['Resolved'] = df['Resolved'].apply(lambda x: 1 if x=='Y' else 0)

Convert durations 1. Speed_of_answer_in_seconds to numbers 2. AvgTalkDuration “MM:SS/HH:MM:SS” to seconds.

# Parse talk duration to seconds
def parse_duration(s):
    if pd.isna(s): return np.nan
    try:
        h, m, s2 = 0, 0, 0
        parts = str(s).split(':')
        parts = [int(p) for p in parts]
        if len(parts) == 3: h, m, s2 = parts
        elif len(parts) == 2: m, s2 = parts
        else: return float(parts[0])
        return h*3600 + m*60 + s2
    except Exception:
        return np.nan

df['TalkDuration_seconds'] = df['AvgTalkDuration'].apply(parse_duration)

# Datatype conversions
df['Speed_of_answer_in_seconds'] = pd.to_numeric(df['Speed_of_answer_in_seconds'], errors='coerce')

Poisson distribution (calls/day)

Imagine you want to know: “How many calls will arrive in the next hour?”
Call arrivals are typically independent and occur at a relatively constant average rate over short intervals. This is where the Poisson distribution is particularly useful.
Definition
The Poisson distribution models the probability of observing \(k\) events in a fixed interval when events occur independently at a constant rate \(\lambda\).

\[P(X=x)= \frac{\lambda^xe^{-\lambda}}{x!}\]
# Calculate: counts/hour
hourly_counts = df.set_index('timestamp').groupby([pd.Grouper(freq='h'), 'Agent']).size().reset_index()
hourly_counts.columns = ['timestamp', 'Agent', 'no_of_calls']

# Create a dataset that has all combination of agent-timestamp (hours)
df['dummy_col'] = 1
outer_join_df = df.groupby('Agent').dummy_col.max().reset_index().merge(
    df.groupby('timestamp').dummy_col.max().reset_index(), on='dummy_col')

# join the datasets to get complete dataset
hourly_counts = outer_join_df.merge(hourly_counts, on=['timestamp', 'Agent'], how='outer')
hourly_counts['no_of_calls'] = hourly_counts.no_of_calls.fillna(0)

# Metrics
lambda_hat = hourly_counts.no_of_calls.mean()
var_hat = hourly_counts.no_of_calls.var(ddof=1)

# Sample lambda distribution
max_k = int(hourly_counts.no_of_calls.max())
k_vals = np.arange(0, max_k+1)
pmf = stats.poisson.pmf(k_vals, mu=lambda_hat)

# Plot the distribution
fig, ax = plt.subplots(figsize=(10,6))
sns.histplot(hourly_counts.no_of_calls, bins=np.arange(0, max_k+2), stat='probability', color="gold", ax=ax)
ax.plot(k_vals, pmf, 'o-', color="darkgreen", label=f"Poisson(λ={lambda_hat:.2f}) PMF")
ax.set_title('Calls per Hour vs Poisson PMF')
ax.set_xlabel('Calls per hour')
ax.legend()
plt.show();

png

Exponential distribution (inter arrival times)

Now, let’s answer: “How long until the next call arrives?”
If calls follow a Poisson process, the time between consecutive calls is memoryless—meaning the probability of a call arriving in the next minute doesn’t depend on how long you’ve already waited. This is modeled by the Exponential distribution.
Definition The exponential distribution models the time between events in a Poisson process: $$ PDF:= f(\lambda) = \lambda\times e^{-\lambda t} $$
Where: \(t\) = waiting time (e.g., minutes between calls),
\(\lambda\) = rate parameter (calls per minute).

Key Properties 1. Mean waiting time = \(1/\lambda\). 2. Memoryless property:$ P(T > s + t \mid T > s) = P(T > t)$

Example
If the average time between calls is 53 minutes, then:
$$ \hat{\lambda} = \frac{1}{50} = 0.02 \text{ per minute} $$ The probability that the next call arrives within 10 minutes: $$ P(T \le 10) = 1 - e^{-0.02 \times 10} \approx 0.18 $$

# Exponential: inter-arrival times
ts = df['timestamp'].sort_values().values
inter_arrival_sec = np.diff(ts) / np.timedelta64(1, 's')
inter_arrival_sec = inter_arrival_sec[inter_arrival_sec > 0]
inter_arrival_min = inter_arrival_sec / 60.0

# Metrics
lambda_exp_hat = 1.0 / np.mean(inter_arrival_min)

fig, ax = plt.subplots(figsize=(10,6))
sns.histplot(inter_arrival_min, bins=40, stat='density', color="gold", ax=ax)
x = np.linspace(0, np.percentile(inter_arrival_min, 99), 200)
ax.plot(x, stats.expon.pdf(x, scale=1/lambda_exp_hat), color="green", lw=2, 
        label=f"Expon PDF (λ={lambda_exp_hat:.3f} PDF)")
ax.set_title('Inter-arrival Times (minutes) with Exponential Fit')
ax.set_xlabel('Minutes')
ax.legend()
plt.show();

png

Geometric Distribution (failures before first success)

Suppose you want to know: “How many failed attempts occur before an issue is resolved?”
In call centers, some issues require multiple interactions before success. Each attempt can be modeled as a Bernoulli trial, either a success or a failure..
Definition The geometric distribution models the number of failures before the first success: $$ P(X=r)=p\times (1-p)^{r} $$
where r = 0,1,2,3,4,...
\(p\) = probability of success on any attempt
\(r\) = number of failures before success
Example If the probability of resolving an issue in one attempt is 0.73, the probability of having 2 failures before success is: $$ P(X = 2) = (1 - 0.73)^2 \times 0.73 $$

Insights 1. A high value of p indicates that most issues are resolved quickly.
2. Low p indicates complex cases or training gaps.

# Failures before first success (per agent)

# For each agent’s chronological sequence of resolved flags, count the number of N (failures) before the next Y (first success).
geo_failures = []
for agent, grp in df.sort_values('timestamp').groupby('Agent'):
    seq = grp['Resolved'].dropna().values
    fail = 0
    for v in seq:
        if v == 1:
            geo_failures.append(fail)
            fail = 0
        elif v == 0:
            fail += 1

geo_failures = np.array(geo_failures)

# Fit a Geometric distribution on “failures before success” across agents; 
# estimate success probability p (MLE via mean failures).
if len(geo_failures) > 0:
    mean_fail = float(np.mean(geo_failures))
    p_hat = 1.0 / (mean_fail + 1.0)
    k_max_g = int(np.percentile(geo_failures, 99))
    k_vals_g = np.arange(0, max(5, k_max_g)+1)
    pmf_geom = stats.geom.pmf(k_vals_g+1, p_hat)

    fig, ax = plt.subplots(figsize=(10,6))
    sns.histplot(geo_failures, bins=range(0, k_vals_g[-1]+2), stat='probability', 
                 color="gold", ax=ax)
    ax.plot(k_vals_g, pmf_geom, 'o-', color="green", label=f"Geom(p={p_hat:.3f}) PMF (shifted)")
    ax.set_title('Failures before First Success vs Geometric PMF')
    ax.set_xlabel('Failures before success')
    ax.legend()
    plt.show();

png

Operational Insights from These Models

By applying Poisson, Exponential, and Geometric distributions to call center data, you can: 1. Predict call volumes and waiting times, 2. Optimize staffing and training, 3. Improve customer satisfaction through data-driven decisions.

Staffing and Scheduling

Poisson arrival rates help forecast expected call volumes per hour. Use this to plan agent shifts and avoid under/over-staffing.

Service Level Agreements (SLAs)

Exponential waiting times allow you to estimate probability of long waits. Example: If SLA requires answering within 30 seconds, compute: $$ P(T \le 0.5 \text{ min}) = 1 - e^{-\lambda \times 0.5} $$

Quality and Training

Geometric distribution highlights how many attempts are needed before resolution. High failure streaks indicate complex topics or agent skill gaps.

Customer Experience

Combine these models to simulate queue performance: 1. Expected wait time, 2. Probability of abandonment, 3. Resolution likelihood on first attempt.

Validating Distribution Fits with Chi-Square Tests

When we fit a theoretical distribution to real-world data, we need to check how well the model explains the observed frequencies. One common method is the Chi-Square Goodness-of-Fit (GOF) test. The test compares:
1. Observed frequencies (from your dataset), 2. Expected frequencies (from the fitted distribution).

The test statistic: $$ \chi^2 = \sum_{i=1}^{k} \frac{(O_i - E_i)^2}{E_i}$$ Where: \(O_i\) = observed count in bin \(i\),
\(E_i\) = expected count in bin \(i\),
\(k\) = number of bins.

Interpretation:

Null hypothesis \(H_0\): Data follows the specified distribution.
Large \(\chi^2\) → poor fit.
p-value > 0.05 → fail to reject \(H_0\) (good fit).

Steps for Chi-Square GOF 1. Bin the data (combine bins with expected count < 5). 2. Compute observed and expected frequencies. 3. Apply scipy.stats.chisquare() in Python.

# Poisson distribution
chi2_stat_poisson, p_poisson = stats.chisquare(hourly_counts.groupby('no_of_calls').timestamp.count(), 
                                               f_exp=pmf*hourly_counts.no_of_calls.count())
print(f"Poisson Chi-Square p-value: {p_poisson:.4f}")

fig, ax = plt.subplots(figsize=(10,6))
res = stats.probplot(hourly_counts.no_of_calls, dist=stats.poisson, 
                     sparams=(hourly_counts.no_of_calls.mean(),), plot=ax)
ax.set_title("Poisson QQ-Plot for lambda = "+str(round(hourly_counts.no_of_calls.mean(),2)))
plt.show();

# Exponential distribution
s_D, ks_p = stats.kstest(inter_arrival_min, 'expon', args=(0, 1/lambda_exp_hat))
print(f"Exponential Kolmogorov-Smirnov test p-value: {p_poisson:.4f}")

fig, ax = plt.subplots(figsize=(10,6))
probs = np.linspace(0.01, 0.99, 99)
emp_q = np.quantile(inter_arrival_min, probs)
theo_q = stats.expon.ppf(probs, scale=1/lambda_exp_hat)
ax.plot(theo_q, emp_q, 'o', alpha=0.7)
ax.plot(theo_q, theo_q, 'r--', lw=2)
ax.set_title('Exponential QQ-Plot')
ax.set_xlabel('Theoretical quantiles')
ax.set_ylabel('Empirical quantiles')
plt.show();

# Geometric distribution
n_fail = np.mean(geo_failures)
p_hat = 1 / (mean_fail + 1)
k_vals_g = np.arange(0, int(np.percentile(geo_failures, 99))+1)
pmf_geom = stats.geom.pmf(k_vals_g+1, p_hat)

emp_counts = pd.Series(geo_failures).value_counts().sort_index()
obs_g = np.array([emp_counts.get(k, 0) for k in k_vals_g])
exp_g = pmf_geom * obs_g.sum()

chi2_stat_geom, p_geom = stats.chisquare(obs_g, f_exp=exp_g*np.sum(obs_g)/np.sum(exp_g))
print(f"Geometric Chi-Square p-value: {p_geom:.4f}")


fig, ax = plt.subplots(figsize=(10,6))
ax.scatter(obs_g, exp_g, s=40, alpha=0.75, edgecolor='k')
max_q = max(obs_g.max(), exp_g.max())
ax.plot([0, max_q], [0, max_q], 'r--', lw=2)
ax.set_xlabel('Theoretical quantiles (Geometric, failures)')
ax.set_ylabel('Empirical quantiles (sample)')
ax.set_title(f'Geometric Q–Q Plot (p̂={p_hat:.3f})')
Poisson Chi-Square p-value: 0.0000

png

Exponential Kolmogorov-Smirnov test p-value: 0.0000

png

Geometric Chi-Square p-value: 0.7642





Text(0.5, 1.0, 'Geometric Q–Q Plot (p̂=0.730)')

png

Central Limit theorem

The Central Limit Theorem states:

When you take repeated samples from any population and compute their means, the distribution of those sample means approaches a normal distribution as the sample size grows—regardless of the original population’s shape.

Why is this important for call centers?
Call center metrics like call arrival counts per hour or average talk duration per agent often have skewed or discrete distributions (Poisson, Exponential, etc.). But when managers look at daily averages or weekly averages, those averages tend to look normal thanks to the CLT.

Example: Calls per Hour
Individual hourly call counts follow a Poisson distribution (skewed, discrete). If you take samples of 30 hours and compute their average call count, and repeat this many times: 1. The histogram of these sample means will look bell-shaped. 2. This happens even though the original hourly counts are not normal.

Visual Demonstration
1. Start with your dataset of hourly call counts. 2. Draw 1000 samples, each of size \(n = 30\) hours. 3. Compute the mean calls per hour for each sample. 4. Plot the histogram of these means → it will approximate a normal distribution.

sample_size = 30
n_samples = 1000

# Generate sample means
sample_means = []
for _ in range(n_samples):
    sample = np.random.choice(hourly_counts.no_of_calls, size=sample_size, replace=True)
    sample_means.append(sample.mean())

# Plot histogram of sample means
plt.figure(figsize=(10,6))
plt.hist(sample_means, bins=10, color="#86BC25", edgecolor='#2E7C26', density=True)
plt.title(f"CLT in Action: Distribution of Sample Means (n={sample_size})")
plt.xlabel("Sample Mean (calls per hour)")
plt.ylabel("Density")

# Overlay normal curve
mean = np.mean(sample_means)
std = np.std(sample_means)
x = np.linspace(min(sample_means), max(sample_means), 200)
plt.plot(x, stats.norm.pdf(x, loc=mean, scale=std), 'r--', lw=2, label="Normal Approximation")
plt.legend()
plt.show();

png

Back to top