The two-layer (“bipartite”) crossing-minimization problem underlies
many diagram types, from alluvial/Sankey plots to storyline charts, and
is the subject of dedicated benchmarks such as the 2024 PACE Challenge.
Two classic, cheap heuristics for that problem are the
barycenter heuristic (place each node at the average position
of its neighbors) and the median heuristic (place each node at
the median position instead) – both available here as
method = "barycenter"/"median" in
[sort_to_uncross()].
That classical problem is unweighted: every crossing counts the same.
Alluvial data is not – edges have a magnitude (the size of the flow),
and sort_to_uncross()‘s default objective
(weighted_metric = TRUE) weights each crossing by the
product of the two crossing edges’ magnitudes, so a crossing between two
large flows counts for more than one between two small ones.
neighbornet (this package’s default heuristic, adapted from
phylogenetic network reconstruction) and the two-sided
greedy heuristic were both designed with this weighted
objective in mind. This vignette checks whether that design choice
actually pays off: how close does each method get to the true
weighted-optimal ordering, on data built for the weighted case?
We compare five methods – tsp, neighbornet,
greedy, barycenter, and median –
against the true optimum. For small enough instances, that optimum can
be found by brute force: try every ordering of both layers and keep the
best.
combinat_perms <- function(x) {
if (length(x) == 1) return(list(x))
res <- list()
for (i in seq_along(x)) for (r in combinat_perms(x[-i])) res[[length(res) + 1]] <- c(x[i], r)
res
}
# Exhaustive search over both layers' orderings -- (n_categories!)^2 candidate
# arrangements, so this only stays fast enough to keep here for small
# n_categories (up to ~5).
brute_force_optimum <- function(df, n_categories) {
labels <- as.character(seq_len(n_categories) - 1L)
perms <- combinat_perms(labels)
best <- Inf
for (pa in perms) {
for (pb in perms) {
d2 <- df
d2$A <- factor(d2$A, levels = pa)
d2$B <- factor(d2$B, levels = pb)
obj <- compute_crossing_objective(d2, cols = c("A", "B"), wt = "value", weighted_metric = TRUE)$output_objective
if (obj < best) best <- obj
}
}
best
}Uniform-random categories give every heuristic no structure to
exploit, which isn’t representative of real alluvial data
(e.g. tissue/cell-type combinations, where columns correlate through a
shared underlying identity). This generator gives each row a latent
cluster label shared across both columns; with probability
cluster_strength a column’s value follows that cluster
(through its own random permutation of labels), otherwise it’s uniform
noise.
make_weighted_two_layer_df <- function(n_rows, n_categories, seed, cluster_strength = 0.7) {
set.seed(seed)
labels <- as.character(seq_len(n_categories) - 1L)
cluster <- sample.int(n_categories, size = n_rows, replace = TRUE)
col_perms <- replicate(2, sample(labels), simplify = FALSE)
raw <- as.data.frame(lapply(1:2, function(j) {
matches_cluster <- stats::runif(n_rows) < cluster_strength
noise <- sample(labels, size = n_rows, replace = TRUE)
ifelse(matches_cluster, col_perms[[j]][cluster], noise)
}), stringsAsFactors = FALSE)
names(raw) <- c("A", "B")
as.data.frame(dplyr::count(raw, A, B, name = "value"))
}For a few problem sizes and several random replicates each, find the true optimum and see how far each method’s result lands from it.
methods <- c("tsp", "neighbornet", "greedy", "barycenter", "median")
n_categories_vals <- c(3, 4, 5)
n_seeds <- 6
rows <- list()
for (nc in n_categories_vals) {
for (seed in seq_len(n_seeds)) {
df <- make_weighted_two_layer_df(n_rows = 500, n_categories = nc, seed = seed)
optimum <- brute_force_optimum(df, nc)
for (method in methods) {
sorted <- sort_to_uncross(df, cols = c("A", "B"), wt = "value", method = method)
obj <- compute_crossing_objective(sorted, cols = c("A", "B"), wt = "value", weighted_metric = TRUE)$output_objective
rows[[length(rows) + 1]] <- data.frame(
n_categories = nc, seed = seed, method = method,
objective = obj, optimum = optimum
)
}
}
}
results <- do.call(rbind, rows)
results$pct_above_optimal <- 100 * (results$objective - results$optimum) / results$optimumsummary_table <- aggregate(
pct_above_optimal ~ method, data = results,
FUN = function(x) c(median = round(median(x), 2), mean = round(mean(x), 2), max = round(max(x), 2))
)
summary_table <- do.call(data.frame, summary_table)
names(summary_table) <- c("method", "median_pct_above_optimal", "mean_pct_above_optimal", "max_pct_above_optimal")
knitr::kable(summary_table[order(summary_table$median_pct_above_optimal), ])| method | median_pct_above_optimal | mean_pct_above_optimal | max_pct_above_optimal | |
|---|---|---|---|---|
| 4 | neighbornet | 0.00 | 1.66 | 6.78 |
| 5 | tsp | 0.00 | 3.01 | 15.90 |
| 1 | barycenter | 8.58 | 10.85 | 30.99 |
| 2 | greedy | 8.58 | 10.85 | 30.99 |
| 3 | median | 8.58 | 10.85 | 30.99 |
if (requireNamespace("ggplot2", quietly = TRUE)) {
results$method <- factor(results$method, levels = summary_table$method[order(summary_table$median_pct_above_optimal)])
ggplot2::ggplot(results, ggplot2::aes(x = method, y = pct_above_optimal)) +
ggplot2::geom_boxplot(outlier.shape = NA) +
ggplot2::geom_jitter(width = 0.15, alpha = 0.5) +
ggplot2::labs(
x = NULL, y = "% above true optimal (weighted crossings)",
title = "Distance from the true weighted-optimal ordering",
subtitle = sprintf("%d configs (n_categories in %s, %d seeds each)", nrow(results) / length(methods), paste(n_categories_vals, collapse = ", "), n_seeds)
) +
ggplot2::theme_minimal()
}neighbornet – this package’s default two-sided method –
lands closest to the true optimum (median 0% above optimal across these
18 configurations, hitting the exact optimum in more than half of them),
clearly ahead of the classical
barycenter/median heuristics (median ~8.6%
above optimal) and the two-sided greedy heuristic, which
performs identically to barycenter/median at
this scale. That three otherwise-different algorithms converge to the
same answer here isn’t a bug: at small n_categories with
strongly clustered data, there’s often one dominant, “obvious”
arrangement that several reasonable heuristics independently find –
confirmed above by checking that they diverge normally once the
clustering signal is weakened. tsp performs comparably to
neighbornet here, though with a higher ceiling on how far
it can miss (up to 15.9% above optimal in this sample, versus 6.8% for
neighbornet) despite being exponentially more expensive to
run.
This is a small, brute-force-validated demonstration rather than a
large-scale benchmark – see benchmarks/ in the package
source for larger sweeps – but it directly answers the motivating
question: on data built for the weighted, correlated case
sort_to_uncross() targets, its default heuristic does
outperform the classical unweighted-problem heuristics, not just match
them.
## R version 4.6.1 (2026-06-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 26.04.1 LTS
##
## Matrix products: default
## BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
## LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
##
## locale:
## [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
## [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
## [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
## [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
## [9] LC_ADDRESS=C LC_TELEPHONE=C
## [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
##
## time zone: Etc/UTC
## tzcode source: system (glibc)
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] wompwomp_1.0.0 ggalluvial_0.12.6 ggplot2_4.0.3 dplyr_1.2.1
## [5] rmarkdown_2.32
##
## loaded via a namespace (and not attached):
## [1] sass_0.4.10 utf8_1.2.6 generics_0.1.4 tidyr_1.3.2
## [5] stringi_1.8.9 digest_0.6.39 magrittr_2.0.5 evaluate_1.0.5
## [9] grid_4.6.1 RColorBrewer_1.1-3 iterators_1.0.14 fastmap_1.2.0
## [13] foreach_1.5.2 jsonlite_2.0.0 purrr_1.2.2 scales_1.4.0
## [17] tweenr_2.0.3 codetools_0.2-20 jquerylib_0.1.4 cli_3.6.6
## [21] rlang_1.3.0 polyclip_1.10-7 withr_3.0.3 cachem_1.1.0
## [25] yaml_2.3.12 otel_0.2.0 tools_4.6.1 buildtools_1.0.0
## [29] vctrs_0.7.3 TSP_1.2.7 R6_2.6.1 lifecycle_1.0.5
## [33] stringr_1.6.0 MASS_7.3-66 pkgconfig_2.0.3 pillar_1.11.1
## [37] bslib_0.12.0 gtable_0.3.6 glue_1.8.1 Rcpp_1.1.2
## [41] ggforce_0.5.0 xfun_0.62 tibble_3.3.1 tidyselect_1.2.1
## [45] sys_3.4.3 knitr_1.52 farver_2.1.2 htmltools_0.5.9
## [49] igraph_2.3.4 maketools_1.3.2 labeling_0.4.3 compiler_4.6.1
## [53] S7_0.2.2