An outranking model has three tuning surfaces: the
thresholds of each criterion, the
weights between criteria, and the number of
clusters k. A good analysis checks that its
conclusions are not an artefact of a single arbitrary choice. This
vignette shows how, using illustrative_log.
traces <- as_traces(illustrative_log, "case_id", "activity", "timestamp")
criteria <- function(activity_sim = 0.8, activity_indiff = 0.7,
w_activity = 0.2) {
list(
crit_activity_profile(weight = w_activity, indifference = activity_indiff,
similarity = activity_sim, veto = 0.4),
crit_edit_distance(weight = 0.2, similarity = 2, indifference = 3,
veto = 6),
crit_nominal("status", weight = 0.3),
crit_nominal("satisfaction", weight = 0.3)
)
}We compare clusterings with the Rand index (the fraction of case pairs that two partitions agree to keep together or apart):
rand_index <- function(a, b) {
agree <- 0L; n <- length(a)
for (i in seq_len(n - 1L)) for (j in (i + 1L):n) {
agree <- agree + ((a[i] == a[j]) == (b[i] == b[j]))
}
agree / choose(n, 2)
}
baseline <- cluster_traces(outrank_similarity(traces, criteria()),
k = 4, seed = 42)$membershipsA threshold can be a fixed number or a quantile of the criterion’s
own value distribution, written as_quantile(p). Quantiles
adapt to the data, which is useful when you do not have a natural
scale.
crit_fixed <- crit_activity_profile(0.2, indifference = 0.7, similarity = 0.8)
crit_quantile <- crit_activity_profile(0.2, indifference = as_quantile(0.5),
similarity = as_quantile(0.8))
crit_quantile
#> <criterion 'activity_profile'>: direction = similarity, weight = 0.2
#> indifference = as_quantile(0.5), similarity = as_quantile(0.8), veto = noneSweep the activity-similarity threshold and measure both the average credibility and the agreement of the resulting clustering with the baseline.
grid <- seq(0.75, 0.95, by = 0.05) # kept above the indifference of 0.70
sens <- t(vapply(grid, function(s) {
sim <- outrank_similarity(traces, criteria(activity_sim = s))
memb <- cluster_traces(sim, k = 4, seed = 42)$memberships
c(mean_S = mean(sim$S[upper.tri(sim$S)]),
rand = rand_index(memb, baseline))
}, numeric(2)))
data.frame(similarity = grid, sens)
#> similarity mean_S rand.1
#> 1 0.75 0.4959126 0.9633333
#> 2 0.80 0.4885674 1.0000000
#> 3 0.85 0.4765922 1.0000000
#> 4 0.90 0.4643035 1.0000000
#> 5 0.95 0.4566253 1.0000000The Rand index stays high across the sweep: the four-cluster solution is not an artefact of one threshold choice.
Do the same for the weight of the activities criterion, from negligible to dominant (weights are renormalised internally).
weights <- c(0.05, 0.2, 0.5, 1)
w_sens <- vapply(weights, function(w) {
sim <- suppressMessages(outrank_similarity(traces,
criteria(w_activity = w)))
rand_index(cluster_traces(sim, k = 4, seed = 42)$memberships, baseline)
}, numeric(1))
data.frame(w_activity = weights, rand = w_sens)
#> w_activity rand
#> 1 0.05 0.9633333
#> 2 0.20 1.0000000
#> 3 0.50 0.8666667
#> 4 1.00 0.8666667eigengap() guides k from the spectrum of
the normalized Laplacian; the gap after the k-th eigenvalue marks a
natural cut.
Pair the diagnostic with a validity index across candidate
k when is available:
if (requireNamespace("clValid", quietly = TRUE)) {
t(vapply(2:6, function(k) {
cl <- cluster_traces(sim, k = k, seed = 42)
c(k = k, validate_clusters(cl))
}, numeric(3)))
}
#> k connectivity dunn
#> [1,] 2 1.317857 0.3881421
#> [2,] 3 9.033730 0.4285714
#> [3,] 4 20.276190 0.4891980
#> [4,] 5 25.589683 0.4000000
#> [5,] 6 29.134127 0.0000000Report the sweep, not a point estimate. When the Rand index stays high and a validity index agrees with the eigengap, the clustering is robust to the inevitable arbitrariness of the parameters. ```