Topology-Preserving Models

Topology-preserving models learn the structure of unlabeled data by positioning prototypes such that neighborhood relationships are preserved.

Neural Gas

Neural Gas distributes prototypes to match data density using rank-based neighborhood cooperation. No predefined grid structure is needed.

For each sample, prototypes are ranked by distance. The cooperation weight for rank \(k\) is \(h(k) = \exp(-k / \lambda)\), where \(\lambda\) controls the neighborhood range.

from prosemble.models import NeuralGas
from prosemble.datasets import load_iris_jax

dataset = load_iris_jax()
X = dataset.input_data

model = NeuralGas(
    n_prototypes=10,
    max_iter=100,
    lr_init=0.5,          # initial learning rate
    lr_final=0.01,        # final learning rate
    lambda_init=5.0,      # initial neighborhood range
    lambda_final=0.01,    # final (narrow) range
    random_seed=42,
)
model.fit(X)

# Assign samples to nearest prototype
labels = model.predict(X)

# Distance to all prototypes
distances = model.transform(X)

print(f"Prototypes: {model.prototypes_.shape}")
print(f"Iterations: {model.n_iter_}")
print(f"Energy: {model.loss_:.4f}")

How the ranking works:

  1. Compute distances from sample \(x\) to all prototypes

  2. Sort prototypes by distance: nearest = rank 0, farthest = rank K-1

  3. Cooperation weight \(h(k) = \exp(-k / \lambda)\)

  4. Update all prototypes: \(w_j \leftarrow w_j + \varepsilon \cdot h(k_j) \cdot (x - w_j)\)

Large \(\lambda\) means many prototypes cooperate (global ordering). Small \(\lambda\) means only nearest prototypes update (local refinement).

Kohonen SOM

Self-Organizing Map arranges prototypes on a fixed 2D grid. Neighborhoods are defined by grid position, not data-space distance.

from prosemble.models import KohonenSOM

model = KohonenSOM(
    grid_height=5,
    grid_width=5,
    sigma_init=2.0,       # initial grid neighborhood width
    sigma_final=0.5,      # final (narrow)
    lr_init=0.5,
    lr_final=0.01,
    max_iter=100,
    random_seed=42,
)
model.fit(X)

labels = model.predict(X)
print(f"Prototypes: {model.prototypes_.shape}")  # (25, n_features)

The key difference from Neural Gas: SOM neighborhoods are defined by fixed grid distance, so the 2D grid topology is preserved in the mapping. This makes SOM ideal for data visualization on a 2D map.

Heskes SOM

Heskes SOM modifies the BMU selection criterion. Instead of choosing the nearest prototype, it selects the unit whose entire neighborhood best represents the sample:

\(c^* = \arg\min_c \sum_k h(k, c) \cdot \|x - w_k\|^2\)

This has a well-defined energy function (unlike standard Kohonen SOM), guaranteeing convergence.

from prosemble.models import HeskesSOM

model = HeskesSOM(
    grid_height=5,
    grid_width=5,
    sigma_init=2.0,
    sigma_final=0.5,
    max_iter=100,
    random_seed=42,
)
model.fit(X)

Growing Neural Gas

Starts with 2 nodes and dynamically adds and removes prototypes during training, adapting model complexity to the data.

from prosemble.models import GrowingNeuralGas

model = GrowingNeuralGas(
    max_nodes=30,
    max_iter=100,
    lr_winner=0.1,
    lr_neighbor=0.01,
    max_age=50,            # remove edges older than this
    insert_interval=100,   # insert new node every N steps
    error_decay=0.995,
    random_seed=42,
)
model.fit(X)

print(f"Final nodes: {model.prototypes_.shape[0]}")  # may be < max_nodes

Node insertion places new prototypes where the representation error is highest, automatically focusing model capacity on complex data regions.

Note

Growing Neural Gas is not JIT-compilable due to its dynamic topology (changing number of nodes and edges). It uses Python loops instead of lax.scan.

Riemannian Neural Gas

Neural Gas extended to Riemannian manifolds. Prototypes live on the manifold and are updated via exponential maps. Supports SO(n), SPD(n), and Grassmannian(n,k).

from prosemble.models import RiemannianNeuralGas
from prosemble.core.manifolds import SO

manifold = SO(3)
model = RiemannianNeuralGas(
    manifold=manifold,
    n_prototypes=5,
    max_iter=50,
    lr_init=0.3,
    lr_final=0.01,
    lambda_final=0.01,
)
model.fit(X)  # X: (n_samples, 9) flattened 3x3 rotation matrices

labels = model.predict(X)
distances = model.transform(X)

Note

Riemannian Neural Gas uses Python loops (not lax.scan) because manifold projection is required after each update step.

For supervised Riemannian models (RiemannianSRNG, RiemannianSMNG, RiemannianSLNG, RiemannianSTNG), see Riemannian Models.

Choosing a Model

Model

Topology

Best For

Neural Gas

Rank-based (adaptive)

General data structure learning

Kohonen SOM

Fixed 2D grid

Visualization on a 2D map

Heskes SOM

Fixed 2D grid

Principled SOM with convergence guarantees

Growing NG

Dynamic edges

Unknown number of clusters