Code
library(tourr)
library(cassowaryr)
library(spinebil)
library(dplyr)
library(tidyr)
library(purrr)
library(ggplot2)
library(knitr)
library(patchwork)
library(purrr)stringy05 index in a guided tour20 Jun 2026
The stringy05 scagnostic measures string-like or snake-like structure in a two-dimensional point cloud. Patterns receiving large values may include chains, curved paths, and polynomial or sine-wave-like relationships. This makes stringy05 a potential projection pursuit index for guiding a tour toward nonlinear structures hidden in high-dimensional data.
This vignette demonstrates how to:
stringy05 with several guided-tour optimizers; andThe examples use both the original index and a rescaled version that reduces values likely to arise from Gaussian noise.
The example dataset contains four variables. Variables V1 and V2 are Gaussian noise, while variables V3 and V4 contain the underlying quadratic structure.
A quadratic polynomial is used to create a clear string-like pattern:
V3 represents the linear coordinate along the curve,V4 represents the quadratic component of the curve.After generating the data, only the structural variables, V3 and V4, are rescaled so that their means and standard deviations match those of the noise variables. The noise variables remain unchanged. This removes differences in location and scale between the signal and noise variables while preserving the quadratic relationship between V3 and V4. As a result, the optimizer must identify the projection based on the geometric structure rather than relying on the signal variables having a larger variance than the noise variables.
set.seed(1050)
n <- 500
t <- seq(-2, 2, length.out = n)
poly_signal <- poly(t, degree = 2, raw = TRUE)
stringy4_raw <- data.frame(
V1 = rnorm(n, sd = 0.15),
V2 = rnorm(n, sd = 0.15),
V3 = poly_signal[, 1] + rnorm(n, sd = 0.01),
V4 = poly_signal[, 2] + rnorm(n, sd = 0.02)
)
match_mean_sd <- function(x, target) {
(x - mean(x)) / sd(x) * sd(target) + mean(target)
}
stringy4_raw$V3 <- match_mean_sd(
stringy4_raw$V3,
stringy4_raw$V1
)
stringy4_raw$V4 <- match_mean_sd(
stringy4_raw$V4,
stringy4_raw$V2
)
stringy4 <- as.matrix(stringy4_raw)
# colMeans(stringy4_raw)
# sapply(stringy4_raw, sd)V3 and V4.V1 and V2.The signal and noise projections are shown side by side to define the target structure that the guided tour should recover.
plot_projection <- bind_rows(
tibble(
x = stringy4[, 1],
y = stringy4[, 2],
projection = "noise: V1 and V2"
),
tibble(
x = stringy4[, 3],
y = stringy4[, 4],
projection = "signal: V3 and V4"
)
)
ggplot(plot_projection, aes(x = x, y = y)) +
geom_point(size = 0.7, alpha = 0.7) +
facet_wrap(~ projection, nrow = 1, scales = "free") +
theme_bw() +
theme(
aspect.ratio = 1,
axis.text = element_blank(),
axis.ticks = element_blank()
) +
labs(
x = NULL,
y = NULL,
title = "Noise and signal projections"
)
The right panel contains the true stringy structure. The left panel contains only noise.
A grand tour rotates through many two-dimensional projections of the four-dimensional data. It provides an initial visual check that the hidden structure is present and visible from suitable projection angles before an index-guided search is applied.
The grand tour provides an unguided view of the projection space. The guided tour can then use stringy05 to search specifically for projections with string-like structure.
stringy05 as a projection pursuit index# Define the index
rescale_stringy05 <- function(z, n) {
lb <- 0.05 + 3.86 / sqrt(n)
pmax(0, (z - lb) / (1 - lb))
}
stringy05_raw <- function(rescale = FALSE) {
function(mat) {
z <- cassowaryr::sc_stringy05(mat[, 1], mat[, 2])
if (rescale) {
z <- rescale_stringy05(z, nrow(mat))
}
z
}
}
stringy05_index_raw <- stringy05_raw(rescale = FALSE)
stringy05_index_rescaled <- stringy05_raw(rescale = TRUE)Before optimization, the index is evaluated directly on the known signal and noise planes.
direct_check <- tibble(
projection = c("true structure: V3-V4", "noise: V1-V2"),
stringy05_values = c(
stringy05_index_rescaled(stringy4 %*% basis_true),
stringy05_index_rescaled(stringy4 %*% basis_noise)
)
)
knitr::kable(
direct_check,
digits = 3,
align = "c",
caption = "stringy05 values for the true structured projection and a noise projection."
)| projection | stringy05_values |
|---|---|
| true structure: V3-V4 | 0.996 |
| noise: V1-V2 | 0.000 |
search_geodesicThe first guided tour uses the default optimizer, search_geodesic, with the rescaled stringy05 index.
search_geodesic tour interactivelyThe animation can be used to assess whether the guided tour moves toward the hidden polynomial structure in variables V3 and V4.
search_better_randomThe search_better_random optimizer provides a more exploratory alternative to the default geodesic search. This can be useful when a scagnostic index produces an irregular optimization surface or several local optima.
search_better_random tour as a gifComparing the two animations shows whether either optimizer recovers the polynomial structure more clearly or more consistently.
search_jellyfishUnlike local search methods, the Jellyfish optimizer begins from multiple candidate projection bases (jellies), with each jelly exploring projection space independently. The returned object therefore contains multiple search loops, where each loop represents one path through projection space.
To increase the likelihood of finding the global optimum, the optimizer is run with 40 jellies and 40 maximum search iterations per jelly. Increasing the number of jellies allows the algorithm to explore a wider range of starting projections, while increasing max.tries gives each jelly more opportunities to refine its search toward a higher projection pursuit index.
The Jellyfish search is first applied to the four-dimensional polynomial dataset, and the resulting search history is saved for later visualization and analysis.
The projection with the largest index value is selected across all Jellyfish loops and iterations.
The loop containing the maximum index value is extracted and replayed. The loop number is determined from the result rather than fixed in advance.
Extract the best loop and its sequence of bases:
Only the loop containing the best projection is replayed.
The exact best basis can also be extracted and used to plot the corresponding projection directly.
best_jelly <- jellyfish_res |> ferrn::get_best()
best_basis <- best_jelly$basis[[1]]
best_projection <- as.data.frame(stringy4 %*% best_basis)
colnames(best_projection) <- c("x", "y")
ggplot(best_projection, aes(x = x, y = y)) +
geom_point(size = 0.7, alpha = 0.7) +
coord_equal() +
theme_bw() +
theme(
aspect.ratio = 1,
axis.text = element_blank(),
axis.ticks = element_blank()
) +
labs(
x = NULL,
y = NULL,
title = "Best projection found by Jellyfish",
subtitle = paste0(
"Best loop = ", best_loop,
", tries = ", best_jelly$tries,
", stringy05 = ", round(best_jelly$index_val, 3)
)
)
The maximum index value found by Jellyfish is:
Jellyfish can be useful for scagnostic indices because their optimization surfaces may be irregular or contain local optima. Exploring several candidate paths increases the opportunity to locate a projection with a large index value, although it does not guarantee recovery of the global optimum.
The number of noise variables is increased to examine whether Jellyfish can still recover the hidden polynomial structure. In each dataset, the final two variables contain the signal and all preceding variables contain Gaussian noise.
Generate the datasets:
match_mean_sd <- function(x, target) {
(x - mean(x)) / sd(x) * sd(target) + mean(target)
}
make_poly_data <- function(n = 500, p = 6, seed = 1050) {
set.seed(seed)
t <- seq(-2, 2, length.out = n)
poly_signal <- poly(t, degree = 2, raw = TRUE)
# Same structural variables for every p
signal_1 <- poly_signal[, 1] + rnorm(n, sd = 0.01)
signal_2 <- poly_signal[, 2] + rnorm(n, sd = 0.02)
# Noise variables
noise <- matrix(
rnorm(n * (p - 2), sd = 0.15),
nrow = n,
ncol = p - 2
)
# Match the structural variables to the noise distribution
target <- as.vector(noise)
signal_1 <- match_mean_sd(signal_1, target)
signal_2 <- match_mean_sd(signal_2, target)
poly_data <- cbind(
noise,
signal_1,
signal_2
)
colnames(poly_data) <- paste0("V", seq_len(p))
poly_data
}
poly6 <- make_poly_data(n = 500, p = 6, seed = 1050)
poly8 <- make_poly_data(n = 500, p = 8, seed = 1050)
poly12 <- make_poly_data(n = 500, p = 12, seed = 1050)Before running Jellyfish, the index is evaluated on the known signal and noise projections.
poly_direct_check <- tibble(
data = c("poly6", "poly8", "poly12"),
true_projection = c(
stringy05_index_rescaled(poly6 %*% basis6_true),
stringy05_index_rescaled(poly8 %*% basis8_true),
stringy05_index_rescaled(poly12 %*% basis12_true)
),
noise_projection = c(
stringy05_index_rescaled(poly6 %*% basis6_noise),
stringy05_index_rescaled(poly8 %*% basis8_noise),
stringy05_index_rescaled(poly12 %*% basis12_noise)
)
)
knitr::kable(poly_direct_check, digits = 3)| data | true_projection | noise_projection |
|---|---|---|
| poly6 | 0.974 | 0 |
| poly8 | 0.974 | 0 |
| poly12 | 0.974 | 0 |
A large value for the known signal projection confirms that the index recognizes the target structure. If the optimizer does not recover a comparable projection, the limitation is more likely related to search difficulty than to the index definition itself.
The same search procedure is applied to the six-, eight-, and twelve-dimensional datasets.
Read the saved results:
Summarize the best values:
best_poly_summary <- tibble(
data = c("poly6", "poly8", "poly12"),
best_loop = c(best_poly6$loop, best_poly8$loop, best_poly12$loop),
tries = c(best_poly6$tries, best_poly8$tries, best_poly12$tries),
max_index_val = c(best_poly6$index_val, best_poly8$index_val, best_poly12$index_val)
)
knitr::kable(best_poly_summary, digits = 3)| data | best_loop | tries | max_index_val |
|---|---|---|---|
| poly6 | 15 | 37 | 0.268 |
| poly8 | 17 | 37 | 0.216 |
| poly12 | 5 | 38 | 0.125 |
bases_poly6_best <- poly6_jelly |>
filter(loop == best_poly6$loop) |>
arrange(tries) |>
pull(basis) |>
check_dup(0.1)
bases_poly8_best <- poly8_jelly |>
filter(loop == best_poly8$loop) |>
arrange(tries) |>
pull(basis) |>
check_dup(0.1)
bases_poly12_best <- poly12_jelly |>
filter(loop == best_poly12$loop) |>
arrange(tries) |>
pull(basis) |>
check_dup(0.1)plot_best_proj <- function(data, best_row, title_text) {
best_basis <- best_row$basis[[1]]
proj <- as.data.frame(as.matrix(data) %*% best_basis)
colnames(proj) <- c("x", "y")
ggplot(proj, aes(x = x, y = y)) +
geom_point(size = 0.6, alpha = 0.7) +
coord_equal() +
theme_bw() +
theme(
aspect.ratio = 1,
axis.text = element_blank(),
axis.ticks = element_blank()
) +
labs(
x = NULL,
y = NULL,
title = title_text,
subtitle = paste0(
"best loop = ", best_row$loop,
", tries = ", best_row$tries,
", stringy05 = ", round(best_row$index_val, 3)
)
)
}The true projection containing the quadratic signal has a Stringy index value of approximately 0.996. However, as the number of dimensions increases, the Jellyfish optimizer appears to struggle to locate this optimal projection. In the six-dimensional example, the best projection found by Jellyfish has a much lower index value, suggesting that the optimizer may have converged to a local optimum or failed to reach the neighbourhood of the true signal.
To investigate this further, we first extract the best basis found by Jellyfish. We then use this basis as the starting point for search_polish. Since search_polish performs a very local search around the current projection, this experiment allows us to determine whether Jellyfish has already moved into a promising region of the projection space. We can then observe whether polishing continues to improve the projection and moves toward the true optimum of 0.996, or whether it produces only a small improvement, indicating that the Jellyfish solution is still far from the optimal projection.
# Extract the projection basis
best_basis_poly6 <- best_poly6$basis[[1]]
set.seed(1050)
poly6_polish_history <- save_history(
data = poly6,
tour_path = guided_tour(
index_f = stringy05_index_rescaled,
search_f = search_polish,
polish_max_tries = 50,
n_sample = 200,
),
start = best_basis_poly6
)
saveRDS(
poly6_polish_history,
file = "poly6_polish_history.rds"
)Now, let’s examine the index values found by search_polish.
[1] 0.2680160 0.2970523 0.3226147 0.3226147
attr(,"class")
[1] "path_index"
The Jellyfish Search Optimizer is stochastic, so a single optimization run is not sufficient to assess how reliably it can recover the known Stringy structure. We therefore repeat the optimization 20 times using different random seeds and retain the best projection found in each run. For every run, the Jellyfish search is performed using the same projection pursuit index and optimization settings, and the projection corresponding to the maximum index value returned by Jellyfish is stored.
Our first experiment investigates the effect of increasing the dimensionality of the data while keeping the optimization budget fixed. The same underlying two-dimensional polynomial signal is embedded in datasets of increasing dimension, with the additional variables containing Gaussian noise. The signal variables are kept the same across dimensions, and the noise variables are nested so that increasing the dimension only introduces additional irrelevant variables.
For the initial comparison, we use 40 jellyfish and a maximum of 40 tries for every dataset and repeat the search 20 times. We consider the 4D, 6D, 8D, and 12D datasets. Keeping the optimizer settings fixed provides a fair comparison: if Jellyfish performance deteriorates as the dimension increases, this indicates that recovering the Stringy projection becomes more difficult as the search space grows under a fixed optimization budget.
After establishing this baseline, the number of jellyfish can be varied in a second experiment to investigate whether increasing the amount of search improves recovery in the higher-dimensional problems. This will help distinguish between deterioration caused by insufficient search effort and a more fundamental difficulty associated with optimization in higher dimensions.
For each of the 20 repeated runs, the following function extracts the best projection found by Jellyfish and displays it in a separate panel. The Stringy index value obtained in the run is also displayed above each projection. The panels are arranged in run order from 1 to 20.
plot_jellyfish_runs <- function(
results,
ncol = 5,
nrow = 4
) {
all_values <- unlist(
lapply(
results$jellyfish_projection,
as.numeric
)
)
plot_limit <- max(abs(all_values), na.rm = TRUE)
plots <- purrr::map(
seq_len(nrow(results)),
function(i) {
best_projection <- as.data.frame(
results$jellyfish_projection[[i]]
)
colnames(best_projection) <- c("x", "y")
ggplot(
best_projection,
aes(x = x, y = y)
) +
geom_point(
size = 0.6,
alpha = 0.7
) +
coord_equal(
xlim = c(-plot_limit, plot_limit),
ylim = c(-plot_limit, plot_limit)
) +
theme_bw() +
theme(
aspect.ratio = 1,
axis.text = element_blank(),
axis.ticks = element_blank(),
axis.title = element_blank(),
panel.grid = element_blank(),
plot.title = element_text(
size = 9,
hjust = 0.5
),
plot.subtitle = element_text(
size = 8,
hjust = 0.5
)
) +
labs(
title = paste0(
"Run ", results$run[i]
),
subtitle = paste0(
"J = ",
round(
results$jellyfish_index[i],
3
)
)
)
}
)
patchwork::wrap_plots(
plots,
ncol = ncol,
nrow = nrow
) +
patchwork::plot_annotation(
title = paste0(
unique(results$dimension),
"D: Best Jellyfish projection from 20 runs"
),
subtitle = paste0(
unique(results$n_jellies),
" jellyfish, ",
unique(results$max_tries),
" maximum tries | ",
"Known signal index = ",
round(
unique(results$true_2d_index),
3
)
),
theme = theme(
plot.title = element_text(
size = 14,
face = "bold"
),
plot.subtitle = element_text(
size = 10
)
)
)
}We first examine the 4D problem. Each panel below represents one independent Jellyfish run. The points correspond to the best two-dimensional projection found during that run, and (J) gives its rescaled Stringy index value.
Next, we increase the number of jellyfish from 40 to 60 while keeping the other settings unchanged. We again compare the 6D, 8D, and 12D datasets over 20 repeated runs.
Finally, we increase the number of jellyfish to 100 while keeping the remaining settings unchanged. We again compare the 6D, 8D, and 12D datasets over 20 repeated runs.