Code
library(tourr)
library(cassowaryr)
library(spinebil)
library(dplyr)
library(tidyr)
library(purrr)
library(ggplot2)
library(knitr)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.
# A tibble: 1 × 8
basis index_val info method alpha tries loop id
<list> <dbl> <chr> <chr> <dbl> <dbl> <dbl> <int>
1 <dbl [4 × 2]> 0.995 current_best search_jellyfish NA 40 15 1575
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_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:
Although the Jellyfish optimizer successfully identifies a projection that is extremely close to the optimal projection, the corresponding animation does not clearly show how the search reaches this view. The full search path contains many intermediate projection bases, making it difficult to visualise the progression toward the optimum.
To better illustrate the final stage of the search, we extract only the last ten projection bases from the loop containing the highest projection pursuit index. These represent the final refinements made by the optimizer before reaching its best solution.
best_10 <- jellyfish_res |>
filter(loop == best_loop, !is.na(index_val)) |>
slice_max(
order_by = index_val,
n = 10,
with_ties = FALSE
) |>
arrange(index_val)
bases_best_10 <- best_10$basis
render_gif(
data = stringy4,
tour_path = planned_tour(
bases_best_10
),
display = display_xy(
axes = "bottomleft",
center = TRUE,
half_range = 0.5,
cex = 0.9
),
frames = 500,
gif_file = "stringy4_jellyfish_best_10.gif",
width = 400,
height = 400
)Interestingly, using only the last ten projection bases produces an almost static plot rather than an animation. This happens because the final projection bases are extremely similar to one another. Since there is very little difference between consecutive bases, the planned tour has almost no movement to interpolate, so the animation appears nearly static.
Another observation is that several consecutive projection bases have similar projection pursuit index value. These repeated index values usually correspond to very similar projection bases, contributing little additional information to the animation.
To obtain a more informative visualization, we instead keep only the projection bases where the index value changes from the previous step. In other words, consecutive bases with identical index values are removed. This preserves the main stages of the optimizer’s progress while eliminating redundant intermediate steps, making it much easier to see how the search moves toward the optimal projection.
best_loop_data <- jellyfish_res |>
filter(loop == best_loop)
best_loop_improving <- best_loop_data |>
filter(
row_number() == 1 |
index_val != lag(index_val)
)
bases_improved <- best_loop_improving |>
pull(basis)
final_basis <- tail(bases_improved, 1)[[1]]
bases_improved <- c(
bases_improved,
rep(list(final_basis), 15)
)
render_gif(
data = stringy4,
tour_path = planned_tour(
bases_improved
),
display = display_xy(
axes = "bottomleft",
center = TRUE,
half_range = 0.45,
cex = 0.9
),
frames = 500,
gif_file = "stringy4_jellyfish_improved_loop.gif",
width = 400,
height = 400
)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"