--- title: "Parameter tuning and sensitivity" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Parameter tuning and sensitivity} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4) library(simOutrank) ``` 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`. ```{r base} 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): ```{r rand} 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)$memberships ``` ## Fixed vs quantile thresholds A 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. ```{r quantile} 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 ``` ## Threshold sensitivity Sweep the activity-similarity threshold and measure both the average credibility and the agreement of the resulting clustering with the baseline. ```{r threshold-sweep} 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) ``` The Rand index stays high across the sweep: the four-cluster solution is not an artefact of one threshold choice. ## Weight sensitivity Do the same for the weight of the activities criterion, from negligible to dominant (weights are renormalised internally). ```{r weight-sweep} 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) ``` ## Choosing k `eigengap()` guides `k` from the spectrum of the normalized Laplacian; the gap after the k-th eigenvalue marks a natural cut. ```{r eigengap} sim <- outrank_similarity(traces, criteria()) gaps <- eigengap(sim, k_max = 8) gaps[which.max(gaps$gap), ] ``` Pair the diagnostic with a validity index across candidate `k` when \pkg{clValid} is available: ```{r kvalidity} 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))) } ``` ## Takeaway Report 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. ```