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\).
# 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();

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();

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();

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

Exponential Kolmogorov-Smirnov test p-value: 0.0000

Geometric Chi-Square p-value: 0.7642
Text(0.5, 1.0, 'Geometric Q–Q Plot (p̂=0.730)')

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();
