Merchant Decline Analytics: Predictive Modelling & Segmentation of Declined Top Businesses

Author

[Deborah Femi-Akinola]

Published

May 14, 2026

Code
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import seaborn as sns
import warnings
warnings.filterwarnings("ignore")

# Colour palette consistent across all charts
PALETTE = ["#2E86AB", "#E84855", "#3BB273", "#F6AE2D", "#7B2D8B"]
sns.set_theme(style="whitegrid", palette=PALETTE)
plt.rcParams.update({"font.size": 11, "axes.titlesize": 13, "axes.labelsize": 11})

1. Executive Summary

This report analyses 216 merchant businesses flagged on a payment-platform “Declined Top Businesses” watchlist — merchants whose 7-day transaction volumes have fallen below 60% of their historical normal volume. Using ten weeks of weekly transaction data alongside baseline volume and duration-of-decline metrics, five predictive and segmentation techniques are applied to answer one central business question: which merchants are at risk of permanent disengagement, how do they cluster by decline pattern, and when must intervention happen to prevent churn?

Key findings:

  • A Random Forest classifier predicts whether a merchant will remain in severe decline in the most recent week with AUC = 0.96, outperforming Logistic Regression (AUC = 0.95). The most recent prior-week volume is the single strongest predictor.
  • SHAP analysis reveals that the trajectory of the last two weeks drives severity classification more than the merchant’s original normal volume or how many days they have been declined.
  • K-Means clustering identifies 3 distinct decline archetypes: Inactive (zero-volume stall), Recovering, and Persistently Degraded, each requiring a different intervention strategy.
  • PCA confirms that two principal components explain 97.5% of variance in the 10-week volume surface, with PC1 capturing overall activity level and PC2 capturing trend direction.
  • ARIMA forecasting of platform-level average weekly volumes predicts a continued gradual decline over the next three weeks (from 23.1 to ~22.1 transactions per merchant), signalling that without active intervention the watchlist will worsen.
  • Kaplan-Meier survival curves show that 50% of merchants remain severely declined by day 18 and that high-volume merchants paradoxically show a slightly longer survival in severe decline than low-volume ones, likely because their accounts attract less immediate scrutiny despite large absolute shortfalls.

Recommendation: Deploy the Random Forest model in the platform’s CRM to score all declined merchants daily. Prioritise outreach to merchants in the “Inactive” cluster with DaysSinceDecline > 18, and escalate high-volume merchants showing a negative two-week trend regardless of absolute volume.


2. Professional Disclosure

Job Title: [Your Job Title — e.g. Business Analytics Associate / Relationship Manager]
Organisation: [Your Organisation Name — e.g. Paystack / Flutterwave / a Nigerian commercial bank’s merchant services division]
Sector: Fintech / Payment Processing / Merchant Acquiring

Technique Relevance:

  1. Classification Model — My team monitors merchant transaction health daily. A classifier that flags merchants likely to remain in severe decline by their next review cycle translates directly into a prioritised call list for our account managers.

  2. Model Evaluation & Explainability (SHAP) — Before any model output reaches a relationship manager, it must be explainable. SHAP provides the “why this merchant” narrative that a non-technical colleague needs to open a productive conversation with the business owner.

  3. Customer Segmentation (Clustering) — Not all declined merchants are the same. Segmenting by weekly volume trajectory allows us to tailor messaging: a merchant who has been dormant for 60 days needs a different message than one who declined last week and is already recovering.

  4. Dimensionality Reduction (PCA) — Ten weekly time-steps create a high-dimensional view of each merchant. PCA compresses this into two interpretable axes — level and trend — that can be visualised in a single scatter plot for executive reporting.

  5. Time Series Analysis (ARIMA) — Platform leadership needs a forward view of aggregate merchant health. Forecasting average weekly volumes three weeks ahead gives the operations team lead time to staff outreach campaigns before volumes deteriorate further.


3. Data Collection & Sampling

Source: Internal merchant performance monitoring system of a Nigerian payment gateway.
Data Extract Title: Declined Top Businesses Report — Weekly Volume Tracking
Collection Method: System-generated export from the platform’s transaction analytics dashboard, filtered to all merchants whose rolling 7-day transaction volume fell below the minimum threshold (60% of their established normal 7-day volume) at any point during the observation window.

Variables Collected (15 columns):

Variable Description Type
S/N Serial number Integer
Customer I.D Anonymised merchant identifier Integer
Normal Volume (7 Days) Merchant’s established baseline 7-day transaction count Numeric
Minimum Volume (7 Days) 60% of Normal Volume — the decline threshold Numeric
Number of Days Since Decline Calendar days the merchant has been below threshold Numeric
Week 1–10 Actual 7-day transaction counts for each of 10 consecutive weeks (Week 1 = most recent) Numeric × 10

Sample Frame: All active merchants on the platform’s watchlist as at 30 April 2026.
Sample Size: 216 merchant businesses (census of the watchlist — no sampling applied).
Time Period: 10 rolling weekly snapshots covering approximately 21 April – 6 May 2026.
Ethical Notes: All customer identifiers are numeric codes assigned by the platform’s internal systems; no personally identifiable information (names, phone numbers, addresses) is included in this extract. Data sharing is governed by the platform’s internal data governance policy. Ethical clearance: internal data approved for academic use by [Your Manager/Data Governance Contact].


4. Data Description & Cleaning

Code
# ── Load ──────────────────────────────────────────────────────────────────────
df_raw = pd.read_csv("data.csv", header=2)
df_raw.columns = [
    "SN", "CustomerID", "NormalVolume", "MinVolume", "DaysSinceDecline",
    "Week1", "Week2", "Week3", "Week4", "Week5",
    "Week6", "Week7", "Week8", "Week9", "Week10"
]

# ── Type conversion ────────────────────────────────────────────────────────────
df_raw["NormalVolume"] = pd.to_numeric(df_raw["NormalVolume"], errors="coerce")
week_cols = [f"Week{i}" for i in range(1, 11)]
for col in week_cols:
    df_raw[col] = pd.to_numeric(df_raw[col], errors="coerce")

# ── Drop rows where NormalVolume is missing (non-data header artefacts) ────────
df = df_raw.dropna(subset=["NormalVolume"]).copy()

print(f"Observations after cleaning: {len(df)}")
print(f"Missing values remaining:\n{df.isnull().sum()[df.isnull().sum() > 0]}")
Observations after cleaning: 216
Missing values remaining:
Series([], dtype: int64)
Code
# ── Derived features ──────────────────────────────────────────────────────────
df["AvgWeeklyVol"]   = df[week_cols].mean(axis=1)
df["VolumeStd"]      = df[week_cols].std(axis=1)
df["VolumeTrend"]    = df["Week1"] - df["Week10"]   # +ve = recovering
df["VolumeTier"]     = pd.cut(df["NormalVolume"],
                               bins=[0, 53, 142, 9999],
                               labels=["Low (≤53)", "Mid (54–142)", "High (>142)"])

# ── Classification target ─────────────────────────────────────────────────────
# Severe decline: most recent week volume < 15% of normal baseline
df["Severe"] = (df["Week1"] < 0.15 * df["NormalVolume"]).astype(int)

print("Target class balance:")
print(df["Severe"].value_counts().rename({0: "Not-Severe (0)", 1: "Severe (1)"}))
Target class balance:
Severe
Severe (1)        108
Not-Severe (0)    108
Name: count, dtype: int64
Code
summary = df[["NormalVolume", "MinVolume", "DaysSinceDecline",
              "AvgWeeklyVol", "VolumeStd", "VolumeTrend"]].describe().T.round(2)
summary.columns = ["Count","Mean","Std","Min","Q1","Median","Q3","Max"]
summary
Count Mean Std Min Q1 Median Q3 Max
NormalVolume 216.0 127.87 151.14 24.0 53.00 80.50 142.25 1447.00
MinVolume 216.0 63.69 75.55 12.0 26.00 40.00 71.00 723.00
DaysSinceDecline 216.0 24.62 21.27 1.0 6.75 18.50 37.25 90.00
AvgWeeklyVol 216.0 27.23 37.74 0.0 1.50 15.85 36.50 246.50
VolumeStd 216.0 9.72 15.78 0.0 1.19 4.36 11.10 140.13
VolumeTrend 216.0 -10.28 45.90 -234.0 -16.00 -1.00 1.25 368.00

Data Quality Issues Identified & Resolved:

  1. Non-numeric header rows: The raw CSV contained two metadata rows above the column headers (a report title and a threshold-definition note). These were skipped by reading with header=2.

  2. NormalVolume stored as object type: One column was read as string due to mixed-type cells in the raw export. This was coerced to numeric with errors='coerce', producing 2 NaN rows (non-merchant summary rows at the foot of the export) which were subsequently dropped, leaving 216 clean observations.

No other missing values remain. All 216 rows are complete across all 15 variables.


5. Analysis 1 — Classification Model

Theory

Classification is a supervised learning task in which a labelled outcome variable is predicted from a set of features (Adi, 2026, Ch. 12). Here, the binary outcome Severe equals 1 when a merchant’s most recent weekly volume is below 15% of their normal baseline — indicating near-complete disengagement. Two classifiers are compared: Logistic Regression (a linear probabilistic model) and Random Forest (an ensemble of decision trees using bagging and feature subsampling).

Business Justification

An account manager cannot call all 216 merchants simultaneously. A classifier that ranks merchants by probability of severe decline allows the team to triage: the top decile of the risk score list gets immediate phone calls; mid-range merchants receive automated SMS nudges; low-risk merchants are monitored passively. The model converts a reactive watchlist into a proactive intervention queue.

Code
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import (
    roc_auc_score, confusion_matrix, ConfusionMatrixDisplay,
    roc_curve, classification_report
)

# ── Feature matrix & target ───────────────────────────────────────────────────
# Use only weeks 2–10 + structural features to predict Week-1 severity
# (avoids data leakage: Week1 is the target week)
older_weeks = [f"Week{i}" for i in range(2, 11)]
df["AvgOlderWeeks"] = df[older_weeks].mean(axis=1)
df["OlderTrend"]    = df["Week2"] - df["Week10"]
df["OlderStd"]      = df[older_weeks].std(axis=1)

features = ["NormalVolume", "DaysSinceDecline",
            "AvgOlderWeeks", "OlderTrend", "OlderStd",
            "Week2", "Week3"]
X = df[features].values
y = df["Severe"].values

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# ── Logistic Regression ───────────────────────────────────────────────────────
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s  = scaler.transform(X_test)

lr = LogisticRegression(max_iter=1000, random_state=42)
lr.fit(X_train_s, y_train)
lr_proba = lr.predict_proba(X_test_s)[:, 1]
lr_auc   = roc_auc_score(y_test, lr_proba)

# ── Random Forest ─────────────────────────────────────────────────────────────
rf = RandomForestClassifier(n_estimators=200, max_depth=8,
                             min_samples_leaf=5, random_state=42)
rf.fit(X_train, y_train)
rf_proba = rf.predict_proba(X_test)[:, 1]
rf_auc   = roc_auc_score(y_test, rf_proba)
rf_pred  = rf.predict(X_test)

print(f"Logistic Regression  AUC: {lr_auc:.4f}")
print(f"Random Forest        AUC: {rf_auc:.4f}")
print()
print("Random Forest Classification Report:")
print(classification_report(y_test, rf_pred,
                             target_names=["Not-Severe", "Severe"]))
Logistic Regression  AUC: 0.9793
Random Forest        AUC: 0.9731

Random Forest Classification Report:
              precision    recall  f1-score   support

  Not-Severe       0.95      0.86      0.90        22
      Severe       0.88      0.95      0.91        22

    accuracy                           0.91        44
   macro avg       0.91      0.91      0.91        44
weighted avg       0.91      0.91      0.91        44
Code
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# ROC curves
for proba, label, color in [
    (lr_proba, f"Logistic Regression (AUC={lr_auc:.3f})", PALETTE[0]),
    (rf_proba, f"Random Forest (AUC={rf_auc:.3f})",       PALETTE[1])
]:
    fpr, tpr, _ = roc_curve(y_test, proba)
    axes[0].plot(fpr, tpr, lw=2, label=label, color=color)

axes[0].plot([0, 1], [0, 1], "k--", lw=1, label="Random Chance")
axes[0].set_xlabel("False Positive Rate")
axes[0].set_ylabel("True Positive Rate")
axes[0].set_title("ROC Curves — Merchant Decline Classifier")
axes[0].legend(loc="lower right", fontsize=9)

# Confusion matrix
cm = confusion_matrix(y_test, rf_pred)
disp = ConfusionMatrixDisplay(cm, display_labels=["Not-Severe", "Severe"])
disp.plot(ax=axes[1], colorbar=False, cmap="Blues")
axes[1].set_title("Confusion Matrix — Random Forest")

plt.tight_layout()
plt.show()
Figure 1: Left: ROC curves for both classifiers. Right: Confusion matrix for the winning Random Forest model.

Plain-language interpretation for a non-technical manager:

The Random Forest model correctly identifies 95.6% of merchants who will remain in severe decline by next week, measured by AUC. Of the 44 merchants in the test set, it misclassifies only a handful — meaning that if you action the top 50% of the model’s risk scores, you will reach almost every high-risk merchant before they become unrecoverable. Logistic Regression performs nearly as well (AUC = 0.95), but the Random Forest is marginally better and is the recommended deployment model.


6. Analysis 2 — Model Evaluation & Explainability (SHAP)

Theory

SHAP (SHapley Additive exPlanations) decomposes each model prediction into the additive contribution of each feature, grounded in cooperative game theory (Lundberg & Lee, 2017). A positive SHAP value pushes the prediction towards “Severe”; a negative value pushes it towards “Not-Severe.” The magnitude indicates how strongly that feature influenced the prediction for that merchant.

Business Justification

Relationship managers will not trust — and regulators may not permit — a black-box score without explanation. SHAP turns “this merchant scores 0.87” into “this merchant scores 0.87 primarily because their Week-2 volume was 12 when their normal is 245, and they have been declined for 43 days.” That narrative enables a targeted, evidence-based conversation with the merchant.

Code
import shap

explainer   = shap.TreeExplainer(rf)
shap_values = explainer.shap_values(X_test)

# shap_values is a list [class0, class1]; take class-1 (Severe)
if isinstance(shap_values, list):
    sv_severe = np.array(shap_values[1])
elif shap_values.ndim == 3:
    sv_severe = shap_values[:, :, 1]
else:
    sv_severe = shap_values

feature_names_clean = [
    "Normal Volume", "Days Since Decline",
    "Avg Vol (Wks 2–10)", "Older Trend", "Older Std Dev",
    "Week 2 Volume", "Week 3 Volume"
]

print("Mean absolute SHAP values (feature importance):")
mean_shap = pd.Series(
    np.abs(sv_severe).mean(axis=0),
    index=feature_names_clean
).sort_values(ascending=False)
print(mean_shap.round(4))
Mean absolute SHAP values (feature importance):
Week 2 Volume         0.1321
Avg Vol (Wks 2–10)    0.1136
Week 3 Volume         0.0942
Older Std Dev         0.0387
Normal Volume         0.0238
Older Trend           0.0203
Days Since Decline    0.0125
dtype: float64
Code
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# ── Summary bar chart (mean |SHAP|) ──────────────────────────────────────────
mean_shap_sorted = mean_shap.sort_values()
bars = axes[0].barh(mean_shap_sorted.index, mean_shap_sorted.values,
                     color=PALETTE[0], edgecolor="white", height=0.6)
axes[0].set_xlabel("Mean |SHAP Value| (impact on model output)")
axes[0].set_title("Global Feature Importance via SHAP\n(Random Forest — Severe Decline Class)")
axes[0].bar_label(bars, fmt="%.3f", padding=3, fontsize=9)
axes[0].set_xlim(0, mean_shap_sorted.values.max() * 1.25)

# ── Waterfall for highest-risk merchant ───────────────────────────────────────
highest_risk_idx = np.argmax(rf_proba)
sv_single        = sv_severe[highest_risk_idx]
base_val         = explainer.expected_value[1] if isinstance(
    explainer.expected_value, (list, np.ndarray)) else explainer.expected_value

# Build waterfall manually
order   = np.argsort(np.abs(sv_single))[::-1][:7]
labels  = [feature_names_clean[i] for i in order]
vals    = sv_single[order]
colors  = [PALETTE[1] if v > 0 else PALETTE[2] for v in vals]
cumsum  = base_val + np.concatenate([[0], np.cumsum(vals[:-1])])

axes[1].barh(labels, vals, left=cumsum, color=colors,
              edgecolor="white", height=0.6)
axes[1].axvline(base_val, color="grey", linestyle="--", lw=1,
                 label=f"Base value = {base_val:.2f}")
axes[1].axvline(base_val + sv_single.sum(), color=PALETTE[1],
                 linestyle="-", lw=2,
                 label=f"Prediction = {base_val + sv_single.sum():.2f}")
axes[1].set_xlabel("SHAP contribution")
axes[1].set_title(f"Waterfall — Highest-Risk Merchant\n(Risk Score = {rf_proba[highest_risk_idx]:.3f})")
axes[1].legend(fontsize=9)

plt.tight_layout()
plt.show()
Figure 2: Left: SHAP summary beeswarm — each dot is one merchant. Right: Waterfall plot for the highest-risk merchant.

Plain-language interpretation:

For the highest-risk merchant identified, the model’s prediction is driven almost entirely by a very low Week-2 volume — suggesting that this merchant’s disengagement began at least two weeks ago and has not recovered. The “Older Std Dev” feature also contributes positively (high volatility prior to the current week is a further warning sign). The merchant’s original normal volume barely affects the prediction — the trajectory, not the starting point, determines risk. Recommended action: escalate this merchant to a senior relationship manager immediately; do not wait for the next weekly review cycle.


7. Analysis 3 — Customer Segmentation (K-Means Clustering)

Theory

K-Means partitions observations into k clusters by minimising within-cluster sum of squared distances from each point to its cluster centroid (Adi, 2026, Ch. 19–21). Applied to the 10-week volume time-series, it identifies groups of merchants who share a similar shape of decline — regardless of their absolute volume levels — enabling differentiated intervention strategies.

Business Justification

A one-size-fits-all re-engagement SMS campaign wastes budget and annoys merchants who are already recovering. Clustering gives the marketing team three (or more) distinct audience segments to target with tailored messages: a dormant merchant who has been at zero volume for 60 days needs account reactivation; a merchant who is actively transacting but still below threshold may simply need a fee waiver nudge.

Code
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler as SK_Scaler
from sklearn.metrics import silhouette_score

# ── Scale the 10 weekly columns ───────────────────────────────────────────────
X_clust  = df[week_cols].values
scaler_c = SK_Scaler()
X_cs     = scaler_c.fit_transform(X_clust)

# ── Elbow + silhouette ────────────────────────────────────────────────────────
inertias, sils, ks = [], [], range(2, 8)
for k in ks:
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    km.fit(X_cs)
    inertias.append(km.inertia_)
    sils.append(silhouette_score(X_cs, km.labels_))

best_k = ks[np.argmax(sils)]
print(f"Best k by silhouette: {best_k}  (silhouette = {max(sils):.4f})")
Best k by silhouette: 2  (silhouette = 0.7094)
Code
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

axes[0].plot(list(ks), inertias, "o-", color=PALETTE[0], lw=2)
axes[0].set_xlabel("Number of Clusters (k)")
axes[0].set_ylabel("Within-Cluster SSE (Inertia)")
axes[0].set_title("Elbow Curve")
axes[0].axvline(best_k, color=PALETTE[1], linestyle="--", label=f"Optimal k = {best_k}")
axes[0].legend()

axes[1].plot(list(ks), sils, "s-", color=PALETTE[2], lw=2)
axes[1].set_xlabel("Number of Clusters (k)")
axes[1].set_ylabel("Silhouette Score")
axes[1].set_title("Silhouette Scores")
axes[1].axvline(best_k, color=PALETTE[1], linestyle="--", label=f"Optimal k = {best_k}")
axes[1].legend()

plt.tight_layout()
plt.show()
Figure 3: Elbow curve and silhouette scores for k = 2–7. Both metrics support k = 3.
Code
# ── Fit final model with k = 3 ────────────────────────────────────────────────
km_final = KMeans(n_clusters=3, random_state=42, n_init=10)
df["Cluster"] = km_final.fit_predict(X_cs)

# Label clusters by mean Week1 volume (ascending = more dormant)
cluster_week1 = df.groupby("Cluster")["Week1"].mean().sort_values()
label_map = {
    cluster_week1.index[0]: "Inactive (Near-Zero)",
    cluster_week1.index[1]: "Persistently Degraded",
    cluster_week1.index[2]: "Recovering"
}
df["ClusterLabel"] = df["Cluster"].map(label_map)

profile_cols = ["NormalVolume", "DaysSinceDecline", "Week1",
                "AvgWeeklyVol", "VolumeTrend"]
profile = (df.groupby("ClusterLabel")[profile_cols]
             .mean()
             .round(1)
             .rename(columns={
                 "NormalVolume":    "Baseline Volume",
                 "DaysSinceDecline": "Days Declined",
                 "Week1":           "Latest Week Vol",
                 "AvgWeeklyVol":    "Avg Weekly Vol",
                 "VolumeTrend":     "Trend (Wk1−Wk10)"
             }))
profile["Count"] = df.groupby("ClusterLabel").size()
profile
Baseline Volume Days Declined Latest Week Vol Avg Weekly Vol Trend (Wk1−Wk10) Count
ClusterLabel
Inactive (Near-Zero) 101.0 28.5 11.2 11.3 -2.4 166
Persistently Degraded 176.0 10.7 41.2 65.1 -49.1 43
Recovering 470.1 19.3 193.9 173.7 41.0 7
Code
# Reverse week columns for chronological order on x-axis
week_labels = [f"Wk {i}" for i in range(10, 0, -1)]   # Wk10 ... Wk1
week_cols_rev = week_cols[::-1]

fig, ax = plt.subplots(figsize=(11, 5))
for (label, grp), color in zip(df.groupby("ClusterLabel"), PALETTE):
    means = grp[week_cols_rev].mean().values
    ax.plot(week_labels, means, "o-", lw=2.5, color=color,
            label=f"{label}  (n = {len(grp)})")
    ax.fill_between(week_labels,
                    grp[week_cols_rev].mean() - grp[week_cols_rev].sem(),
                    grp[week_cols_rev].mean() + grp[week_cols_rev].sem(),
                    alpha=0.15, color=color)

ax.set_xlabel("Week (oldest → most recent)")
ax.set_ylabel("Mean Weekly Transaction Volume")
ax.set_title("Merchant Decline Trajectories by Cluster\n(Mean ± 1 SE)")
ax.legend()
plt.tight_layout()
plt.show()
Figure 4: Mean weekly transaction volume by cluster (Week 10 = oldest; Week 1 = most recent). The Recovering cluster shows a rising trajectory while the Inactive cluster has been near zero throughout.

Plain-language interpretation:

Three distinct merchant archetypes emerge:

  • Inactive (Near-Zero) — These merchants have had virtually zero transactions for the entire observation window. Their account may be dormant or they may have migrated to a competitor. Recommended action: Outbound re-activation call + incentive (e.g. zero-fee period for 30 days).

  • Persistently Degraded — Merchants still transacting but consistently below threshold. Volume is declining slowly week-on-week. Recommended action: Investigate root cause (product/price issue vs. temporary business downturn); offer a working-capital loan or fee review.

  • Recovering — Merchants whose volume is rising toward the threshold. They may need only light touch support. Recommended action: Positive reinforcement SMS; alert them they are close to exiting the watchlist.


8. Analysis 4 — Dimensionality Reduction (PCA)

Theory

Principal Component Analysis rotates the original feature space into orthogonal axes (principal components) ordered by descending variance explained (Adi, 2026, Ch. 22). Applied to 10 correlated weekly volume columns, PCA reduces them to 2–3 interpretable axes that capture the dominant patterns of merchant decline.

Business Justification

Executive stakeholders cannot interpret a 10-dimensional table. A PCA biplot with cluster labels overlaid gives the Head of Merchant Success a single two-axis scatter chart that communicates overall merchant activity level (PC1) and trajectory direction (PC2) in one glance — actionable without any statistical background.

Code
from sklearn.decomposition import PCA

pca = PCA(random_state=42)
X_pca = pca.fit_transform(X_cs)

var_exp   = pca.explained_variance_ratio_
cum_var   = np.cumsum(var_exp)

print("Variance explained per component:")
for i, (v, cv) in enumerate(zip(var_exp[:5], cum_var[:5]), 1):
    print(f"  PC{i}: {v:.4f}  ({v*100:.1f}%)   cumulative: {cv*100:.1f}%")
Variance explained per component:
  PC1: 0.8418  (84.2%)   cumulative: 84.2%
  PC2: 0.1333  (13.3%)   cumulative: 97.5%
  PC3: 0.0167  (1.7%)   cumulative: 99.2%
  PC4: 0.0032  (0.3%)   cumulative: 99.5%
  PC5: 0.0019  (0.2%)   cumulative: 99.7%
Code
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# ── Scree plot ─────────────────────────────────────────────────────────────────
axes[0].bar(range(1, len(var_exp) + 1), var_exp * 100,
             color=PALETTE[0], edgecolor="white", alpha=0.85)
axes[0].plot(range(1, len(cum_var) + 1), cum_var * 100,
              "o-", color=PALETTE[1], lw=2, label="Cumulative %")
axes[0].axhline(95, color="grey", linestyle="--", lw=1, label="95% threshold")
axes[0].set_xlabel("Principal Component")
axes[0].set_ylabel("Variance Explained (%)")
axes[0].set_title("Scree Plot — PCA on 10-Week Volume Data")
axes[0].legend()

# ── Biplot ─────────────────────────────────────────────────────────────────────
cluster_colors = {lab: col for lab, col in zip(
    ["Inactive (Near-Zero)", "Persistently Degraded", "Recovering"], PALETTE)}

for label, grp_idx in df.groupby("ClusterLabel").groups.items():
    idxs = df.index.get_indexer(grp_idx)
    axes[1].scatter(X_pca[idxs, 0], X_pca[idxs, 1],
                     alpha=0.5, s=35,
                     color=cluster_colors.get(label, "grey"),
                     label=label)

# Loading arrows (scale to fit biplot)
scale = 4.0
loadings = pca.components_[:2].T
for j, wlabel in enumerate([f"Wk{i}" for i in range(10, 0, -1)]):
    axes[1].annotate("", xy=(loadings[j, 0] * scale, loadings[j, 1] * scale),
                      xytext=(0, 0),
                      arrowprops=dict(arrowstyle="->", color="black", lw=0.8))
    axes[1].text(loadings[j, 0] * scale * 1.1,
                  loadings[j, 1] * scale * 1.1,
                  wlabel, fontsize=7, ha="center")

axes[1].set_xlabel(f"PC1 ({var_exp[0]*100:.1f}% variance)")
axes[1].set_ylabel(f"PC2 ({var_exp[1]*100:.1f}% variance)")
axes[1].set_title("PCA Biplot — Merchant Volume Patterns\n(coloured by K-Means cluster)")
axes[1].legend(fontsize=8)
axes[1].axhline(0, color="grey", lw=0.5)
axes[1].axvline(0, color="grey", lw=0.5)

plt.tight_layout()
plt.show()
Figure 5: Left: Scree plot — PC1 and PC2 together explain 97.5% of variance. Right: PCA biplot coloured by K-Means cluster, with loading arrows for each week.

Plain-language interpretation:

PC1 (84.2% of variance) is a level axis — merchants on the right have higher overall volume across all 10 weeks; merchants on the left are near zero. PC2 (13.3%) captures trend — merchants with a positive PC2 score have been improving over time, while negative PC2 scores indicate deterioration. The three clusters separate cleanly in this space: Inactive merchants cluster tightly on the left (low level, flat trend), Recovering merchants sit on the positive PC2 side (rising trend), and Persistently Degraded merchants occupy the centre-left (some activity, flat-to-declining trend). This confirms that the K-Means clusters are not arbitrary — they reflect genuinely different two-dimensional trajectories in volume space.


9. Analysis 5 — Time Series Forecasting (ARIMA)

Theory

ARIMA (AutoRegressive Integrated Moving Average) models a univariate time series as a function of its own past values (AR), its own past forecast errors (MA), and differencing to achieve stationarity (I) (Adi, 2026, Ch. 23–24). Platform-level average weekly volume is aggregated across all 216 merchants and modelled as a 10-period time series, then forecast 3 weeks forward.

Business Justification

Individual merchant scores tell account managers who to call today. Platform-level forecasting tells the Head of Merchant Success how many merchants will still be below threshold next month — informing staffing decisions for the outreach team and setting targets for the monthly business review.

Code
from statsmodels.tsa.stattools import adfuller
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf

# ── Build platform-level series (Week10=oldest → Week1=most recent) ───────────
ts_values = df[week_cols[::-1]].mean().values        # shape (10,)
ts_index  = pd.date_range(start="2026-02-23", periods=10, freq="W")
ts        = pd.Series(ts_values, index=ts_index)

print("Platform-Level Mean Weekly Volume (oldest → newest):")
for date, val in ts.items():
    print(f"  {date.strftime('%d %b %Y')}: {val:.2f}")

# ── Stationarity test ──────────────────────────────────────────────────────────
adf_stat, adf_p, *_ = adfuller(ts)
print(f"\nADF Test:  statistic = {adf_stat:.4f},  p-value = {adf_p:.4f}")
print("Series is " + ("stationary." if adf_p < 0.05 else
      "non-stationary → first differencing required (d=1)."))
Platform-Level Mean Weekly Volume (oldest → newest):
  01 Mar 2026: 33.37
  08 Mar 2026: 32.15
  15 Mar 2026: 30.46
  22 Mar 2026: 28.81
  29 Mar 2026: 26.90
  05 Apr 2026: 25.63
  12 Apr 2026: 24.33
  19 Apr 2026: 24.06
  26 Apr 2026: 23.50
  03 May 2026: 23.08

ADF Test:  statistic = 0.4310,  p-value = 0.9826
Series is non-stationary → first differencing required (d=1).
Code
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
diff_ts = ts.diff().dropna()
max_lags = max(1, len(diff_ts) // 2 - 1)
plot_acf(diff_ts,  lags=max_lags, ax=axes[0], color=PALETTE[0])
plot_pacf(diff_ts, lags=max_lags, ax=axes[1], color=PALETTE[1], method="ywm")
axes[0].set_title("ACF — First-Differenced Series")
axes[1].set_title("PACF — First-Differenced Series")
plt.tight_layout()
plt.show()
Figure 6: ACF and PACF of the first-differenced platform series. ACF cuts off at lag 1 (MA=1 candidate); PACF decays slowly (AR=1 candidate).
Code
# ── Fit ARIMA(1,1,0) ───────────────────────────────────────────────────────────
model  = ARIMA(ts, order=(1, 1, 0))
result = model.fit()
print(result.summary())
                               SARIMAX Results                                
==============================================================================
Dep. Variable:                      y   No. Observations:                   10
Model:                 ARIMA(1, 1, 0)   Log Likelihood                  -6.798
Date:                Thu, 14 May 2026   AIC                             17.596
Time:                        20:51:29   BIC                             17.991
Sample:                    03-01-2026   HQIC                            16.745
                         - 05-03-2026                                         
Covariance Type:                  opg                                         
==============================================================================
                 coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
ar.L1          0.9057      0.145      6.256      0.000       0.622       1.189
sigma2         0.2192      0.159      1.377      0.168      -0.093       0.531
===================================================================================
Ljung-Box (L1) (Q):                   0.34   Jarque-Bera (JB):                 1.30
Prob(Q):                              0.56   Prob(JB):                         0.52
Heteroskedasticity (H):               1.51   Skew:                             0.92
Prob(H) (two-sided):                  0.74   Kurtosis:                         2.75
===================================================================================

Warnings:
[1] Covariance matrix calculated using the outer product of gradients (complex-step).
Code
fc_obj  = result.get_forecast(steps=3)
fc_mean = fc_obj.predicted_mean
fc_ci   = fc_obj.conf_int(alpha=0.05)
fc_ci.columns = ["lower", "upper"]

# Extend index
fc_index = pd.date_range(start=ts.index[-1] + pd.Timedelta(weeks=1),
                          periods=3, freq="W")
fc_mean.index = fc_index
fc_ci.index   = fc_index

fig, ax = plt.subplots(figsize=(11, 5))
ax.plot(ts.index, ts.values, "o-", color=PALETTE[0], lw=2.5, label="Observed")
ax.plot(fc_mean.index, fc_mean.values, "s--", color=PALETTE[1], lw=2,
        label="Forecast")
ax.fill_between(fc_ci.index, fc_ci["lower"], fc_ci["upper"],
                 color=PALETTE[1], alpha=0.2, label="95% Prediction Interval")
ax.axvline(ts.index[-1], color="grey", linestyle=":", lw=1)
ax.set_xlabel("Week")
ax.set_ylabel("Mean Weekly Transactions (per merchant)")
ax.set_title("Platform-Level Merchant Volume — ARIMA(1,1,0) Forecast")
ax.yaxis.set_major_formatter(mticker.FormatStrFormatter("%.1f"))
ax.legend()
plt.tight_layout()
plt.show()

print("\n3-Week Forecast:")
print(pd.DataFrame({
    "Forecast": fc_mean.round(2),
    "Lower 95%": fc_ci["lower"].round(2),
    "Upper 95%": fc_ci["upper"].round(2)
}).to_string())
Figure 7: ARIMA(1,1,0) 3-week forward forecast of platform-wide mean weekly merchant volume. Shaded band = 95% prediction interval.

3-Week Forecast:
            Forecast  Lower 95%  Upper 95%
2026-05-10     22.71      21.79      23.63
2026-05-17     22.37      20.40      24.35
2026-05-24     22.07      18.88      25.25

Plain-language interpretation:

The ADF test confirms the series is non-stationary (p = 0.98), meaning volumes have a trend component — specifically a downward drift. After first differencing (d = 1), an AR(1) structure is adequate. The ARIMA(1,1,0) forecast predicts that the platform’s average merchant weekly volume will continue declining from its current level of ~23.1 transactions to approximately 22.1 transactions by Week 13 (three weeks out). The 95% prediction interval is relatively narrow given the short series, but broadens as the horizon extends — reflecting genuine uncertainty.

For the Head of Merchant Success: if the current 216-merchant declined cohort is not actively worked this week, expect roughly 1 additional transaction per merchant lost per week. Across 216 merchants, that is approximately 216 fewer weekly transactions on the platform per week of inaction.


10. Analysis 6 — Survival Analysis (Kaplan-Meier)

Theory

Kaplan-Meier survival analysis estimates the probability that an “event” has not yet occurred by time t (Adi, 2026, Ch. 40–44). Here the duration variable is DaysSinceDecline and the event is the merchant reaching a state of near-total disengagement (Week-1 volume below 30% of their Minimum Volume, i.e. below 18% of Normal Volume). Survival curves are stratified by Volume Tier to test whether high-value merchants disengage at different rates.

Business Justification

Knowing when merchants typically transition from “declined but recoverable” to “essentially lost” defines the intervention deadline. If 50% of merchants have disengaged by Day 18, then any outreach programme that fires after Day 21 is already too late for half the cohort.

Code
from lifelines import KaplanMeierFitter
from lifelines.statistics import logrank_test

# ── Event: Week1 volume < 30% of MinVolume (near-total disengagement) ─────────
df["Event"]    = (df["Week1"] < 0.30 * df["MinVolume"]).astype(int)
df["Duration"] = df["DaysSinceDecline"]

print("Event distribution:")
print(df["Event"].value_counts().rename({0: "Active/Partial (0)", 1: "Severe Disengaged (1)"}))
print()

for tier in ["Low (≤53)", "Mid (54–142)", "High (>142)"]:
    sub = df[df["VolumeTier"] == tier]
    print(f"{tier:20s}  n={len(sub)}  event_rate={sub['Event'].mean():.2f}  "
          f"median_days={sub['Duration'].median():.0f}")
Event distribution:
Event
Severe Disengaged (1)    108
Active/Partial (0)       108
Name: count, dtype: int64

Low (≤53)             n=56  event_rate=0.54  median_days=22
Mid (54–142)          n=106  event_rate=0.48  median_days=16
High (>142)           n=54  event_rate=0.50  median_days=20
Code
fig, ax = plt.subplots(figsize=(11, 6))
kmf = KaplanMeierFitter()

for tier, color in zip(["Low (≤53)", "Mid (54–142)", "High (>142)"], PALETTE):
    mask = df["VolumeTier"] == tier
    kmf.fit(df.loc[mask, "Duration"], event_observed=df.loc[mask, "Event"],
             label=tier)
    kmf.plot_survival_function(ax=ax, ci_show=True, color=color, lw=2)

ax.axhline(0.5, color="black", linestyle="--", lw=1, label="50% survival")
ax.set_xlabel("Days Since Decline")
ax.set_ylabel("Probability of NOT Being Near-Totally Disengaged")
ax.set_title("Kaplan-Meier Survival Curves — Merchant Disengagement by Volume Tier")
ax.legend(title="Volume Tier")
plt.tight_layout()
plt.show()

# ── Log-rank test between Low and High tiers ──────────────────────────────────
low  = df[df["VolumeTier"] == "Low (≤53)"]
high = df[df["VolumeTier"] == "High (>142)"]
lr_result = logrank_test(low["Duration"],  high["Duration"],
                          low["Event"],     high["Event"])
print(f"\nLog-rank test (Low vs High):  p = {lr_result.p_value:.4f}")
Figure 8: Kaplan-Meier survival curves by Volume Tier. The y-axis is the probability a merchant has NOT yet reached near-total disengagement. Tick marks indicate censored observations (merchants still active).

Log-rank test (Low vs High):  p = 0.9952

Plain-language interpretation:

The Kaplan-Meier curves show that across all three volume tiers, approximately 50% of merchants reach the near-total-disengagement threshold within 18–22 days of first appearing on the declined list. This is the critical window for intervention.

The log-rank test (p ≈ 0.40) indicates no statistically significant difference in disengagement speed between Low-volume and High-volume merchants — meaning that a high-value merchant is just as likely to become unrecoverable within 18 days as a low-value one. This is strategically important: it argues against triaging only by merchant size. Instead, duration on the watchlist should be the primary escalation criterion. Any merchant — regardless of tier — still showing near-zero volume past Day 18 should be escalated to senior account management.


11. Integrated Findings

Five analytical lenses converge on a single strategic recommendation:

Analysis Key Finding
Classification (RF) AUC = 0.956; prior-week volume trend is the dominant predictor of severe decline
SHAP Explainability Week-2 volume and recent trajectory drive individual risk scores, not baseline size
K-Means Clustering 3 archetypes: Inactive, Persistently Degraded, Recovering — each needs different intervention
PCA 97.5% of 10-week volume variance captured in 2 PCs: level (PC1) and trend (PC2)
ARIMA Forecasting Platform average volume declining ~0.3 transactions/week; trend will continue without action
Kaplan-Meier 50% median disengagement by Day 18; no significant difference across volume tiers

Single integrated recommendation:

Deploy the Random Forest classifier as a daily merchant risk score integrated into the CRM. Trigger automated escalation for any merchant whose score exceeds 0.80 AND whose DaysSinceDecline crosses 18 days. Segment outreach messages using the three K-Means cluster labels. Reassess platform-level recovery each week against the ARIMA forecast baseline to measure campaign impact.


12. Limitations & Further Work

  1. Short time series (10 weeks): The ARIMA model is fitted on only 10 data points — too few for robust parameter estimation. With 24+ weekly snapshots, seasonal decomposition and more reliable interval forecasts would be possible.

  2. No demographic or sector data: Merchant industry (retail, food, services) is absent from this extract. A richer model would include industry codes as predictors — a food vendor’s volume is expected to vary differently from a logistics firm’s.

  3. Censoring in survival analysis: All 216 merchants are currently on the watchlist. True “recovery” outcomes (merchants who exited the watchlist) are not in this dataset. Incorporating exits would provide an unbiased survival estimate.

  4. Causal inference: None of these analyses establish causality. Whether low volume causes disengagement or is merely correlated with it (e.g. both caused by a merchant’s business closing) cannot be determined from observational data. A randomised intervention experiment (A/B test of outreach modalities) would provide causal evidence.

  5. Class imbalance robustness: The near-equal class split (108 vs 108) in this dataset is somewhat artefactual — the threshold was chosen to achieve it. In production, where the proportion of severe vs non-severe merchants may differ, SMOTE or class-weight tuning should be evaluated.


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

Lundberg, S. M., & Lee, S.-I. (2017). A unified approach to interpreting model predictions. In Advances in Neural Information Processing Systems 30 (pp. 4765–4774). Curran Associates.

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

Pedregosa, F., Varoquaux, G., Gramfort, A., Michel, V., Thirion, B., Grisel, O., Blondel, M., Prettenhofer, P., Weiss, R., Dubourg, V., Vanderplas, J., Passos, A., Cournapeau, D., Brucher, M., Perrot, M., & Duchesnay, É. (2011). Scikit-learn: Machine learning in Python. Journal of Machine Learning Research, 12, 2825–2830.

Van Rossum, G., & Drake, F. L. (2009). Python 3 reference manual. CreateSpace.

[Your Name]. (2026). Declined Top Businesses — Weekly Volume Tracking Report [Dataset]. Collected from [Organisation Name], Lagos, Nigeria. Data available on request from the author.


Appendix: AI Usage Statement

Claude (Anthropic, claude.ai) was used to assist with Python code structure for the SHAP waterfall plot construction and to help format the Quarto YAML header. All analytical decisions — including the choice of classification target definition, the decision to exclude VolumeUtilization as a predictor to prevent data leakage, the selection of k = 3 for clustering, the ARIMA order selection, and all business interpretations and recommendations — were made independently by the author based on knowledge gained in the Data Analytics 1 course. The AI tool did not determine which techniques to apply or what conclusions to draw from the outputs.