Fair Interpolated Transport for Group-Fair Clustering

Jesse S. Ghashti, Warren Hare, John R. J. Thompson

2026-08-19

Introduction

The FITclust package implements Fair Interpolated Transport (FIT), an algorithm-agnostic preprocessing framework for group-fair clustering. FIT moves each group-conditional empirical distribution along its Wasserstein-2 geodesic toward a shared barycenter at a tunable transport intensity t in the interval from zero to one, then selects the smallest intensity at which a soft-fairness violation falls within a tolerance deltaFair. This package accompanies the paper Algorithm-Agnostic Group-Fair Clustering via Fair Interpolated Transport by Ghashti, Hare, and Thompson (2026).

Features of this package include

Package Overview

There are three clustering functions, one per family. Each takes the data, the protected-group labels, and the number of clusters, and each carries out the full transport sweep internally.

  1. Fit the centroid family with fitSKM(), built on fuzzy c-means.

  2. Fit the graph family with fitSSC(), built on soft normalized spectral clustering.

  3. Fit the model family with fitSMM(), built on a Gaussian mixture.

The transport is conducted with the functions buildTransport(), wassersteinBarycenter(), and computeTransportMaps(), and the fairness diagnostics through softViolation() and hardViolation(), so the individual steps can be examined on their own.

Installation

Install the latest release version of FITclust from GitHub or with the following:

library(devtools)
install_github("ghashti-j/FITclust")
library(FITclust)

Sample Usage

We follow the step-by-step demonstration from the Appendix A of Ghashti, Hare, and Thompson (2026). The example is a bivariate mixture with two well-separated clusters and a protected attribute that is imbalanced within each cluster.

  1. First we generate the demonstration data. There are two clusters centreed at (-3, -3) and (3, 3), and two protected groups offset in opposite directions on the second coordinate. The group counts are asymmetric within each cluster, one hundred against two hundred, so the overall group marginals are balanced while the baseline clustering is not fair.
set.seed(42)
demoData <- rbind(
  data.frame(x1 = rnorm(100, -3, 1), x2 = rnorm(100, -3 - 0.25, 1), cluster = 1L, group = 0L),
  data.frame(x1 = rnorm(200, -3, 1), x2 = rnorm(200, -3 + 0.25, 1), cluster = 1L, group = 1L),
  data.frame(x1 = rnorm(200,  3, 1), x2 = rnorm(200,  3 - 0.25, 1), cluster = 2L, group = 0L),
  data.frame(x1 = rnorm(100,  3, 1), x2 = rnorm(100,  3 + 0.25, 1), cluster = 2L, group = 1L)
)
dataMat <- as.matrix(demoData[, c("x1", "x2")])
groupVec <- demoData$group
trueCluster <- demoData$cluster
cat("n =", nrow(dataMat),
    " group counts =", paste(table(groupVec), collapse = "/"),
    " cluster counts =", paste(table(trueCluster), collapse = "/"), "\n")
#> n = 600  group counts = 300/300  cluster counts = 300/300

We plot the data by protected group. The two groups overlap heavily in each cluster, so the imbalance is not visually obvious, yet it is enough to make an unconstrained clustering unfair.

ggplot(demoData, aes(x1, x2, shape = factor(group), fill = factor(group))) +
  geom_point(size = 2, colour = "black", stroke = 0.3, alpha = 0.7) +
  scale_shape_manual("Group", values = c("0" = 21, "1" = 24)) +
  scale_fill_manual("Group", values = c("0" = "#8ABF69", "1" = "#D08890")) +
  labs(x = expression(x[1]), y = expression(x[2])) +
  coord_fixed() + theme_bw() +
  theme(panel.grid = element_blank(), legend.position = "bottom")

  1. We construct the transport interpolation. buildTransport() computes the Wasserstein-2 barycenter on the full sample and the group transport maps, and returns a closure fn that produces the transported data at any intensity.
alphaVec <- resolveAlpha("uniform", groupVec, sort(unique(groupVec)))
transport <- buildTransport(dataMat, groupVec, alphaVec, verbose = FALSE)
cat("barycenter atoms =", nrow(transport$barycenter),
    " converged =", transport$baryConverged,
    " iterations =", transport$baryIter, "\n")
#> barycenter atoms = 600  converged = TRUE  iterations = 4

We can compare the soft-fairness violation of a baseline fuzzy c-means fit on the original data against a fit on the fully transported data at intensity one.

baseFit <- fcm(dataMat, numClusters = 2, numStart = 5)
fullFit <- fcm(transport$fn(1), numClusters = 2, numStart = 5)
cat("Delta_soft at t = 0:", round(softViolation(baseFit$membership, groupVec), 3), "\n")
#> Delta_soft at t = 0: 0.154
cat("Delta_soft at t = 1:", round(softViolation(fullFit$membership, groupVec), 3), "\n")
#> Delta_soft at t = 1: 0.001
  1. We now run the full centroid-family procedure with fitSKM(), sweeping the transport intensity on a grid at the tolerance deltaFair = 0.05. The returned object reports the selected intensity tOptimal, the baseline and final violations, and the swept diagnostics in history.
set.seed(1)
fitCentroid <- fitSKM(dataMat, groupVec, numClusters = 2, deltaFair = 0.05,
                      tSeq = seq(0, 1, by = 0.02), verbose = FALSE)
cat("t* =", fitCentroid$tOptimal,
    " Delta_soft:", round(fitCentroid$violationBaseline, 3),
    "->", round(fitCentroid$violation, 3), "\n")
#> t* = 0.82  Delta_soft: 0.154 -> 0.046
  1. The history data frame records the soft and hard violations at every grid point. Plotting the soft-violation trace against the intensity shows where it first crosses the tolerance, which is the selected tOptimal.
hist <- fitCentroid$history
ggplot(hist, aes(t, violationSoft)) +
  geom_line() + geom_point(size = 1) +
  geom_hline(yintercept = 0.05, linetype = "dashed", colour = "#E31A1C") +
  geom_vline(xintercept = fitCentroid$tOptimal, linetype = "dotted", colour = "grey30") +
  labs(x = expression(t), y = expression(Delta[soft](t))) +
  theme_bw() + theme(panel.grid = element_blank())

  1. We can count how many observations are reassigned between the baseline and the fair solution, after aligning the two labelings so that cluster identities match. This is the practical footprint of the fairness intervention.
alignLabels <- function(current, reference) {
  overlap <- table(current, reference)
  mapping <- apply(overlap, 1, which.max)
  as.integer(mapping[as.character(current)])
}
baseLabels <- fitCentroid$clustersBaseline
fairLabels <- alignLabels(fitCentroid$clusters, baseLabels)
cat("reassigned:", sum(fairLabels != baseLabels),
    "of", length(baseLabels),
    sprintf("(%.1f%%)", 100 * mean(fairLabels != baseLabels)), "\n")
#> reassigned: 37 of 600 (6.2%)
  1. Finally we visualize the fair clustering in the original coordinates, with observations colored by their fair cluster label and shaped by protected group.
plotDF <- data.frame(x1 = dataMat[, 1], x2 = dataMat[, 2],
                     cluster = factor(fairLabels), group = factor(groupVec))
ggplot(plotDF, aes(x1, x2, shape = group, fill = cluster)) +
  geom_point(size = 2, colour = "black", stroke = 0.3) +
  scale_shape_manual("Group", values = c("0" = 21, "1" = 24)) +
  scale_fill_manual("Cluster", values = c("1" = "#4E9BC7", "2" = "#F4A460")) +
  labs(x = expression(x[1]), y = expression(x[2])) +
  coord_fixed() + theme_bw() +
  theme(panel.grid = element_blank(), legend.position = "bottom") +
  guides(fill = guide_legend(override.aes = list(shape = 22)),
         shape = guide_legend(override.aes = list(fill = "grey60")))

The Three Clustering Families

We fit all three on the demonstration data and compare the selected intensities and the violation reductions.

set.seed(1)
fitGraph <- fitSSC(dataMat, groupVec, numClusters = 2, deltaFair = 0.05,
                   tSeq = seq(0, 1, by = 0.02), verbose = FALSE)
fitModel <- fitSMM(dataMat, groupVec, numClusters = 2, deltaFair = 0.05,
                   tSeq = seq(0, 1, by = 0.02), verbose = FALSE)
summaryTab <- data.frame(
  Family = c("Centroid (fitSKM)", "Graph (fitSSC)", "Model (fitSMM)"),
  tOptimal = c(fitCentroid$tOptimal, fitGraph$tOptimal, fitModel$tOptimal),
  DeltaSoftBaseline = round(c(fitCentroid$violationBaseline,
                              fitGraph$violationBaseline,
                              fitModel$violationBaseline), 3),
  DeltaSoftFair = round(c(fitCentroid$violation,
                          fitGraph$violation,
                          fitModel$violation), 3)
)
summaryTab
#>              Family tOptimal DeltaSoftBaseline DeltaSoftFair
#> 1 Centroid (fitSKM)     0.82             0.154         0.046
#> 2    Graph (fitSSC)     0.70             0.166         0.048
#> 3    Model (fitSMM)     0.62             0.167         0.013

Functions

The transport and fairness building blocks can be called on their own.

References