K-medoids clustering grouping data points around representative medoids

K-Medoids Clustering: A More Robust Alternative

Clustering is one of the most common tools for exploring structure in data. It helps reveal groups, patterns, and relationships that are not obvious from raw tables or summary statistics. Among clustering methods, k-means is often the default choice, but it is not always the most appropriate one. K-medoids clustering is a practical and more robust alternative, especially when working with outlier-prone, heterogeneous, or non-Euclidean data.

This article introduces the core ideas behind k-medoids clustering, explains how it differs from k-means and other approaches, and outlines when it is worth considering in applied data science work.

The core idea behind K-medoids

At a high level, k-medoids clustering partitions data into k groups, just like k-means. The key difference lies in how each cluster is represented. Instead of using the mean of the points in a cluster, k-medoids chooses an actual data point as the cluster representative. This representative is called the medoid.

The medoid is whichever point in a cluster has the lowest total distance to the rest. Because it must be a real observation, the medoid always corresponds to a valid and interpretable data record.

Figure 1 puts these two representatives side by side. It marks two points for one group of observations, the mean that k-means would use and the medoid that k-medoids would use. Add an extreme observation and drag it away from the group to see what happens.

Figure 1 · robustness

How the mean and the medoid respond to an extreme observation

The mean is a computed point that can drift toward an extreme value. The medoid is an actual observation chosen to minimize total distance, so it tends to stay with the bulk of the data. Add an extreme observation and move it outward to compare the two.

Interactive figure. A group of ten observations is shown on a scatter plot with average session length in minutes on the horizontal axis and sessions per week on the vertical axis. A mean marker and a medoid marker are drawn. A control adds one extreme observation and a slider moves it further from the group. As the extreme observation moves out, the mean marker follows it while the medoid marker stays on a central observation. With the extreme observation at the far position, the mean moves to about 32, 22 while the medoid remains on observation P2 at 26, 18.

Robustness of the medoid to an extreme observationA group of ten observations with one extreme observation. The mean marker sits away from the group toward the extreme point, while the medoid stays on a central real observation.1020304050607080102030405060Average session length (minutes)Sessions per week

Interactive controls need JavaScript. The scene above shows the fixed result. The mean marker sits away from the group toward the extreme observation, while the medoid marker stays on a central observation.

Extreme observation
Display
k‑means centroid (the mean) (30.5, 21.2) objective: within‑cluster SSE 1951 sum of squared distances to the mean
k‑medoids medoid observation P2 objective: total distance 104.8 sum of distances to the medoid
With the extreme observation included, the mean is pulled toward it while the medoid stays on a central observation.

The two objectives measure different quantities and are not directly comparable. Squared distances grow faster than distances, which is why the k‑means objective reacts more sharply to the extreme observation. The medoid resists movement, yet its objective still rises, so robustness softens the effect of an extreme value while leaving it in place.

Figure 1. The mean and the medoid for one group of observations. As the extreme observation moves outward, the mean drifts toward it while the medoid stays on a central data point.

The medoid holds its position because moving to the extreme observation would increase its total distance to the rest of the group. That resistance is the root of the robustness that sets k-medoids apart from k-means, which the next section examines in more detail.

K-medoids versus K-means

K-means minimizes the sum of squared Euclidean distances between points and their assigned cluster centroids, which is computationally efficient and easy to implement, though it leaves the method sensitive to outliers. A single extreme value can pull a centroid far away from the bulk of the data and distort cluster assignments.

K-medoids, by contrast, minimizes the sum of distances from each point to its nearest medoid. Because distances are not squared and the representative cannot leave the data, an extreme value pulls that representative far less. The outlier still joins a cluster and still counts toward the objective. What it cannot do is drag the representative far.

Another practical difference concerns distance metrics. K-means is fundamentally tied to Euclidean distance and the notion of an average. K-medoids can be used with any distance or dissimilarity measure, including Manhattan distance, cosine distance, or even precomputed distance matrices. That freedom suits data types where averages are not meaningful, such as categorical variables, mixed feature spaces, or complex similarity measures.

Figure 2 makes the effect of the distance choice concrete. It clusters a small set of customers described by annual spend and an engagement score, and it lets you switch the feature scaling and the distance metric to watch the clusters and their medoids respond.

Figure 2 · dissimilarity and scaling

The distance you choose decides which observations count as similar

K‑medoids can run on any dissimilarity. This example clusters thirteen customers described by annual spend and an engagement score. Switch the feature scaling and the distance metric to see how the two clusters and their medoids change.

Interactive figure. Thirteen observations are plotted in standardized coordinates, with standardized spend on the horizontal axis and standardized engagement on the vertical axis. Controls switch the feature scaling between raw units and standardized, and switch the distance metric between Euclidean and Manhattan. With raw units the two clusters split by spend from left to right, because spend has a much larger numeric range. After standardizing, the clusters split by engagement into a high band and a low band. One observation, P13, sits between the two bands, and its cluster depends on the metric: Euclidean places it in the low engagement cluster, Manhattan places it in the high engagement cluster.

How feature scaling and the distance metric reshape clustersThirteen observations plotted in standardized coordinates. With standardized Euclidean distance, the two clusters separate by engagement into a high band and a low band, with one ambiguous observation between them.-2-1012-101Spend (standardized z‑score)Engagement (standardized z‑score)

Interactive controls need JavaScript. The scene above shows standardized Euclidean clustering, where the observations separate into a high engagement band and a low engagement band.

Feature scaling
Distance metric
Medoids P4 (blue) and P9 (amber)
Total dissimilarity 12.2 sum of distances to nearest medoid
Ambiguous observation P13 Cluster 2 (amber)

After standardizing, spend and engagement contribute on the same scale. The clusters separate into a high engagement band and a low engagement band.

Distance from observation P13 to each medoid (Euclidean, standardized)
MedoidDistance
Medoid P4 · Cluster 1 (blue)1.0
Medoid P9 · Cluster 2 (amber)0.9
Scaling: standardized. Distance: euclidean. The clusters separate by engagement.

Observation P13 sits between the two bands. Under Euclidean distance it joins Cluster 2. Beyond the two metrics offered here, k‑medoids also accepts cosine distance or a precomputed dissimilarity matrix, so the metric is worth choosing deliberately to match the data.

Figure 2. K-medoids applied to thirteen customers under two feature-scaling choices and two distance metrics. In raw units, spend spans thousands while engagement spans single digits, so distance is dominated by spend and the clusters split by spend. After standardizing, the two features contribute equally and the clusters split by engagement. Observation P13 lies between the engagement bands, and the metric decides which cluster it joins.

The dissimilarity you supply defines what counts as similar, so feature scaling and metric selection are modeling decisions in their own right. For this data, scaling changes the whole partition, while the choice between Euclidean and Manhattan distance moves only the ambiguous observation between clusters.

Comparison with other clustering methods

Compared to hierarchical clustering, k-medoids offers the same flexibility in distance definitions, and its advantage lies there rather than in scaling. Classical PAM compares every medoid with every non-medoid on each swap pass, which is comparable to agglomerative methods or heavier. It is variants such as Clustering Large Applications (CLARA) and Clustering Large Applications based on RANdomized Search (CLARANS) that scale to larger datasets. Hierarchical methods provide rich structure but can become computationally expensive and harder to tune in practice.

Density-based methods like DBSCAN focus on discovering arbitrarily shaped clusters and identifying noise points. These methods shine when cluster density is the primary signal. They also demand careful parameter choices, and they falter when densities vary widely. K-medoids, by contrast, hands back a clear partition into a fixed number of clusters. Exploratory analysis and downstream modeling both tend to want exactly that.

Model-based approaches such as Gaussian mixture models rely on distributional assumptions that may not hold in real data. K-medoids makes no distributional assumption and works purely from distances between observations. That does not make it assumption-free. Like k-means it looks for a fixed number of compact groups around single representatives, so elongated or nested shapes stay out of reach.

Practical advantages of K-medoids

One of the strongest advantages of k-medoids is robustness. The objective sums unsquared distances, and the representative has to be an actual observation. Together those two facts blunt the effect of noise and extreme values, without removing it. The outlier still joins a cluster and still counts toward the objective. That robustness is what recommends the method in domains like finance, healthcare, and behavioral data, where outliers are common and often meaningful.

Interpretability is another benefit. A medoid can be inspected directly, summarized, or used as a representative example of a cluster. Stakeholders tend to follow that more easily than an abstract centroid sitting where no real data point does.

Freedom over the distance metric widens the range of applications. Define a sensible dissimilarity measure and k-medoids will run on it.

Cost, scale and choosing k

Cost is the trade. Finding optimal medoids means evaluating a great many pairwise distances, which grows with the size of the dataset. Modern implementations and heuristics narrow the gap, and k-medoids runs slower than k-means.

Scalability is the next question. Very large datasets are handled with approximate methods or sampling. For a first look, k-means or a mini-batch variant is the lighter option.

Finally, like k-means, k-medoids requires specifying the number of clusters in advance, which puts weight on model selection, often supported by validation metrics such as silhouette scores or stability analyses.

Figure 3 shows where the computational cost comes from. It walks through Partitioning Around Medoids, usually shortened to PAM, the classic algorithm for k-medoids. The walkthrough covers two phases. Build picks an initial set of medoids. Swap then tests exchanging a medoid for another observation, keeping only the exchanges that lower the total distance.

Figure 3 · the PAM algorithm

How partitioning around medoids searches for the medoids

PAM first builds an initial set of medoids, then repeatedly tests swapping a medoid for a non‑medoid and keeps only swaps that lower the total distance. Play the walkthrough, or step through it, to follow the build and swap phases.

Interactive figure. Twelve observations are plotted on a scatter plot and grouped into two clusters. The build phase picks the observation with the lowest total distance to all points as the first medoid. It then adds whichever observation lowers the total distance most as the second. In the swap phase it evaluates twenty candidate swaps, each replacing one medoid with one non‑medoid, and applies the swap that most lowers the total distance. After one accepted swap the medoids settle on observations P5 and P8, the total distance is 123.4, and no further swap lowers it, so the algorithm stops at a local optimum. Controls play, pause, step forward, step back, and reset the walkthrough.

Partitioning Around Medoids: build and swapTwelve observations partitioned into two clusters by the PAM algorithm. After the build phase and one accepted swap, the medoids settle on two representative observations and no further swap lowers the total distance.20406080204060Feature xFeature y

Interactive controls need JavaScript. The scene above shows the final result, where PAM has settled on the two medoids that give the lowest total distance it can reach by local swaps.

DONE Step 5 of 5
Total distance (objective) 123.4 sum of distances to nearest medoid

PAM has converged at a local optimum. No swap lowers the total distance below 123.4.

This is classical PAM. The swap phase is a local search, so the result can depend on the starting medoids and is not guaranteed to be the global optimum. Each pass evaluates every medoid against every non‑medoid, which is the source of PAM’s cost. FastPAM and FasterPAM reach the same objective more quickly. CLARA and CLARANS go further and scale to larger datasets, by limiting how much of the problem they search at once.

Figure 3. One run of PAM on twelve observations with k set to two. The build phase picks the first medoid as the observation with the lowest total distance to all points, then adds a second medoid. The swap phase evaluates every medoid against every non-medoid and applies the exchange that most lowers the total distance. The run stops when no exchange improves the objective.

Each swap pass compares every medoid with every non-medoid, which is why PAM becomes costly as the dataset grows. The search is local, so the outcome depends on the starting medoids and is not guaranteed to reach the lowest possible total distance.

Example: K-medoids clustering for customer data

K-Medoids Segments. How K-Medoids turns nearly 9,000 credit card customers into behavioral segments anchored by real cardholders. The data are imputed, standardized, validated and profiled. Key topics covered: Credit card customer data, Impute missing values, Clusters in principal component analysis (PCA) space, Standardize features, Fit K-Medoids, Profile each segment, Interactive 3D view, Choose the number of clusters.

Hover any card to explore

In this example, we will build a customer segmentation model using credit card transaction and behavior data.

Our main business goal is to:

  • Group credit card customers into behavioral segments (clusters)
  • Help marketing and product teams design targeted strategies for each segment

We will:

  • Use K-Medoids clustering to segment customers
  • Choose the number of clusters using the silhouette score, with two further indices reported alongside it
  • Use PCA (Principal Component Analysis) to visualize clusters in 2D and 3D
  • Create a descriptive Table 1 stratified by cluster using the tableone package

The dataset (CC GENERAL.csv) contains ~9,000 customers and 18 columns, of which 17 are behavioral variables once the CUST_ID identifier is dropped.

Imports and configuration

In this section we:

  • Import all necessary Python libraries
  • Configure plotting styles for nicer visuals
  • Optionally suppress non-critical warnings to keep the notebook output clean
Python
import numpy as np
import pandas as pd

import matplotlib.pyplot as plt
import seaborn as sns
import plotly.graph_objects as go

from sklearn.experimental import enable_iterative_imputer
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA

from sklearn.metrics import (
    silhouette_score,
    calinski_harabasz_score,
    davies_bouldin_score,
)

from joblib import Parallel, delayed
import multiprocessing

from pyclustering.cluster.kmedoids import kmedoids
from pyclustering.utils.metric import distance_metric, type_metric
from tableone import TableOne

# ============================================================
# Global configuration
# ============================================================
RANDOM_STATE = 42

sns.set(style="whitegrid", context="notebook")
plt.rcParams["figure.figsize"] = (10, 6)
plt.rcParams["axes.titlesize"] = 14
plt.rcParams["axes.labelsize"] = 12

K-medoids wrapper class

Define a minimal, sklearn-like interface for K-Medoids using pyclustering.

Python
# ============================================================
# K-Medoids using pyclustering
# ============================================================

class KMedoids:
    """
    Minimal sklearn-like K-Medoids wrapper using pyclustering.
    """

    def __init__(
        self,
        n_clusters,
        metric="euclidean",
        init="random",
        random_state=None,
    ):
        self.n_clusters = int(n_clusters)
        self.metric = metric
        self.init = init
        self.random_state = random_state

        self.labels_ = None
        self.medoid_indices_ = None

    def _get_metric(self):
        if self.metric == "manhattan":
            return distance_metric(type_metric.MANHATTAN)
        return distance_metric(type_metric.EUCLIDEAN)

    def _init_medoids(self, n_samples):
        rng = np.random.RandomState(self.random_state)
        return rng.choice(
            n_samples,
            size=self.n_clusters,
            replace=False,
        ).tolist()

    def fit(self, X):
        X = np.asarray(X)
        n_samples = X.shape[0]

        initial_medoids = self._init_medoids(n_samples)
        metric = self._get_metric()

        model = kmedoids(
            data=X.tolist(),
            initial_index_medoids=initial_medoids,
            metric=metric,
        )
        model.process()

        clusters = model.get_clusters()
        medoids = model.get_medoids()

        labels = np.empty(n_samples, dtype=int)
        for cluster_id, indices in enumerate(clusters):
            labels[indices] = cluster_id

        self.labels_ = labels
        self.medoid_indices_ = np.asarray(medoids, dtype=int)

        return self

    def fit_predict(self, X):
        self.fit(X)
        return self.labels_

Load and inspect the data

We now load the CC GENERAL.csv file and take a first look at basic descriptive statistics of the dataset.

Python
# Load the dataset
data_path = "CC GENERAL.csv"
df = pd.read_csv(data_path)

# Descriptive statistics for numerical columns
print("\nDescriptive statistics for numerical variables:")
desc_stats = df.describe().T.round(2)
display(desc_stats)

# Save descriptive statistics as CSV
desc_stats.to_csv("descriptive_statistics.csv")
Descriptive statistics for numerical variables:
Table 1

Descriptive statistics for the 17 variables

Table 1. Output of df.describe().T.round(2) on the file as loaded, before imputation or scaling: count, mean, standard deviation, minimum, quartiles and maximum for each of the 17 numeric variables. CUST_ID is text and does not appear. Two are incomplete: CREDIT_LIMIT holds 8,949 values and MINIMUM_PAYMENTS 8,637, against 8,950 elsewhere. The spending and balance amounts are strongly right skewed, with standard deviations above their means (CREDIT_LIMIT is the exception, 4494.45 against 3638.82): BALANCE has mean 1564.47 and SD 2081.53, PURCHASES 1003.20 and 2136.63, CASH_ADVANCE 978.87 and 2097.16. Means are directional only here.
countmeanstdmin25%50%75%max
BALANCE8950.01564.472081.530.00128.28873.392054.1419043.14
BALANCE_FREQUENCY8950.00.880.240.000.891.001.001.00
PURCHASES8950.01003.202136.630.0039.64361.281110.1349039.57
ONEOFF_PURCHASES8950.0592.441659.890.000.0038.00577.4040761.25
INSTALLMENTS_PURCHASES8950.0411.07904.340.000.0089.00468.6422500.00
CASH_ADVANCE8950.0978.872097.160.000.000.001113.8247137.21
PURCHASES_FREQUENCY8950.00.490.400.000.080.500.921.00
ONEOFF_PURCHASES_FREQUENCY8950.00.200.300.000.000.080.301.00
PURCHASES_INSTALLMENTS_FREQUENCY8950.00.360.400.000.000.170.751.00
CASH_ADVANCE_FREQUENCY8950.00.140.200.000.000.000.221.50
CASH_ADVANCE_TRX8950.03.256.820.000.000.004.00123.00
PURCHASES_TRX8950.014.7124.860.001.007.0017.00358.00
CREDIT_LIMIT8949.04494.453638.8250.001600.003000.006500.0030000.00
PAYMENTS8950.01733.142895.060.00383.28856.901901.1350721.48
MINIMUM_PAYMENTS8637.0864.212372.450.02169.12312.34825.4976406.21
PRC_FULL_PAYMENT8950.00.150.290.000.000.000.141.00
TENURE8950.011.521.346.0012.0012.0012.0012.00

Data preprocessing

Before clustering, we need to:

  1. Remove the identifier column (CUST_ID) from the feature set
  2. Inspect and handle missing values
  3. Construct a clean numerical feature matrix X suitable for downstream scaling and clustering

We first examine the extent of missingness in each column to understand which variables require imputation. The identifier column (CUST_ID) is excluded from the feature matrix, as it does not carry meaningful information for clustering.

Handling missing values

Rather than using univariate imputation, we apply a model-based multivariate imputation strategy using IterativeImputer with a BayesianRidge estimator. This approach:

  • Models each feature with missing values as a function of the other features
  • Iteratively refines imputations over multiple rounds until convergence
  • Approximates multivariate relationships between variables, which is especially important for clustering
Python
import pandas as pd
from sklearn.impute import IterativeImputer
from sklearn.linear_model import BayesianRidge

# Keep a copy of the original dataframe with CUST_ID for later interpretation
df_original = df.copy()

# Check missing values per column
print("Missing values per column:")
missing_counts = df.isna().sum().sort_values(ascending=False)
display(missing_counts)

# Drop CUST_ID from the features (identifier, not used for clustering)
if "CUST_ID" in df.columns:
    df_features = df.drop(columns=["CUST_ID"])
else:
    df_features = df.copy()

# Feature matrix (numerical variables)
X = df_features.copy()

# Model-based multivariate imputer
imputer = IterativeImputer(
    estimator=BayesianRidge(),
    max_iter=20,
    tol=1e-3,
    imputation_order="ascending",
    random_state=42
)

# Fit and transform
X_imputed = imputer.fit_transform(X)

# Convert back to DataFrame
X_imputed = pd.DataFrame(
    X_imputed,
    columns=X.columns,
    index=X.index
)

Missing values per column:
MINIMUM_PAYMENTS                    313
CREDIT_LIMIT                          1
BALANCE                               0
CUST_ID                               0
BALANCE_FREQUENCY                     0
PURCHASES                             0
CASH_ADVANCE                          0
PURCHASES_FREQUENCY                   0
ONEOFF_PURCHASES                      0
INSTALLMENTS_PURCHASES                0
PURCHASES_INSTALLMENTS_FREQUENCY      0
ONEOFF_PURCHASES_FREQUENCY            0
CASH_ADVANCE_TRX                      0
CASH_ADVANCE_FREQUENCY                0
PURCHASES_TRX                         0
PAYMENTS                              0
PRC_FULL_PAYMENT                      0
TENURE                                0
dtype: int64

Note on distribution and skewness

Many financial variables (e.g., BALANCE, PURCHASES, CASH_ADVANCE) are typically right-skewed with long tails. For simplicity and interpretability, we will:

  • Leave the data in its original scale, but
  • Apply standardization before k-medoids (which works on distances)

In a more advanced analysis, we might consider log-transforming highly skewed variables before scaling, but we will not do that here to keep the workflow straightforward.

Feature scaling

K-Means is a distance-based algorithm that uses squared Euclidean distances, and k-medoids is distance-based too, whatever measure it is given. Variables on larger scales (e.g., dollars) can dominate others (e.g., frequencies mostly in [0, 1], though CASH_ADVANCE_FREQUENCY reaches 1.50).

Therefore, we:

  • Use StandardScaler to standardize each feature:
    • Mean = 0
    • Standard deviation = 1

This lets all variables contribute more equally to the clustering.

K-Medoids is also distance-based, so scaling remains important. Because medoids must be actual observations, K-Medoids is often more robust to outliers than K-Means, but it still relies on meaningful distances between features.

Python
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_imputed)

print("Shape of scaled feature matrix:", X_scaled.shape)
Shape of scaled feature matrix: (8950, 17)

Selecting the number of clusters (choosing k for K-medoids)

Unlike K-Means, K-Medoids does not optimize a “within-cluster dispersion” objective in the same way, so the k-means elbow plot of within-cluster sum of squares does not carry over directly. An elbow on the k-medoids objective is possible, but it is not the route taken here to select k.

Instead, we evaluate a range of k values using cluster validity indices:

  • Silhouette score (primary): higher is better (balance of cohesion and separation)
  • Calinski–Harabasz score (secondary): higher is better
  • Davies–Bouldin score (secondary): lower is better

We will fit K-Medoids across a reasonable range of k values and select a final k based on the evidence from these metrics.

Python
# Range of k values to try (keep runtime reasonable)
k_values = list(range(2, 9))

# Distance metric for K-Medoids
DIST_METRIC = "euclidean"

# Use all CPU cores
N_JOBS = multiprocessing.cpu_count()


def evaluate_k(k, X, metric, random_state):
    """
    Fit K-Medoids for a given k and compute validation metrics.
    Returns (k, silhouette, calinski_harabasz, davies_bouldin).
    """
    kmed = KMedoids(
        n_clusters=k,
        metric=metric,
        init="k-medoids++",  # kept for API compatibility
        random_state=random_state,
    )
    labels = kmed.fit_predict(X)

    sil = silhouette_score(X, labels, metric=metric)
    ch = calinski_harabasz_score(X, labels)
    db = davies_bouldin_score(X, labels)

    return k, sil, ch, db


# Run evaluations in parallel (each k is independent)
results = Parallel(n_jobs=N_JOBS, backend="loky")(
    delayed(evaluate_k)(k, X_scaled, DIST_METRIC, RANDOM_STATE)
    for k in k_values
)

# Unpack results (sort by k to keep plots ordered)
results = sorted(results, key=lambda t: t[0])
k_vals, sil_scores, ch_scores, db_scores = map(list, zip(*results))


# ============================================================
# Plot validation metrics vs k
# ============================================================

plt.figure()
plt.plot(k_vals, sil_scores, marker="o")
plt.xticks(k_vals)
plt.xlabel("Number of clusters (k)")
plt.ylabel("Silhouette score (higher is better)")
plt.title(f"K-Medoids model selection (metric = {DIST_METRIC})")
plt.grid(True)
plt.savefig("kmedoids_silhouette.png", dpi=300, bbox_inches="tight")
plt.show()

plt.figure()
plt.plot(k_vals, ch_scores, marker="o")
plt.xticks(k_vals)
plt.xlabel("Number of clusters (k)")
plt.ylabel("Calinski–Harabasz score (higher is better)")
plt.title("Calinski–Harabasz score vs k")
plt.grid(True)
plt.savefig("kmedoids_calinski_harabasz.png", dpi=300, bbox_inches="tight")
plt.show()

plt.figure()
plt.plot(k_vals, db_scores, marker="o")
plt.xticks(k_vals)
plt.xlabel("Number of clusters (k)")
plt.ylabel("Davies–Bouldin score (lower is better)")
plt.title("Davies–Bouldin score vs k")
plt.grid(True)
plt.savefig("kmedoids_davies_bouldin.png", dpi=300, bbox_inches="tight")
plt.show()
Figure 4

Silhouette score against the number of clusters

Silhouette score plotted against the number of clusters from 2 to 8
Figure 4. Silhouette score for each k in range(2, 9), from the parallel sweep above, on an axis the code labels higher is better. The curve peaks at k = 2, falls to its lowest point at k = 3, then climbs back to a second, lower peak at k = 7. The selection step reports k = 2, the smallest value searched, so the best score sits at the edge of the sweep and never turns over inside it. Every score in the sweep sits between 0.16 and 0.20 on a scale that runs from −1 to 1, which describes groups that overlap substantially at every k in the sweep. The number of clusters stays a modeling choice.
Figure 5

Calinski-Harabasz score against the number of clusters

Calinski-Harabasz score plotted against the number of clusters from 2 to 8, where higher is better
Figure 5. The first secondary index, over the same k in range(2, 9) from the same sweep. Calinski-Harabasz is a ratio of between-cluster to within-cluster dispersion, so it is maximized and its axis label reads higher is better, the same direction as the silhouette but on an unbounded scale. The curve is highest at k = 2 and falls as k rises, apart from a small rise at k = 5, so it prefers the k the silhouette selects. Both indices come from the same fits on the same features, and both place their best value at the edge of the searched range.
Figure 6

Davies-Bouldin score against the number of clusters

Davies-Bouldin score plotted against the number of clusters from 2 to 8, where lower is better
Figure 6. The second secondary index, over the same k in range(2, 9). Davies-Bouldin averages, over the clusters, the worst similarity between a cluster and any other, so it is minimized, and the axis label reads lower is better. That is the opposite direction to the two curves above, so a low point on this curve is its best score. On its own scale the score falls as k rises, reaching its lowest value at k = 7 before turning up, and k = 2, the value the code selects, sits at the other end. The silhouette drives the selection here.

Based on the validation curves above, we select a value of k that gives the best silhouette score while keeping the solution reasonably simple. The separation itself is weak. Every silhouette score in the sweep sits between 0.16 and 0.20 on a scale that runs from -1 to 1. Calinski-Harabasz peaks at the same value, and it comes from the same fits on the same features. Davies-Bouldin, which is minimized, bottoms out at k = 7 and places the selected k at the other end. The silhouette drives the selection, and the value it picks is the smallest in the searched range, so the optimum sits on the boundary of the sweep.

In practice, Silhouette score is a solid default choice for distance-based clustering on standardized continuous features. Note that the wrapper’s initialization draws its medoids at random and does not apply k-medoids++, despite the init argument passed at both call sites, so the sweep above and the final fit below both start from random medoids at random_state=42. The code below selects the k with the best silhouette score.

Python
# Choose k based on the best silhouette score (primary), with a bias toward simpler solutions
sil_series = pd.Series(sil_scores, index=list(k_values))
best_sil = sil_series.max()

# All k values within a tiny tolerance of the best silhouette
tol = 1e-6
candidate_ks = sil_series[sil_series >= best_sil - tol].index.tolist()
optimal_k = int(min(candidate_ks))

print(f"Selected k = {optimal_k} using silhouette score (metric = {DIST_METRIC}).")
Selected k = 2 using silhouette score (metric = euclidean).

Fit final K-medoids model

With optimal_k chosen, we now:

  • Fit a final KMedoids model on the scaled features
  • Obtain cluster labels for each customer
  • Attach the cluster labels back to the original dataframe (df_clustered)
  • Inspect how many customers fall into each cluster
Python
# Fit final K-Medoids model
kmedoids_final = KMedoids(
    n_clusters=optimal_k,
    metric=DIST_METRIC,
    init="k-medoids++",
    random_state=RANDOM_STATE
)
cluster_labels = kmedoids_final.fit_predict(X_scaled)

# Medoids are representative REAL observations (indices into the input array order)
medoid_indices = kmedoids_final.medoid_indices_

# Attach cluster labels to the original dataframe
df_clustered = df_original.copy()
df_clustered["cluster"] = cluster_labels

# Show cluster sizes
print("Cluster label counts:")
display(df_clustered["cluster"].value_counts().sort_index())

print("Medoid indices (row positions in the clustered matrix):")
display(pd.Series(medoid_indices, name="medoid_row_position"))
Cluster label counts:

cluster
0    4955
1    3995
Name: count, dtype: int64

Medoid indices (row positions in the clustered matrix):
0    7063
1    7721
Name: medoid_row_position, dtype: int64

Each customer is now assigned to one of the k clusters using K-Medoids (0 to k-1). These clusters represent behavioral customer segments, which we will further explore using visualization and descriptive statistics.

Visualizing clusters with PCA

The dataset has many dimensions (17 behavioral features), which makes it hard to visualize clusters directly. To address this, we use:

  • Principal Component Analysis (PCA) to reduce the standardized features to 3 principal components
  • Then plot customers in the 2D and 3D PCA space, colored by their cluster

PCA helps capture as much variance as possible in a low-dimensional projection, giving us an approximate but informative visual representation of the clusters.

Python
# Perform PCA to reduce to 3 principal components (for visualization only)
pca = PCA(n_components=3, random_state=RANDOM_STATE)
X_pca = pca.fit_transform(X_scaled)

# Create a DataFrame for PCA results
df_pca = pd.DataFrame(
    X_pca,
    columns=["PC1", "PC2", "PC3"],
    index=df_clustered.index
)
df_pca["cluster"] = df_clustered["cluster"].astype(int)

# Mark medoids (medoids are actual observations)
df_pca["is_medoid"] = False
medoid_df_indices = df_clustered.index[medoid_indices]  # map row positions -> df index
df_pca.loc[medoid_df_indices, "is_medoid"] = True

# ----------------------------
# 2D (Seaborn/Matplotlib): medoids colored by their cluster
# ----------------------------
palette = sns.color_palette("tab10", n_colors=int(df_pca["cluster"].nunique()))
cluster_to_color = {cl: palette[i] for i, cl in enumerate(sorted(df_pca["cluster"].unique()))}

plt.figure()
sns.scatterplot(
    data=df_pca,
    x="PC1",
    y="PC2",
    hue="cluster",
    palette=cluster_to_color,
    alpha=0.75,
    s=60
)

# Overlay medoids with marker "X" but color-matched to cluster
d_m = df_pca[df_pca["is_medoid"]]
plt.scatter(
    d_m["PC1"],
    d_m["PC2"],
    s=220,
    marker="X",
    c=[cluster_to_color[c] for c in d_m["cluster"].tolist()],
    edgecolor="black",
    linewidth=1.5,
    label="Medoid"
)

plt.title("Customer K-Medoids Clusters")
plt.xlabel("Principal Component 1")
plt.ylabel("Principal Component 2")
plt.legend(title="Cluster / Medoid", bbox_to_anchor=(1.02, 1), loc="upper left")
plt.tight_layout()
plt.savefig("pca_2d_clusters_kmedoids.png", dpi=300, bbox_inches="tight")
plt.show()

# ----------------------------
# 3D (Plotly): medoids colored by their cluster
# ----------------------------
fig = go.Figure()

# Use the same tab10 mapping for Plotly (as rgb strings)
def rgb_str(rgb_tuple):
    r, g, b = rgb_tuple
    return f"rgb({int(r*255)},{int(g*255)},{int(b*255)})"

cluster_to_color_plotly = {cl: rgb_str(col) for cl, col in cluster_to_color.items()}

# Add each cluster as a separate trace (for an explicit legend)
for cl in sorted(df_pca["cluster"].unique()):
    d = df_pca[df_pca["cluster"] == cl]
    fig.add_trace(
        go.Scatter3d(
            x=d["PC1"],
            y=d["PC2"],
            z=d["PC3"],
            mode="markers",
            name=f"Cluster {cl}",
            marker=dict(
                size=4,
                opacity=0.75,
                color=cluster_to_color_plotly[cl],
            ),
        )
    )

# Add medoids as separate traces per cluster so their color matches
for cl in sorted(df_pca.loc[df_pca["is_medoid"], "cluster"].unique()):
    dmc = df_pca[(df_pca["is_medoid"]) & (df_pca["cluster"] == cl)]
    fig.add_trace(
        go.Scatter3d(
            x=dmc["PC1"],
            y=dmc["PC2"],
            z=dmc["PC3"],
            mode="markers",
            name=f"Medoid (Cluster {cl})",
            marker=dict(
                size=10,
                symbol="diamond",
                opacity=1.0,
                color=cluster_to_color_plotly[cl],
                line=dict(width=2, color="black"),
            ),
        )
    )

fig.update_layout(
    title="K-Medoids Clusters",
    scene=dict(
        xaxis_title="PC1",
        yaxis_title="PC2",
        zaxis_title="PC3",
    ),
    legend_title_text="Cluster",
)
fig.write_html("pca_3d_clusters_kmedoids_nt.html")
fig.show()

# Explained variance information
explained_var = pca.explained_variance_ratio_
print(
    f"Explained variance by PC1, PC2, PC3: {explained_var[0]:.2%}, {explained_var[1]:.2%}, {explained_var[2]:.2%}"
)
Figure 7

Customer clusters in the first two principal components

Customer segments from k-medoids clustering in the first two principal components
Figure 7. All 8,950 customers drawn on the first two principal components of the standardized features, colored by k-medoids cluster, with each cluster medoid overlaid as a color-matched X outlined in black. Cluster 0 holds 4,955 customers and cluster 1 holds 3,995. The two colors meet along a boundary that runs through the dense core, with no gap between them, and both medoids sit near the origin, close together. PC1 and PC2 carry 27.30% and 20.32% of the variance, so the view holds 47.62% of the variance across those 17 dimensions. The overlap on screen is as much a property of the projection as of the partition.
Figure 8

Interactive view of the clusters in three components

Open this figure at full size in a new tab
Figure 8. The same partition in a rotatable Plotly scene on PC1, PC2 and PC3, one marker per customer colored by cluster, with the two medoids drawn as larger black-outlined diamonds in their cluster color. Drag to rotate the scene and hover any marker to read a customer’s component scores. The three components hold 27.30%, 20.32% and 8.83% of the variance, 56.45% together, so the majority of the spread across the 17 features is in the view. Rotating shows the groups from several angles, and the partition itself lives in the full 17 features.
Explained variance by PC1, PC2, PC3: 27.30%, 20.32%, 8.83%

Descriptive Statistics by Cluster (Table 1 using tableone)

To understand what makes each cluster unique, we will:

  • Use the tableone package to create a Table 1 of descriptive statistics
  • Stratify the table by cluster
  • Include the main behavioral numeric variables (e.g., balances, purchases, cash advances)

The table helps us attach business meaning to each segment, such as:

  • High spenders
  • Frequent revolvers (carry a balance)
  • Cash-advance heavy users
  • Low-activity or dormant customers

Note: If tableone is not installed in your environment, you can install it via:
pip install tableone

Python
# Prepare variables for Table 1
# We will include all numeric behavioral variables and stratify by 'cluster'.
# Exclude 'cluster' itself and the identifier 'CUST_ID' from the summarized columns.

# Identify numeric columns
numeric_columns = df_clustered.select_dtypes(include=[np.number]).columns.tolist()

# Remove 'cluster' from the list of summarized variables
if "cluster" in numeric_columns:
    numeric_columns.remove("cluster")

columns = numeric_columns
categorical = []  # No categorical variables in this example

# Create the TableOne object, stratified by cluster
table1 = TableOne(
    df_clustered,
    columns=columns,
    categorical=categorical,
    groupby="cluster",
    label_suffix=True
)

print("Table 1: Descriptive statistics of customer behavior by cluster")
display(table1)

# Save Table 1 to CSV
table1.to_csv("table1_customer_behavior_by_cluster.csv")
Table 1: Descriptive statistics of customer behavior by cluster
Table 2

Customer behavior summarized by cluster

Table 2. Built with tableone and printed above as Table 1, the term of art for a table of baseline characteristics: mean (SD) for the 17 variables in original units, overall and for cluster 0 (n = 4,955) and cluster 1 (n = 3,995). The contrast is purchasing against cash: cluster 1 averages 1834.3 in purchases and 28.6 transactions, against 333.1 and 3.5 for cluster 0, while cluster 0 averages 1480.4 in cash advances against 356.8. For balances, purchases and cash advances the standard deviations exceed the means, so these numbers describe directional differences between skewed distributions. No typical customer sits behind them.
Grouped by cluster
MissingOverall01
n895049553995
BALANCE, mean (SD)01564.5 (2081.5)1937.4 (2222.7)1101.9 (1787.1)
BALANCE_FREQUENCY, mean (SD)00.9 (0.2)0.9 (0.3)0.9 (0.2)
PURCHASES, mean (SD)01003.2 (2136.6)333.1 (781.6)1834.3 (2867.6)
ONEOFF_PURCHASES, mean (SD)0592.4 (1659.9)270.3 (752.0)992.0 (2276.7)
INSTALLMENTS_PURCHASES, mean (SD)0411.1 (904.3)63.0 (194.6)842.8 (1203.6)
CASH_ADVANCE, mean (SD)0978.9 (2097.2)1480.4 (2448.4)356.8 (1311.4)
PURCHASES_FREQUENCY, mean (SD)00.5 (0.4)0.2 (0.2)0.9 (0.2)
ONEOFF_PURCHASES_FREQUENCY, mean (SD)00.2 (0.3)0.1 (0.2)0.3 (0.4)
PURCHASES_INSTALLMENTS_FREQUENCY, mean (SD)00.4 (0.4)0.1 (0.2)0.7 (0.3)
CASH_ADVANCE_FREQUENCY, mean (SD)00.1 (0.2)0.2 (0.2)0.1 (0.1)
CASH_ADVANCE_TRX, mean (SD)03.2 (6.8)4.9 (8.1)1.3 (4.0)
PURCHASES_TRX, mean (SD)014.7 (24.9)3.5 (5.8)28.6 (31.5)
CREDIT_LIMIT, mean (SD)14494.4 (3638.8)4316.8 (3464.4)4714.7 (3833.1)
PAYMENTS, mean (SD)01733.1 (2895.1)1536.9 (2622.2)1976.5 (3185.0)
MINIMUM_PAYMENTS, mean (SD)313864.2 (2372.4)981.0 (2441.8)723.4 (2278.3)
PRC_FULL_PAYMENT, mean (SD)00.2 (0.3)0.0 (0.1)0.3 (0.4)
TENURE, mean (SD)011.5 (1.3)11.4 (1.4)11.6 (1.2)

The clustering results split the customers into two segments with different usage and payment behaviors.

Cluster 0 (n = 4,955)
This group is characterized by higher average balances and substantially greater reliance on cash advances. Purchase activity is relatively low, with few transactions and low purchase frequencies. Customers in this cluster also rarely pay their balances in full, suggesting more revolving credit behavior and potentially higher credit risk.

Cluster 1 (n = 3,995)
This cluster represents highly active card users. Customers show much higher total purchases, frequent transactions, and strong use of installment purchases. Cash advance usage is minimal, and full-payment rates are notably higher. Credit limits and payments are slightly higher on average, indicating more engaged and financially disciplined card usage.

The clusters separate customers into a more debt- and cash-advance–oriented segment versus a high-spending, transaction-heavy segment with healthier repayment behavior. That distinction gives risk management, marketing and product targeting somewhere to start. It does not settle the deeper question. The validity indices and a PCA view holding 27.30% and 20.32% of the variance cannot tell you whether the two groups are genuinely distinct or a partition imposed on a continuum.

Summary

Key Takeaways

A medoid is a real observation

K-means puts its centroid at the mean, a computed point that need not exist in the data. K-medoids picks the cluster member with the lowest total distance to the others, so the representative is always an inspectable record.

Robustness comes from not squaring

K-means minimizes squared distances, so one extreme value contributes disproportionately and drags the centroid toward it. K-medoids sums unsquared distances to a medoid that cannot leave the data, which damps that pull without removing it.

Match the dissimilarity to the data

K-medoids runs on Manhattan, cosine, or a precomputed dissimilarity matrix, so it suits data where an average means nothing. In Figure 2, scaling redraws the entire partition while the metric only moves the ambiguous observation P13.

The swap phase is expensive

Each PAM swap pass scores every medoid and non-medoid pair over the whole dataset, twenty pairs for the twelve observations at k = 2 in Figure 3. K-means stays cheaper, and at scale CLARA, CLARANS, or sampling become the practical route.

Choosing k and the local optimum remain

K-medoids fixes the cluster count in advance as k-means does, and PAM’s swap phase is a local search that depends on the starting medoids. The sweep scores k from 2 to 8, but the silhouette alone settles on k = 2, the smallest value tried.

Profiling turns labels into segments

Table 2 stratifies the 17 variables by cluster. Cluster 0 (n = 4,955) carries higher balances and cash advances, cluster 1 (n = 3,995) purchases far more and pays in full more often. The PCA scatter is illustration, at 27.30% and 20.32% of variance.

Data & License

Dataset

Credit Card Dataset for Clustering by Arjun Bhasin (Kaggle) — the file CC_GENERAL.csv, about 8,950 anonymized card-holder records across 18 columns, 17 of them behavioral once the CUST_ID identifier is dropped. Source: kaggle.com/datasets/arjunbhasin2013/ccdata.License: CC0 1.0 Universal (Public Domain Dedication) — no rights reserved; credited here as a courtesy. CC0 1.0

Article

© 2025 Philip Sarajlic. All rights reserved for the article’s original text and figures; the third-party data remains under the license shown above.

Code

Code examples in this article are licensed under the Common Public Attribution License Version 1.0 (CPAL-1.0), an OSI-approved copyleft license based on the Mozilla Public License 1.1. Initial Developer: Philip Sarajlic.

Attribution required by CPAL Exhibit B: © 2025 Philip Sarajlic · “Based on code by Philip Sarajlic” · philipsarajlic.com · no graphic image. This attribution must be displayed in Larger Works.

Modifications must be released in Source Code form under CPAL-1.0. Making the code usable by anyone other than you over a network is External Deployment under the license and is treated as distribution, so the Source Code must be made available to those users.

Full text: opensource.org/license/cpal-1-0 (SPDX identifier CPAL-1.0)

Scroll to Top

Free diagnostic

Would your model hold up to an external review?

Answer 39 questions in about nine minutes and get a clear picture of where your model stands.

You’ll receive a readiness score, a breakdown across 15 areas, your biggest evidence gaps, and the five questions an external reviewer would be most likely to ask first.

The assessment draws on guidance from:

  • TRIPOD+AI
  • PROBAST+AI
  • FDA GMLP
  • NIST AI RMF
  • SR 11-7

We’ll send you one email with your link, and your results when you finish. Your email address is carried through when you complete the assessment, so you won’t need to enter it again.

We don’t ask for your data, and there are no free-text fields in the assessment. See how we use your email address.