Skip to contents

This vignette is a guide to diagnosing Projection Pursuit Indices in high-dimensional exploratory analysis. We examine how Projection Pursuit Indices behave under different conditions. The goal is to help you decide which index to use, how to tune it, and how to trust its signals.

Overview

This vignette presents a workflow built around these functions: data_gen(), noise_gen(), ppi_mean(), ppi_scale(), ppi_noise_threshold() and ppi_samplesize_effect().

1) Generating Synthetic Structures and Noise

  • data_gen(); generate structured 2D point sets (e.g., sine, circle, cluster) or orthogonal polynomial features for benchmarking/visualisation.

    • Structures (type like "sine", "circle", …) return a tibble with columns x, y, and structure.
    • Polynomial (type = "polynomial") returns an n × degree matrix of orthogonal polynomial basis vectors on [0,1].
  • noise_gen(); generates a numeric vector of noise values (as a tibble with value and type) from distributions such as: "gaussian", "uniform", "lognormal", "t_distributed", "cauchy", "beta_noise", "exponential", "microstructure".

We’ll preview available structures, create simple polynomial features, sample a few noise types, and combine structure + noise.

Function usage

data_gen(type = "all", n = 500, degree = NULL, seed = NULL)
  • type: "linear", "sine", "circle", "cluster", "snake", "outliers", "sparse", "clumpy", "skewed", "striated", "concave", "monotonic", "doughnut", "all", or "polynomial".
  • n: number of points/samples.
  • degree: only for "polynomial"; number of basis columns.
  • seed: optional seed for reproducibility.
noise_gen(n = 500, type = "gaussian", level = 0.1, seed = NULL)
  • n: number of noise samples.
  • type: distribution name ( "gaussian", "uniform", "lognormal", "t_distributed", "cauchy", "beta_noise", "exponential", "microstructure").
  • level: scale/strength of noise.
  • seed: optional seed.

Example usage

# One structure
df_sine <- spinebil::data_gen("sine", n = 400, seed = 1)

# Multiple structures at once
df_all <- spinebil::data_gen("all", n = 400, seed = 1)

Preview all structures

ggplot2::ggplot(df_all, ggplot2::aes(x, y)) +
  ggplot2::geom_point(alpha = 0.6, size = 0.6) +
  ggplot2::facet_wrap(~structure) +
  ggplot2::labs(
    title = "Synthetic 2D Structures from dataGen()",
    subtitle = "Each panel generated with n = 400"
  ) +
  ggplot2::xlim(-1.5, 1.5) +
  ggplot2::ylim(-1.5, 1.5) +
  ggplot2::theme(
    aspect.ratio = 1,
    axis.text = ggplot2::element_blank(),
    axis.title = ggplot2::element_blank(),
    axis.ticks = ggplot2::element_blank()
  )

Polynomial features (type = "polynomial")

# Generate a 4-degree orthogonal polynomial basis
dPoly <- spinebil::data_gen("polynomial", n = 200, degree = 4, seed = 1)

ggplot2::ggplot(dPoly, ggplot2::aes(x = dPoly[, 1], y = dPoly[, 2])) +
  ggplot2::geom_point(alpha = 0.7) +
  ggplot2::xlab("polynomial degree 1") +
  ggplot2::ylab("polynomial degree 2") +
  ggplot2::theme(
    aspect.ratio = 1,
    axis.text = ggplot2::element_blank(),
    axis.ticks = ggplot2::element_blank()
  )

noise_gen(): noise distributions

eps_g <- spinebil::noise_gen(n = 500, type = "gaussian", level = 0.06, seed = 340)
eps_t <- spinebil::noise_gen(n = 500, type = "t_distributed", level = 0.15, seed = 341)
eps_cau <- spinebil::noise_gen(n = 500, type = "cauchy", level = 0.04, seed = 342)

Combining structure + noise

As a simple example, let’s take a clean sine wave and add Gaussian noise to y.

set.seed(345)
n <- 500
sine_clean <- spinebil::data_gen("sine", n = n) |> dplyr::mutate(y = sin(x))

# Noise to add on y
eps <- spinebil::noise_gen(n = n, type = "gaussian", level = 0.06, seed = 123)$value
sine_noisy <- sine_clean |> dplyr::mutate(y = y + eps)

dplyr::bind_rows(
  sine_clean |> dplyr::mutate(which = "clean"),
  sine_noisy |> dplyr::mutate(which = "noisy (gaussian, level = 0.06)")
) |>
  ggplot2::ggplot(ggplot2::aes(x, y, color = which)) +
  ggplot2::geom_point(alpha = 0.6, size = 0.6) +
  ggplot2::labs(
    title = "Sine Structure with and without Added Noise",
    color = NULL
  ) +
  ggplot2::theme(
    axis.text = ggplot2::element_blank(),
    axis.title = ggplot2::element_blank(),
    axis.ticks = ggplot2::element_blank()
  )

Return value

  • data_gen("polynomial", ...) returns an n × degree matrix.
  • data_gen(<other type>, ...) return a tibble with columns x, y, and structure.
  • noise_gen(...) returns a tibble with columns value (numeric) and type (factor/character).

2) Finding Projection Pursuit Index Mean

ppi_mean() simulates and summarizes a projection pursuit index (PPI) over all 2D variable pairs in a dataset. For each pair, it repeats the index evaluation n_sim times (sampling n_obs observations each time) and returns the mean index per pair.

Function usage

res <- ppi_mean(
  data,        # data frame with ≥ 2 numeric columns
  index_fun,   # function taking n×2 matrix and returning a numeric value
  n_sim = 100,
  n_obs = 300
)

Example Usage

data <- as.data.frame(spinebil::data_gen(type = "polynomial", degree = 2))
res <- spinebil::ppi_mean(data, spinebil::scag_index("stringy"), n_sim = 5)
res
#> # A tibble: 1 × 3
#>   var_i var_j mean_index
#>   <chr> <chr>      <dbl>
#> 1 1     2              1

Return value

A tibble with one row per variable pair:

  • var_i, var_j; names of the variables forming the pair.
  • mean_index; the mean of the index across n_sim simulations for that pair.

3) Comparing Index Scale on Structured vs. Noisy Data

ppi_scale() runs simulations to compare a projection pursuit index (PPI) on:

  1. Structured dataset (your input), and
  2. Pure Gaussian noise (same sample size).

For every pair of variables and across many simulations, it records the index values for both cases so you can see the scale and separation of the index between structure and noise.

Function Usage

ppi_scale(
  data,        
  index_fun, 
  n_sim = 100,
  n_obs = 500,
  seed = NULL
)

Inputs:

  • data; data frame/tibble/matrix; (>= 2 numeric columns).
  • index_fun; function: takes a 2-column matrix and returns a numeric scalar (e.g., scag_index("stringy")).
  • n_sim; number of simulations.
  • n_obs; number of observations.
  • seed; optional seed for reproducibility.

Example usage

Let’s evaluate the stringy index from the cassowaryr package to see how the scale change for pure noise and structure.

res <- spinebil::ppi_scale(spinebil::data_gen("polynomial", degree = 3), spinebil::scag_index("stringy"), n_sim = 3)

head(res)
#> # A tibble: 6 × 6
#>   simulation var_i var_j var_pair sigma index
#>        <int> <chr> <chr> <chr>    <dbl> <dbl>
#> 1          1 1     2     1-2          0 1    
#> 2          1 1     2     1-2          1 0.732
#> 3          1 1     3     1-3          0 1    
#> 4          1 1     3     1-3          1 0.783
#> 5          1 2     3     2-3          0 0.996
#> 6          1 2     3     2-3          1 0.719

Here sigma = 0 is structured and sigma = 1 is pure noise.

Boxplots: structured vs noise per variable pair

res |>
  dplyr::mutate(sigma = factor(sigma, levels = c(0, 1), labels = c("structured", "noise"))) |>
  ggplot2::ggplot(ggplot2::aes(x = sigma, y = index, fill = sigma)) +
  ggplot2::geom_boxplot() +
  ggplot2::facet_wrap(~var_pair) +
  ggplot2::labs(
    title = "Index scale on structured vs noisy data",
    x = NULL, y = "index"
  )

Return value

ppi_scale() returns a tibble with columns:

  • simulation: simulation index
  • var_i, var_j: the two variable names
  • var_pair: "var_i-var_j" compact label.
  • sigma: 0 = structured, 1 = noise
    1. Structured value (sigma = 0); compute the index on the actual pair.
    2. Noise value (sigma = 1); generate n_obs standard-normal x, y, scale to mean 0 / sd 1, compute the index.
  • index: the index value from index_fun.

4) Estimating a Noise Threshold for Projection Pursuit Indices

ppi_noise_threshold() estimates the 95th percentile of a projection pursuit index (PPI) under synthetic noise. This gives you a null threshold: if an observed index on real data exceeds this value, it may indicate non-random structure.

The function supports different noise types via noise_gen() (e.g., Gaussian, tt-distributed, Cauchy, microstructure) and lets you set the number of simulations and sample size per simulation.

Function usage

ppi_noise_threshold(
  index_fun,
  n_sim = 100,
  n_obs = 500,
  noise_type  = "gaussian",
  noise_level = 0.01,
  seed = NULL
)

Inputs :

  • index_fun: a function that returns a single numeric value.
  • n_sim: number of simulations.
  • n_obs: sample size per simulation.
  • noise_type / noise_level: passed through to noise_gen() to control the distribution and its scale.
  • seed: for reproducibility.

Example Usage

thr <- spinebil::ppi_noise_threshold(
  index_fun   = scag_index("stringy"),
  noise_type  = "cauchy",
  noise_level = 0.1,
  n_sim       = 10,
  n_obs       = 100
)
thr
#> 95% 
#>  NA

Return value

  • ppi_noise_threshold() returns a single numeric: estimated 95th percentile of the index under the specified noise model.

5) Sample-Size Effects for Projection Pursuit Indices under Gaussian Noise

This demonstrates how to use the ppi_samplesize_effect() function to study how projection pursuit index (PPI) values behave under increasing sample sizes in purely noisy (Gaussian) data. For each size, it computes a specified index function over the samples and records the 95th percentile of index values observed. This helps estimate the distributional behavior of the index under pure noise, depending on sample size. These curves are useful as a null reference: above-curve values may indicate non-random structure.

Function Usage

ppi_samplesize_effect(index_fun, n_sim = 100)
  • index_fun: a function that accepts the data for one simulation and returns a single numeric index value.
  • n_sim: the number of simulations per sample size (default 100).

Internally, the function evaluates a grid of sample sizes from 30 up to 2000 (denser at small sizes), simulates n_sim draws for each, and returns the 95th percentile of the resulting index values.

Example Usage

Let’s evaluate the stringy index from the cassowaryr package to see how its values change under noise as the sample size increases.

result <- spinebil::ppi_samplesize_effect(spinebil::scag_index("stringy"), n_sim = 4)
head(result)
#> # A tibble: 6 × 2
#>   sample_size percentile95
#>         <dbl>        <dbl>
#> 1          30        0.816
#> 2          35        0.752
#> 3          40        0.832
#> 4          45        0.758
#> 5          50        0.815
#> 6          55        0.800
tail(result)
#> # A tibble: 6 × 2
#>   sample_size percentile95
#>         <dbl>        <dbl>
#> 1        1875        0.739
#> 2        1900        0.744
#> 3        1925        0.736
#> 4        1950        0.737
#> 5        1975        0.731
#> 6        2000        0.736

Plot the 95th-percentile

ggplot2::ggplot(result, ggplot2::aes(sample_size, percentile95)) +
  ggplot2::geom_point(alpha = 0.7, size = 1) +
  ggplot2::geom_smooth(se = FALSE, method = "loess", formula = y ~ x, span = 0.1) +
  ggplot2::labs(
    x = "Sample size (n)",
    y = "95th percentile of index"
  )

Return value

ppi_samplesize_effect() returns a tibble:

  • sample_size: the sample size for that block of simulations
  • percentile95: the empirical 95th percentile of the index across the n_sim simulations at that size.