Robustness: constraints and trimming

A decision maker rarely accepts a clustering as-is. simOutrank lets them inject domain knowledge through pairwise constraints and remove uninformative cases by trimming. This vignette follows Runs 2 and 3 of Delias et al. (2023) on the bundled illustrative_log.

traces <- as_traces(illustrative_log, "case_id", "activity", "timestamp")
criteria <- list(
  crit_activity_profile(weight = 0.2, indifference = 0.7, similarity = 0.8,
                        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)
)
sim <- outrank_similarity(traces, criteria)
run1 <- cluster_traces(sim, k = 4, seed = 42)$memberships

Each Run-1 cluster already contains a single tier: with these criteria and the corrected normalisation, simOutrank separates GOLD from NORMAL without any constraint. (The paper’s original Run 1, under a different normalisation, mixed some long-path GOLD customers with NORMAL ones.)

tier <- illustrative_log$status[match(names(run1), illustrative_log$case_id)]
table(cluster = run1, tier = tier)
#>        tier
#> cluster GOLD NORMAL
#>       1    0      6
#>       2    0      8
#>       3    5      0
#>       4    6      0

Run 3: trim outliers

The two outliers dilute the clustering. trim_outliers() scores each case by its total similarity to the others and removes the least-connected ones. The genuine low-similarity case is flagged first:

trimmed <- trim_outliers(sim, prop = 0.04)   # remove floor(0.04 * 25) = 1 case
attr(trimmed, "trimmed")
#> [1] "24"

Case 24, whose A, B, E flow matches neither tier, has the lowest connectivity and is removed. (The paper additionally trims case 25 by domain judgement; under this weighting 25 shares its tier and satisfaction with many cases and is not flagged automatically – a good illustration of why trimming is offered as a tool, not an oracle.) For small logs the exact integer program is also available:

identical(attr(trim_outliers(sim, prop = 0.04, method = "lp"), "trimmed"),
          attr(trim_outliers(sim, prop = 0.04, method = "greedy"), "trimmed"))
#> [1] TRUE

Clustering the trimmed matrix gives cleaner groups:

run3 <- cluster_traces(trimmed, k = 4, seed = 42)$memberships
split(names(run3), run3)
#> $`1`
#> [1] "1"  "2"  "3"  "4"  "5"  "6"  "7"  "8"  "25"
#> 
#> $`2`
#> [1] "16" "17" "18" "19" "20" "21"
#> 
#> $`3`
#> [1] "9"  "10" "22" "23"
#> 
#> $`4`
#> [1] "11" "12" "13" "14" "15"

Putting it together

Constraints and trimming compose: trim first, then constrain (or the reverse), and cluster the adjusted matrix.

final <- cluster_traces(cannot_link(trimmed, "status"), k = 4, seed = 42)
if (requireNamespace("clValid", quietly = TRUE)) validate_clusters(final)
#> connectivity         dunn 
#>   17.9928571    0.6666667

Reference

Delias, P., Doumpos, M., Grigoroudis, E. and Matsatsinis, N. (2023). Improving the non-compensatory trace-clustering decision process. International Transactions in Operational Research, 30(3), 1387–1406. doi:10.1111/itor.13062 ```