Skip to content

clustering.kt

src/jvmMain/kotlin/afield/math/statistics/clustering.kt · 1766 lines

package and imports — 84 lines, from line 1
@file:Suppress("MemberVisibilityCanBePrivate")
@file:afield.runtime.completion.CompletionCategory("Data")

package afield.math.statistics

import afield.animation.keyframes.h
import afield.animation.keyframes.roulette
import afield.data.collections.alphas
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.context.Why
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.centroid
import afield.geometry.mesh.contains
import afield.geometry.mesh.repeated
import afield.graphics.render.rangeTo
import afield.lang.stacksl.h
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.dot
import afield.math.autodiff.floor
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.round
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.median
import afield.math.linalg.remAssign
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.median
import afield.media.image.threshold
import afield.runtime.execution.end
import afield.runtime.execution.go
import afield.runtime.lifecycle.once
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.study.ui.numbers
import afield.ui.output.h

import afield.audio.synth.*
import afield.data.collections.r
import afield.data.collections.s
import afield.geometry.mesh.bounds
import afield.geometry.spatial.center
import afield.graphics.color.okhsl
import afield.graphics.color.okhslSet
import afield.graphics.render.color
import afield.graphics.render.filled
import afield.graphics.render.fline
import afield.graphics.render.label
import afield.graphics.render.lineTo
import afield.graphics.render.moveTo
import afield.graphics.render.pointSize
import afield.graphics.render.pointed
import afield.graphics.render.stroked
import afield.graphics.render.thickness
import afield.graphics.vulkan.buffers.add
import afield.math.autodiff.maxOf
import afield.math.autodiff.sum
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.set
import afield.math.linalg.vec
import afield.math.linalg.x
import afield.geometry.path.FLine
import smile.clustering.HierarchicalClustering
import smile.clustering.linkage.*
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
import kotlin.random.Random

Clustering for lists of Float, Vec2, Vec3 and Vec4.

Every algorithm here returns either a hierarchy or a centre drawn from the data it summarises: a medoid is one of your own samples, and a median is coordinate-wise robust against an outlier. There is no k-means, whose centroid is neither.

Hierarchical clustering — floatAgglomerative, vec2Agglomerative, vec3Agglomerative, vec4Agglomerative, which wrap Smile's HierarchicalClustering over the seven AgglomerativeLinkages — returns an AgglomerativeClustering holding the merge tree, which you cut into a ClusterLayer with AgglomerativeClustering.partition, AgglomerativeClustering.partitionByHeight or AgglomerativeClustering.layers. The …KMedoids and …KMedians functions are both local implementations in this file, each with D²-weighted seeding and KMedoidsOptions.runs/KMediansOptions.runs restarts keeping the best objective: k-medoids alternates assignment with an exact per-cluster re-medoid, k-medians is Lloyd's algorithm on the L1 metric with a coordinate-wise median. Cuts of the merge tree come back with labels renumbered by first appearance; every result type can be turned into a ClusterLayer.

visualizeVec3ClusterLayers3D, visualizeVec2ClusterLayers3D and visualizeFloatClusterLayers3D — plus the AgglomerativeClustering.visualizeVec3-style extensions that build the layers for you — stack a list of ClusterLayers along one axis and return FLines in a map keyed "cluster-points", "cluster-points-halo", "cluster-centers", "cluster-centers-halo", "cluster-links" and "cluster-guides". ClusterLayerVizOptions controls spacing, sizes and which of those layers are emitted.

enum class AgglomerativeLinkage

How the distance between two clusters is defined, which is the whole content of a hierarchical clustering — the point distances are the same either way.

WARD is the default because it minimises the increase in within-cluster variance at each merge and so tends to produce compact, comparably-sized clusters, which is what a reader of a dendrogram usually means by "cluster". SINGLE merges on the nearest pair and famously chains along dense filaments; COMPLETE merges on the farthest pair and is compact but sensitive to one far-out sample; AVERAGE (UPGMA) and WEIGHTED_AVERAGE (WPGMA) average all pairs, unweighted and weighted by cluster size. CENTROID (UPGMC) and MEDIAN (WPGMC) measure between cluster centres, and are the two that can produce inversions — a merge lower than one it contains — which makes AgglomerativeClustering.partitionByHeight hard to interpret with them; cut by k instead.

enum class AgglomerativeLinkage {
    SINGLE,
    COMPLETE,
    AVERAGE,
    WEIGHTED_AVERAGE,
    CENTROID,
    MEDIAN,
    WARD
}

enum class MedoidDistance

The metric the …KMedoids family assigns and re-medoids with.

MANHATTAN (L1) is the default: it is the metric a medoid is a natural centre for, and it is less moved by one wild coordinate than L2. EUCLIDEAN is there for when the distances have to agree with a geometric picture. A KMedoidsResult.distortion is not comparable across the two.

enum class MedoidDistance {
    EUCLIDEAN,
    MANHATTAN
}

data class KMedoidsOptions

How the …KMedoids functions run.

The per-property text below is what the reference and the completion panel show; the defaults are chosen so that a call with no options is reproducible in shape (six restarts) but not in outcome (no fixed seed) — set randomSeed the moment a picture has to be the same twice.

data class KMedoidsOptions(
    /**
     * Most assign/re-medoid rounds per restart. The loop stops early the first time the medoid set
     * does not move, which is usually well inside ten rounds, so this is a safety bound rather than
     * a setting to tune.
     */
    val maxIterations: Int = 100,
    /**
     * Independent restarts from different D²-weighted seeds; the lowest-distortion one wins.
     *
     * Restarts are how this escapes a local optimum, because the exact re-medoid step never swaps a
     * medoid for a non-member. More restarts cost linearly and help most when `k` is large.
     */
    val runs: Int = 6,
    /** The metric, used both for assignment and for choosing each cluster's medoid. */
    val distance: MedoidDistance = MedoidDistance.MANHATTAN,
    /**
     * Fixes the seeding, so the whole thing is reproducible. `null` uses [Random.Default] and
     * successive calls may differ.
     */
    val randomSeed: Long? = null
)

data class KMediansOptions

How the …KMedians functions run.

Lloyd's algorithm on the L1 metric: assign, take each cluster's coordinate-wise median, repeat. Unlike k-medoids the loop needs a numeric stopping rule, which is what tolerance is.

data class KMediansOptions(
    /** Most assign/re-centre rounds per restart, as a safety bound. */
    val maxIterations: Int = 100,
    /**
     * Convergence threshold, applied to *both* the change in objective and the total L1 movement of
     * the centres — the round has to be quiet on both counts to stop. Must be finite and
     * non-negative.
     */
    val tolerance: Double = 1e-4,
    /** Independent restarts from different D²-weighted seeds; the lowest-objective one wins. */
    val runs: Int = 6,
    /** Fixes the seeding for reproducibility; `null` uses [Random.Default]. */
    val randomSeed: Long? = null
)

class ClusterLayer

One clustering of one dataset: a label per sample, and a name to draw it under.

This is the common currency of the file — every result type converts to one, and the visualize…ClusterLayers3D functions take a list of them so that several cuts of the same data can be stacked and compared in one picture. Immutable: the labels are copied in.

Labels must be non-empty and non-negative, and are conventionally dense and renumbered by first appearance (which is what AgglomerativeClustering.partition hands you).

class ClusterLayer(
    /** What this cut is called — `"k=4"`, `"h=0.3"`, `"kmedoids(k=3)"` by default. */
    val name: String,
    labels: IntArray
) {

val labels: IntArray = labels.copyOf()

One cluster index per sample, in input order. A defensive copy: mutating it changes nothing.

init

    init {
        require(this.labels.isNotEmpty()) { "ClusterLayer.labels must not be empty" }
        this.labels.forEachIndexed { i, label ->
            require(label >= 0) { "ClusterLayer.labels[$i] must be >= 0, found $label" }
        }
    }

val sampleCount: Int

How many samples were labelled.

    val sampleCount: Int
        get() = labels.size

val clusterCount: Int

Highest label plus one — so an unused index in the middle still counts, and this is the length a palette needs rather than the number of non-empty clusters.

    val clusterCount: Int
        get() = (labels.maxOrNull() ?: -1) + 1

fun indices(cluster: Int): IntArray

Which samples landed in cluster, as indices into the original list. Empty for a cluster nothing was assigned to.

    fun indices(cluster: Int): IntArray {
        require(cluster >= 0) { "cluster must be >= 0, found $cluster" }
        val out = ArrayList<Int>()
        for (i in labels.indices) if (labels[i] == cluster) out.add(i)
        return out.toIntArray()
    }
}

class AgglomerativeClustering internal constructor

A whole merge hierarchy, not one clustering — the answer the …Agglomerative functions return.

Hierarchical clustering does not need to be told k, which is its advantage: it merges until everything is one cluster and records the order and the heights, and then you choose where to cut. Cut by count with partition, by dissimilarity with partitionByHeight, or take several cuts at once with layers / defaultLayers and stack them into one picture.

The Smile model is copied on construction and every accessor copies again, so nothing a caller does can invalidate the tree.

class AgglomerativeClustering internal constructor(
    /** Which cluster-distance rule built this hierarchy. See [AgglomerativeLinkage]. */
    val linkage: AgglomerativeLinkage,
    rawModel: HierarchicalClustering
) {
    private val model = HierarchicalClustering(
        rawModel.tree().map { it.copyOf() }.toTypedArray(),
        rawModel.height().copyOf()
    )

val sampleCount: Int

How many samples were clustered — one more than the number of merges, since each merge removes a cluster.

    val sampleCount: Int
        get() = model.tree().size + 1

val tree: Array<IntArray>

The merge tree, one [left, right] pair per merge in merge order.

Indices below sampleCount are original samples; sampleCount + i is the cluster formed by merge i. This is Smile's own encoding, handed back as a fresh copy.

    val tree: Array<IntArray>
        get() = model.tree().map { it.copyOf() }.toTypedArray()

val heights: DoubleArray

The dissimilarity at which each merge happened, parallel to tree — the y-axis of a dendrogram and the scale partitionByHeight cuts on.

    val heights: DoubleArray
        get() = model.height().copyOf()

fun partition(k: Int, name: String = "k=$k"): ClusterLayer

Cut the hierarchy into exactly k clusters. k must be within 1..sampleCount.

Labels come back renumbered by first appearance, so cutting the same tree at different k gives comparable colouring rather than Smile's arbitrary internal ids.

    fun partition(k: Int, name: String = "k=$k"): ClusterLayer {
        require(k in 1..sampleCount) {
            "AgglomerativeClustering.partition: k must be in [1, $sampleCount], found $k"
        }
        val labels = if (k == 1) {
            IntArray(sampleCount)
        } else {
            model.partition(k)
        }
        return ClusterLayer(name, canonicalizeLabels(labels))
    }

fun partitionByHeight(height: Double, name: String = "h=$height"): ClusterLayer

Cut every branch taller than height, letting the data decide how many clusters that is.

The scale is heights, which is in the linkage's own units and is not comparable across linkages or across datasets. Use partition when you want a known number of clusters, and avoid this with CENTROID or MEDIAN linkage, whose heights can be non-monotone.

    fun partitionByHeight(height: Double, name: String = "h=$height"): ClusterLayer {
        require(height.isFinite()) {
            "AgglomerativeClustering.partitionByHeight: height must be finite, found $height"
        }
        val labels = model.partition(height)
        return ClusterLayer(name, canonicalizeLabels(labels))
    }

fun layers(kValues: Iterable<Int>): List<ClusterLayer>

One ClusterLayer per requested k, ready to stack in a visualize… call.

Forgiving: a k outside 1..sampleCount is skipped rather than throwing, and repeats are dropped, so a generous range like 2..20 is safe on a small point cloud.

    fun layers(kValues: Iterable<Int>): List<ClusterLayer> {
        val used = LinkedHashSet<Int>()
        val out = ArrayList<ClusterLayer>()
        for (k in kValues) {
            if (k !in 1..sampleCount) continue
            if (used.add(k)) out += partition(k, "k=$k")
        }
        return out
    }

fun defaultLayers(maxK: Int = 6): List<ClusterLayer>

Cuts at 2..maxK, clamped to what the data can support — a sensible stack with no arguments.

    fun defaultLayers(maxK: Int = 6): List<ClusterLayer> {
        val capped = max(2, min(maxK, sampleCount))
        return layers(2..capped)
    }
}

data class KMedoidsResult<T>

What a …KMedoids call found: the labels, the medoids themselves, and enough to judge whether to believe them.

Read converged and distortion before reading anything into the clusters. T is the caller's own point type, so the medoids come back as Float, Vec2, Vec3 or Vec4 rather than as rows.

data class KMedoidsResult<T>(
    /** One cluster index per input sample, in input order, each in `0 until k`. */
    val labels: IntArray,
    /**
     * The chosen medoids themselves — always **actual input samples**, which is the whole point of
     * k-medoids over k-means: `medoids[c]` is `input[medoidIndices[c]]`.
     */
    val medoids: List<T>,
    /** Where each medoid sits in the input list, parallel to [medoids]. */
    val medoidIndices: IntArray,
    /**
     * The **sum** over samples of the distance to the assigned medoid, in
     * [KMedoidsOptions.distance]'s metric.
     *
     * Lower is better, and it is the quantity the restarts minimise. It is not comparable across
     * different `k` (more clusters always cost less) or across metrics, so it cannot be used to
     * choose `k`.
     */
    val distortion: Double,
    /** Assign/re-medoid rounds taken by the winning restart. */
    val iterations: Int,
    /**
     * `true` if the winning restart stopped because the medoid set repeated, rather than because it
     * hit [KMedoidsOptions.maxIterations]. A `false` here means the numbers below are a snapshot of
     * an unfinished run.
     */
    val converged: Boolean,
    /** How many samples each cluster holds. A zero is possible: an empty cluster keeps its medoid. */
    val clusterSizes: IntArray,
    /** The options this run used, carried along so a result is self-describing. */
    val options: KMedoidsOptions
) {

init

    init {
        require(labels.isNotEmpty()) { "KMedoidsResult.labels must not be empty" }
        require(medoids.isNotEmpty()) { "KMedoidsResult.medoids must not be empty" }
        require(medoidIndices.size == medoids.size) {
            "KMedoidsResult: medoidIndices.size (${medoidIndices.size}) must match medoids.size (${medoids.size})"
        }
    }

val k: Int

How many clusters this result actually has.

    val k: Int
        get() = medoids.size

fun asLayer(name: String = "kmedoids(k=$k)"): ClusterLayer

This result as a ClusterLayer, so it can be stacked and drawn beside other cuts.

    fun asLayer(name: String = "kmedoids(k=$k)"): ClusterLayer =
        ClusterLayer(name, labels)
}

data class KMediansResult<T>

What a …KMedians call found.

The difference from KMedoidsResult that matters: centers are computed coordinate-wise medians and need not be any of your samples, so there is no index to look them up by. They are still robust — a median moves with the ranks, not with the outliers — which is the reason to reach for this over a mean-based centroid.

data class KMediansResult<T>(
    /** One cluster index per input sample, in input order, each in `0 until k`. */
    val labels: IntArray,
    /** The coordinate-wise median of each cluster. Not necessarily an input sample. */
    val centers: List<T>,
    /**
     * Sum of L1 distances from every sample to its assigned centre. Lower is better, and it is what
     * the restarts minimise; as with [KMedoidsResult.distortion] it cannot be used to choose `k`.
     */
    val objective: Double,
    /** Assign/re-centre rounds taken by the winning restart. */
    val iterations: Int,
    /**
     * `true` if the winning restart stopped because both the objective and the centres went quiet
     * within [KMediansOptions.tolerance], rather than on [KMediansOptions.maxIterations].
     */
    val converged: Boolean,
    /** How many samples each cluster holds. An emptied cluster is re-seeded from the worst-fit sample. */
    val clusterSizes: IntArray,
    /** The options this run used. */
    val options: KMediansOptions
) {

init

    init {
        require(labels.isNotEmpty()) { "KMediansResult.labels must not be empty" }
        require(centers.isNotEmpty()) { "KMediansResult.centers must not be empty" }
    }

val k: Int

How many clusters this result actually has.

    val k: Int
        get() = centers.size

fun asLayer(name: String = "kmedians(k=$k)"): ClusterLayer

This result as a ClusterLayer, so it can be stacked and drawn beside other cuts.

    fun asLayer(name: String = "kmedians(k=$k)"): ClusterLayer =
        ClusterLayer(name, labels)
}

data class ClusterLayerVizOptions

How a stack of ClusterLayers is laid out and drawn.

Sizes are in the units the FLine renderer uses for point size and thickness, alphas are 0..1, and the three draw… flags decide which keys appear in the returned map at all — turning one off is cheaper than drawing it and hiding it.

data class ClusterLayerVizOptions(
    /** Distance between consecutive layers along the stacking axis. Zero superimposes them. */
    val layerSpacing: Float = 0.7f,
    /**
     * Extra separation between clusters *within* a layer — a fan-out that makes membership visible when
     * colour alone is not enough. Zero keeps every point at its own coordinates.
     */
    val clusterOffset: Float = 0.0f,
    /** Size of a sample's core dot. */
    val pointSize: Double = 0.1,
    /** Size of the soft halo drawn behind each sample, which is what makes dense regions read as bright. */
    val pointHaloSize: Double = 0.3,
    /** Size of a cluster centre's core dot — slightly larger than a sample's, so centres read as centres. */
    val centerPointSize: Double = 0.14,
    /** Size of the glow behind a cluster centre. Much larger than the others. */
    val centerHaloSize: Double = 22.0,
    /** Stroke thickness of the sample-to-centre links. */
    val linkThickness: Double = 0.85,
    /** Opacity of a sample's core dot. */
    val pointAlpha: Float = 0.92f,
    /** Opacity of the halos. Low, because they accumulate: overlap is the signal. */
    val haloAlpha: Float = 0.14f,
    /** Opacity of a cluster centre's core dot. */
    val centerAlpha: Float = 0.98f,
    /** Opacity of the links, low enough that a thousand of them still show shape rather than ink. */
    val linkAlpha: Float = 0.16f,
    /** Emit `cluster-links`: one segment from every sample to its cluster centre. */
    val drawLinks: Boolean = true,
    /** Emit `cluster-centers` and `cluster-centers-halo`. */
    val drawCenters: Boolean = true,
    /**
     * Emit `cluster-guides`: a rectangle around each layer, so the stacking is legible. Ignored by the
     * `Vec3` visualization, which has no plane to draw one on.
     */
    val drawGuides: Boolean = true
)

fun floatAgglomerative

Hierarchical clustering of one-dimensional values: build the whole merge tree now, choose where to cut later.

Needs at least two samples, and every coordinate must be finite. Returns an AgglomerativeClustering, not a labelling — call partition, partitionByHeight or layers on it. See AgglomerativeLinkage for what the default WARD assumes.

fun floatAgglomerative(
    values: List<Float>,
    linkage: AgglomerativeLinkage = AgglomerativeLinkage.WARD
): AgglomerativeClustering {
    val rows = floatRows(values, "floatAgglomerative")
    return agglomerative(rows, linkage, "floatAgglomerative")
}

fun vec2Agglomerative

Hierarchical clustering of 2-D points: build the whole merge tree now, choose where to cut later.

Needs at least two samples, and every coordinate must be finite. Returns an AgglomerativeClustering, not a labelling — call partition, partitionByHeight or layers on it. See AgglomerativeLinkage for what the default WARD assumes.

fun vec2Agglomerative(
    points: List<Vec2>,
    linkage: AgglomerativeLinkage = AgglomerativeLinkage.WARD
): AgglomerativeClustering {
    val rows = vec2Rows(points, "vec2Agglomerative")
    return agglomerative(rows, linkage, "vec2Agglomerative")
}

fun vec3Agglomerative

Hierarchical clustering of 3-D points: build the whole merge tree now, choose where to cut later.

Needs at least two samples, and every coordinate must be finite. Returns an AgglomerativeClustering, not a labelling — call partition, partitionByHeight or layers on it. See AgglomerativeLinkage for what the default WARD assumes.

fun vec3Agglomerative(
    points: List<Vec3>,
    linkage: AgglomerativeLinkage = AgglomerativeLinkage.WARD
): AgglomerativeClustering {
    val rows = vec3Rows(points, "vec3Agglomerative")
    return agglomerative(rows, linkage, "vec3Agglomerative")
}

fun vec4Agglomerative

Hierarchical clustering of 4-D points: build the whole merge tree now, choose where to cut later.

Needs at least two samples, and every coordinate must be finite. Returns an AgglomerativeClustering, not a labelling — call partition, partitionByHeight or layers on it. See AgglomerativeLinkage for what the default WARD assumes.

fun vec4Agglomerative(
    points: List<Vec4>,
    linkage: AgglomerativeLinkage = AgglomerativeLinkage.WARD
): AgglomerativeClustering {
    val rows = vec4Rows(points, "vec4Agglomerative")
    return agglomerative(rows, linkage, "vec4Agglomerative")
}

fun floatKMedoids

Partition one-dimensional values into k clusters, each summarised by one of your own samples.

Reach for this over …KMedians when the centre has to be a real datum — a representative frame, an exemplar colour, a point you can go and look at. k must be strictly less than the sample count (the algorithm needs a non-medoid to assign), which is one more restriction than …KMedians has.

Cost is quadratic in the sample count: it builds and holds one full distance matrix, shared across the restarts. Fine for thousands of points, not for a million.

fun floatKMedoids(
    values: List<Float>,
    k: Int,
    options: KMedoidsOptions = KMedoidsOptions()
): KMedoidsResult<Float> {
    val rows = floatRows(values, "floatKMedoids")
    val core = kMedoids(rows, k, options, "floatKMedoids")
    val medoids = core.medoidIndices.map { values[it] }
    return KMedoidsResult(
        labels = core.labels,
        medoids = medoids,
        medoidIndices = core.medoidIndices,
        distortion = core.distortion,
        iterations = core.iterations,
        converged = core.converged,
        clusterSizes = core.clusterSizes,
        options = options
    )
}

fun vec2KMedoids

Partition 2-D points into k clusters, each summarised by one of your own samples.

Reach for this over …KMedians when the centre has to be a real datum — a representative frame, an exemplar colour, a point you can go and look at. k must be strictly less than the sample count (the algorithm needs a non-medoid to assign), which is one more restriction than …KMedians has.

Cost is quadratic in the sample count: it builds and holds one full distance matrix, shared across the restarts. Fine for thousands of points, not for a million.

fun vec2KMedoids(
    points: List<Vec2>,
    k: Int,
    options: KMedoidsOptions = KMedoidsOptions()
): KMedoidsResult<Vec2> {
    val rows = vec2Rows(points, "vec2KMedoids")
    val core = kMedoids(rows, k, options, "vec2KMedoids")
    val medoids = core.medoidIndices.map { Vec2(points[it]) }
    return KMedoidsResult(
        labels = core.labels,
        medoids = medoids,
        medoidIndices = core.medoidIndices,
        distortion = core.distortion,
        iterations = core.iterations,
        converged = core.converged,
        clusterSizes = core.clusterSizes,
        options = options
    )
}

fun vec3KMedoids

Partition 3-D points into k clusters, each summarised by one of your own samples.

Reach for this over …KMedians when the centre has to be a real datum — a representative frame, an exemplar colour, a point you can go and look at. k must be strictly less than the sample count (the algorithm needs a non-medoid to assign), which is one more restriction than …KMedians has.

Cost is quadratic in the sample count: it builds and holds one full distance matrix, shared across the restarts. Fine for thousands of points, not for a million.

fun vec3KMedoids(
    points: List<Vec3>,
    k: Int,
    options: KMedoidsOptions = KMedoidsOptions()
): KMedoidsResult<Vec3> {
    val rows = vec3Rows(points, "vec3KMedoids")
    val core = kMedoids(rows, k, options, "vec3KMedoids")
    val medoids = core.medoidIndices.map { Vec3(points[it]) }
    return KMedoidsResult(
        labels = core.labels,
        medoids = medoids,
        medoidIndices = core.medoidIndices,
        distortion = core.distortion,
        iterations = core.iterations,
        converged = core.converged,
        clusterSizes = core.clusterSizes,
        options = options
    )
}

fun vec4KMedoids

Partition 4-D points into k clusters, each summarised by one of your own samples.

Reach for this over …KMedians when the centre has to be a real datum — a representative frame, an exemplar colour, a point you can go and look at. k must be strictly less than the sample count (the algorithm needs a non-medoid to assign), which is one more restriction than …KMedians has.

Cost is quadratic in the sample count: it builds and holds one full distance matrix, shared across the restarts. Fine for thousands of points, not for a million.

fun vec4KMedoids(
    points: List<Vec4>,
    k: Int,
    options: KMedoidsOptions = KMedoidsOptions()
): KMedoidsResult<Vec4> {
    val rows = vec4Rows(points, "vec4KMedoids")
    val core = kMedoids(rows, k, options, "vec4KMedoids")
    val medoids = core.medoidIndices.map { Vec4(points[it]) }
    return KMedoidsResult(
        labels = core.labels,
        medoids = medoids,
        medoidIndices = core.medoidIndices,
        distortion = core.distortion,
        iterations = core.iterations,
        converged = core.converged,
        clusterSizes = core.clusterSizes,
        options = options
    )
}

fun floatKMedians

Partition one-dimensional values into k clusters around coordinate-wise medians (Lloyd's algorithm on L1).

The robust alternative to a mean-based centroid, and the cheaper alternative to …KMedoids: linear in the sample count per round rather than quadratic, and k may go all the way up to the sample count. The price is that a centre is a computed point rather than one of your samples. An emptied cluster is re-seeded from the worst-fit sample, so k clusters are always reported.

fun floatKMedians(
    values: List<Float>,
    k: Int,
    options: KMediansOptions = KMediansOptions()
): KMediansResult<Float> {
    val rows = floatRows(values, "floatKMedians")
    val core = kMedians(rows, k, options, "floatKMedians")
    val centers = core.centers.map { it[0].toFloat() }
    return KMediansResult(
        labels = core.labels,
        centers = centers,
        objective = core.objective,
        iterations = core.iterations,
        converged = core.converged,
        clusterSizes = core.clusterSizes,
        options = options
    )
}

fun vec2KMedians

Partition 2-D points into k clusters around coordinate-wise medians (Lloyd's algorithm on L1).

The robust alternative to a mean-based centroid, and the cheaper alternative to …KMedoids: linear in the sample count per round rather than quadratic, and k may go all the way up to the sample count. The price is that a centre is a computed point rather than one of your samples. An emptied cluster is re-seeded from the worst-fit sample, so k clusters are always reported.

fun vec2KMedians(
    points: List<Vec2>,
    k: Int,
    options: KMediansOptions = KMediansOptions()
): KMediansResult<Vec2> {
    val rows = vec2Rows(points, "vec2KMedians")
    val core = kMedians(rows, k, options, "vec2KMedians")
    val centers = core.centers.map { v -> vec(v[0], v[1]) }
    return KMediansResult(
        labels = core.labels,
        centers = centers,
        objective = core.objective,
        iterations = core.iterations,
        converged = core.converged,
        clusterSizes = core.clusterSizes,
        options = options
    )
}

fun vec3KMedians

Partition 3-D points into k clusters around coordinate-wise medians (Lloyd's algorithm on L1).

The robust alternative to a mean-based centroid, and the cheaper alternative to …KMedoids: linear in the sample count per round rather than quadratic, and k may go all the way up to the sample count. The price is that a centre is a computed point rather than one of your samples. An emptied cluster is re-seeded from the worst-fit sample, so k clusters are always reported.

fun vec3KMedians(
    points: List<Vec3>,
    k: Int,
    options: KMediansOptions = KMediansOptions()
): KMediansResult<Vec3> {
    val rows = vec3Rows(points, "vec3KMedians")
    val core = kMedians(rows, k, options, "vec3KMedians")
    val centers = core.centers.map { v -> vec(v[0], v[1], v[2]) }
    return KMediansResult(
        labels = core.labels,
        centers = centers,
        objective = core.objective,
        iterations = core.iterations,
        converged = core.converged,
        clusterSizes = core.clusterSizes,
        options = options
    )
}

fun vec4KMedians

Partition 4-D points into k clusters around coordinate-wise medians (Lloyd's algorithm on L1).

The robust alternative to a mean-based centroid, and the cheaper alternative to …KMedoids: linear in the sample count per round rather than quadratic, and k may go all the way up to the sample count. The price is that a centre is a computed point rather than one of your samples. An emptied cluster is re-seeded from the worst-fit sample, so k clusters are always reported.

fun vec4KMedians(
    points: List<Vec4>,
    k: Int,
    options: KMediansOptions = KMediansOptions()
): KMediansResult<Vec4> {
    val rows = vec4Rows(points, "vec4KMedians")
    val core = kMedians(rows, k, options, "vec4KMedians")
    val centers = core.centers.map { v -> vec(v[0], v[1], v[2], v[3]) }
    return KMediansResult(
        labels = core.labels,
        centers = centers,
        objective = core.objective,
        iterations = core.iterations,
        converged = core.converged,
        clusterSizes = core.clusterSizes,
        options = options
    )
}

fun visualizeVec3ClusterLayers3D

Draw a stack of clusterings of the same 3-D point cloud as FLines.

Layers are separated radially — each layer pushes its points a little further out from the cloud's own centre — rather than along an axis, because a 3-D cloud has no free axis left. Point colour blends the sample's position in the bounding box with its cluster's colour, and the blend moves towards cluster colour in later layers, so structure emerging as k grows is visible as a shift from rainbow to blocks.

Returns a map keyed "cluster-points", "cluster-points-halo", "cluster-centers", "cluster-centers-halo" and "cluster-links" — one entry per non-empty layer, ready to assign into a box's lines. No guides here; see ClusterLayerVizOptions.drawGuides.

Every layer must label exactly as many samples as there are points.

body — 73 lines, from line 801
fun visualizeVec3ClusterLayers3D(
    points: List<Vec3>,
    layers: List<ClusterLayer>,
    options: ClusterLayerVizOptions = ClusterLayerVizOptions()
): Map<String, List<FLine>> {
    require(points.isNotEmpty()) { "visualizeVec3ClusterLayers3D: points must not be empty" }
    val checkedLayers = validateLayers(points.size, layers, "visualizeVec3ClusterLayers3D")
    val center = points.center()
    val bounds = bounds3(points)

    val pointHalo = pointCloudLine(options.pointHaloSize)
    val pointCore = pointCloudLine(options.pointSize)
    val centerHalo = pointCloudLine(options.centerHaloSize)
    val centerCore = pointCloudLine(options.centerPointSize)
    val links = edgeLine(options.linkThickness)

    val layerCount = checkedLayers.size
    checkedLayers.forEachIndexed { li, layer ->
        val k = max(1, layer.clusterCount)
        val palette = okhslSet(k, saturation = 0.78, lightness = 0.60, alpha = 1f, startHue = li * 0.11f)
        val tLayer = if (layerCount <= 1) 1f else li.toFloat() / (layerCount - 1).toFloat()
        val positioned = ArrayList<Vec3>(points.size)

        for (i in points.indices) {
            val p = points[i]
            val radial = Vec3(p).sub(center)
            if (radial.lengthSquared() < 1e-9f) {
                radial.set(
                    ((i * 0.7548777f + li * 0.173f) % 1f) - 0.5f,
                    ((i * 0.5698403f + li * 0.113f) % 1f) - 0.5f,
                    ((i * 0.4389231f + li * 0.071f) % 1f) - 0.5f
                )
            }
            radial.normalize().mul(li * options.layerSpacing * 0.08f)
            positioned += Vec3(p).add(radial)
        }

        val centers = clusterCentroids(positioned, layer.labels)
        for (i in points.indices) {
            val cluster = layer.labels[i]
            val clusterColor = palette[cluster % palette.size]
            val coordColor = coordinateColor(points[i], bounds, alpha = 1f)
            val color = mixColor(coordColor, clusterColor, 0.35f + 0.55f * tLayer, options.pointAlpha)
            val glow = withAlpha(color, options.haloAlpha)
            pointHalo.addPoint(positioned[i], glow)
            pointCore.addPoint(positioned[i], color)

            if (options.drawLinks) {
                val c = centers[cluster]
                if (c != null) links.addSegment(positioned[i], c, withAlpha(clusterColor, options.linkAlpha))
            }
        }

        if (options.drawCenters) {
            for (cluster in centers.indices) {
                val c = centers[cluster] ?: continue
                val clusterColor = palette[cluster % palette.size]
                val bright = mixColor(clusterColor, vec(1f, 1f, 1f, 1f), 0.22f, options.centerAlpha)
                centerHalo.addPoint(c, withAlpha(clusterColor, options.haloAlpha * 1.25f))
                centerCore.addPoint(c, bright)
            }
        }
    }

    return clusteredLineMap(
        pointHalo = pointHalo,
        pointCore = pointCore,
        centerHalo = centerHalo,
        centerCore = centerCore,
        links = links,
        guides = null
    )
}

fun visualizeVec2ClusterLayers3D

Draw a stack of clusterings of the same 2-D point cloud, one layer per z-plane.

This is the arrangement to reach for when comparing cuts: the data keeps its own x and y, and z carries "which clustering", so a point's movement between layers is only ever its cluster changing. ClusterLayerVizOptions.clusterOffset additionally spreads clusters in z within a layer, and a faint rectangle per layer ("cluster-guides") marks the shared plane.

Same map keys as visualizeVec3ClusterLayers3D, plus "cluster-guides".

body — 79 lines, from line 885
fun visualizeVec2ClusterLayers3D(
    points: List<Vec2>,
    layers: List<ClusterLayer>,
    options: ClusterLayerVizOptions = ClusterLayerVizOptions()
): Map<String, List<FLine>> {
    require(points.isNotEmpty()) { "visualizeVec2ClusterLayers3D: points must not be empty" }
    val checkedLayers = validateLayers(points.size, layers, "visualizeVec2ClusterLayers3D")

    val pointHalo = pointCloudLine(options.pointHaloSize)
    val pointCore = pointCloudLine(options.pointSize)
    val centerHalo = pointCloudLine(options.centerHaloSize)
    val centerCore = pointCloudLine(options.centerPointSize)
    val links = edgeLine(options.linkThickness)
    val guides = if (options.drawGuides) guideLine() else null

    var minX = Float.POSITIVE_INFINITY
    var maxX = Float.NEGATIVE_INFINITY
    var minY = Float.POSITIVE_INFINITY
    var maxY = Float.NEGATIVE_INFINITY
    points.forEach { p ->
        if (p.x < minX) minX = p.x
        if (p.x > maxX) maxX = p.x
        if (p.y < minY) minY = p.y
        if (p.y > maxY) maxY = p.y
    }

    checkedLayers.forEachIndexed { li, layer ->
        val k = max(1, layer.clusterCount)
        val palette = okhslSet(k, saturation = 0.76, lightness = 0.63, alpha = 1f, startHue = li * 0.09f)
        val zBase = li * options.layerSpacing
        val positioned = ArrayList<Vec3>(points.size)

        for (i in points.indices) {
            val p = points[i]
            val cluster = layer.labels[i]
            val zOffset = if (k <= 1) 0f else (cluster - (k - 1) * 0.5f) * options.clusterOffset
            positioned += vec(p.x, p.y, zBase + zOffset)
        }

        val centers = clusterCentroids(positioned, layer.labels)
        for (i in points.indices) {
            val cluster = layer.labels[i]
            val clusterColor = palette[cluster % palette.size]
            val color = withAlpha(clusterColor, options.pointAlpha)
            val glow = withAlpha(clusterColor, options.haloAlpha)
            pointHalo.addPoint(positioned[i], glow)
            pointCore.addPoint(positioned[i], color)

            if (options.drawLinks) {
                val c = centers[cluster]
                if (c != null) links.addSegment(positioned[i], c, withAlpha(clusterColor, options.linkAlpha))
            }
        }

        if (options.drawCenters) {
            for (cluster in centers.indices) {
                val c = centers[cluster] ?: continue
                val clusterColor = palette[cluster % palette.size]
                val bright = mixColor(clusterColor, vec(1f, 1f, 1f, 1f), 0.2f, options.centerAlpha)
                centerHalo.addPoint(c, withAlpha(clusterColor, options.haloAlpha * 1.2f))
                centerCore.addPoint(c, bright)
            }
        }

        if (options.drawGuides) {
            val guideColor = withAlpha(okhsl(0.62f + li * 0.03f, 0.16f, 0.67f, 1f), 0.12f)
            guides?.addRect(minX, minY, maxX, maxY, zBase, guideColor)
        }
    }

    return clusteredLineMap(
        pointHalo = pointHalo,
        pointCore = pointCore,
        centerHalo = centerHalo,
        centerCore = centerCore,
        links = links,
        guides = guides
    )
}

fun visualizeFloatClusterLayers3D

Draw a stack of clusterings of the same one-dimensional data.

The value itself is x, cluster membership becomes a band in y, and the layer is z — so a one-dimensional dataset gets a picture at all, and the bands make it obvious when a cut splits a run of near-equal values. Points are jittered in y by their rank so coincident values do not hide each other. Every value must be finite.

Same map keys as visualizeVec2ClusterLayers3D.

body — 78 lines, from line 975
fun visualizeFloatClusterLayers3D(
    values: List<Float>,
    layers: List<ClusterLayer>,
    options: ClusterLayerVizOptions = ClusterLayerVizOptions()
): Map<String, List<FLine>> {
    require(values.isNotEmpty()) { "visualizeFloatClusterLayers3D: values must not be empty" }
    values.forEachIndexed { i, v ->
        require(v.isFinite()) { "visualizeFloatClusterLayers3D: values[$i] must be finite, found $v" }
    }

    val checkedLayers = validateLayers(values.size, layers, "visualizeFloatClusterLayers3D")

    val pointHalo = pointCloudLine(options.pointHaloSize)
    val pointCore = pointCloudLine(options.pointSize)
    val centerHalo = pointCloudLine(options.centerHaloSize)
    val centerCore = pointCloudLine(options.centerPointSize)
    val links = edgeLine(options.linkThickness)
    val guides = if (options.drawGuides) guideLine() else null

    val sorted = values.indices.sortedBy { values[it] }
    val rank = IntArray(values.size)
    for (i in sorted.indices) rank[sorted[i]] = i

    val minX = values.minOrNull() ?: 0f
    val maxX = values.maxOrNull() ?: 1f
    val ySpread = if (checkedLayers.maxOf { it.clusterCount } <= 1) 0.18f else options.clusterOffset * 1.4f

    checkedLayers.forEachIndexed { li, layer ->
        val k = max(1, layer.clusterCount)
        val palette = okhslSet(k, saturation = 0.75, lightness = 0.62, alpha = 1f, startHue = li * 0.08f)
        val zBase = li * options.layerSpacing
        val positioned = ArrayList<Vec3>(values.size)

        for (i in values.indices) {
            val cluster = layer.labels[i]
            val clusterBand = if (k <= 1) 0f else (cluster - (k - 1) * 0.5f) * ySpread
            val rankJitter = if (values.size <= 1) 0f else ((rank[i] / (values.size - 1f)) - 0.5f) * 0.06f
            positioned += vec(values[i], clusterBand + rankJitter, zBase)
        }

        val centers = clusterCentroids(positioned, layer.labels)
        for (i in values.indices) {
            val cluster = layer.labels[i]
            val clusterColor = palette[cluster % palette.size]
            val color = withAlpha(clusterColor, options.pointAlpha)
            pointHalo.addPoint(positioned[i], withAlpha(clusterColor, options.haloAlpha))
            pointCore.addPoint(positioned[i], color)

            if (options.drawLinks) {
                val c = centers[cluster]
                if (c != null) links.addSegment(positioned[i], c, withAlpha(clusterColor, options.linkAlpha))
            }
        }

        if (options.drawCenters) {
            for (cluster in centers.indices) {
                val c = centers[cluster] ?: continue
                val clusterColor = palette[cluster % palette.size]
                centerHalo.addPoint(c, withAlpha(clusterColor, options.haloAlpha * 1.25f))
                centerCore.addPoint(c, mixColor(clusterColor, vec(1f, 1f, 1f, 1f), 0.24f, options.centerAlpha))
            }
        }

        if (options.drawGuides) {
            val guideColor = withAlpha(okhsl(0.58f + li * 0.02f, 0.14f, 0.68f, 1f), 0.12f)
            guides?.addRect(minX, -ySpread * (k + 1), maxX, ySpread * (k + 1), zBase, guideColor)
        }
    }

    return clusteredLineMap(
        pointHalo = pointHalo,
        pointCore = pointCore,
        centerHalo = centerHalo,
        centerCore = centerCore,
        links = links,
        guides = guides
    )
}

fun AgglomerativeClustering.visualizeVec3

Cut this hierarchy at each of kValues and draw the stack in one call — the two-step layers + visualizeVec3ClusterLayers3D with the range filtered to what the data supports.

fun AgglomerativeClustering.visualizeVec3(
    points: List<Vec3>,
    kValues: IntProgression = 2..6,
    options: ClusterLayerVizOptions = ClusterLayerVizOptions()
): Map<String, List<FLine>> {
    val ks = kValues.filter { it in 1..sampleCount }
    return visualizeVec3ClusterLayers3D(points, layers(ks), options)
}

fun AgglomerativeClustering.visualizeVec2

Cut this hierarchy at each of kValues and draw the stack of 2-D layers in one call.

fun AgglomerativeClustering.visualizeVec2(
    points: List<Vec2>,
    kValues: IntProgression = 2..6,
    options: ClusterLayerVizOptions = ClusterLayerVizOptions()
): Map<String, List<FLine>> {
    val ks = kValues.filter { it in 1..sampleCount }
    return visualizeVec2ClusterLayers3D(points, layers(ks), options)
}

fun AgglomerativeClustering.visualizeFloat

Cut this hierarchy at each of kValues and draw the stack of 1-D layers in one call.

fun AgglomerativeClustering.visualizeFloat(
    values: List<Float>,
    kValues: IntProgression = 2..6,
    options: ClusterLayerVizOptions = ClusterLayerVizOptions()
): Map<String, List<FLine>> {
    val ks = kValues.filter { it in 1..sampleCount }
    return visualizeFloatClusterLayers3D(values, layers(ks), options)
}

private fun agglomerative(
    rows: Array<DoubleArray>,
    linkage: AgglomerativeLinkage,
    fn: String
): AgglomerativeClustering {
    validateRows(rows, fn, minSamples = 2)
    val linkageModel = when (linkage) {
        AgglomerativeLinkage.SINGLE -> SingleLinkage.of(rows)
        AgglomerativeLinkage.COMPLETE -> CompleteLinkage.of(rows)
        AgglomerativeLinkage.AVERAGE -> UPGMALinkage.of(rows)
        AgglomerativeLinkage.WEIGHTED_AVERAGE -> WPGMALinkage.of(rows)
        AgglomerativeLinkage.CENTROID -> UPGMCLinkage.of(rows)
        AgglomerativeLinkage.MEDIAN -> WPGMCLinkage.of(rows)
        AgglomerativeLinkage.WARD -> WardLinkage.of(rows)
    }
    val model = HierarchicalClustering.fit(linkageModel)
    return AgglomerativeClustering(linkage, model)
}

private data class KMedoidsCoreResult(
    val labels: IntArray,
    val medoidIndices: IntArray,
    val distortion: Double,
    val iterations: Int,
    val converged: Boolean,
    val clusterSizes: IntArray
)

k-medoids by alternating assignment and exact per-cluster re-medoiding, restarted from D²-weighted seeds.

Why this is not Smile's KMedoids

It used to be. Smile 5's KMedoids.fit is CLARANS, and it derives a maxNeighbor from the Clustering.Options tolerance like this:

maxNeighbor = round(tol * k * (n - k))
maxNeighbor = max(min(100, k * (n - k)), maxNeighbor)
if (maxNeighbor > n) throw IllegalArgumentException("Too large maxNeighbor: " + maxNeighbor)

That min(100, …) floor is not reachable through any option, so for k >= 2 and k * (n - k) > n — which is every interesting case — the call throws unless n >= 100. Measured: vec2KMedoids(90 points, k = 3) with the default options threw Too large maxNeighbor: 100. The whole …KMedoids family was therefore unusable on any point cloud smaller than a hundred samples, which is most of the point clouds anyone hands it. Two of the old options were also inert: Smile clamps maxIter to min(3, maxIter), and neighborRatio only ever mattered when it pushed maxNeighbor above the floor of 100 — never, at the default 0.0125.

The replacement is the plain alternating algorithm ("Voronoi iteration"): assign every sample to its nearest medoid, then replace each cluster's medoid by the member of that cluster with the smallest total distance to the rest of it, and repeat until the medoid set stops moving. That step is exact — it enumerates the cluster — so the distortion is monotonically non-increasing and the loop terminates. It is not PAM: it will not escape a local optimum by swapping a medoid with a non-member, which is what KMedoidsOptions.runs restarts are for.

Cost: one n × n distance matrix, so O(n²) time and memory in the sample count, then O(n·k) per assignment and O(n²/k) per re-medoid round. The matrix is shared across restarts.

private fun kMedoids(
    rows: Array<DoubleArray>,
    k: Int,
    options: KMedoidsOptions,
    fn: String
): KMedoidsCoreResult {
    validateRows(rows, fn, minSamples = 2)
    require(k in 1 until rows.size) {
        "$fn: k must be in [1, ${rows.size - 1}] for k-medoids, found $k"
    }
    validateKMedoidsOptions(options, fn)

    val n = rows.size
    val metric: (DoubleArray, DoubleArray) -> Double = when (options.distance) {
        MedoidDistance.EUCLIDEAN -> ::l2
        MedoidDistance.MANHATTAN -> ::l1
    }
    val distances = Array(n) { DoubleArray(n) }
    for (i in 0 until n) {
        for (j in i + 1 until n) {
            val d = metric(rows[i], rows[j])
            distances[i][j] = d
            distances[j][i] = d
        }
    }

    var best: KMedoidsCoreResult? = null
    for (run in 0 until options.runs) {
        val rng = when (val seed = options.randomSeed) {
            null -> Random.Default
            else -> Random(seed + run)
        }
        val candidate = runKMedoids(distances, k, options.maxIterations, rng)
        if (best == null || candidate.distortion < best.distortion) best = candidate
    }
    return best!!
}

One restart of kMedoids: seed, then alternate until the medoid set repeats.

source — 54 lines, from line 1187
private fun runKMedoids(
    distances: Array<DoubleArray>,
    k: Int,
    maxIterations: Int,
    rng: Random
): KMedoidsCoreResult {
    val n = distances.size
    var medoids = initMedoids(distances, k, rng)
    val labels = IntArray(n)
    val assigned = DoubleArray(n)
    var iterations = 0
    var converged = false

    while (iterations < maxIterations) {
        iterations++
        assignToMedoids(distances, medoids, labels, assigned)

        val next = IntArray(k)
        for (cluster in 0 until k) {
            var bestMember = -1
            var bestCost = Double.POSITIVE_INFINITY
            for (candidate in 0 until n) {
                if (labels[candidate] != cluster) continue
                var cost = 0.0
                for (other in 0 until n) {
                    if (labels[other] == cluster) cost += distances[candidate][other]
                }
                if (cost < bestCost) {
                    bestCost = cost
                    bestMember = candidate
                }
            }
            // An empty cluster keeps its medoid rather than vanishing, so `k` clusters are always
            // reported — the caller asked for k and gets k, even if one of them has no members.
            next[cluster] = if (bestMember >= 0) bestMember else medoids[cluster]
        }

        if (next.contentEquals(medoids)) {
            converged = true
            break
        }
        medoids = next
    }

    assignToMedoids(distances, medoids, labels, assigned)
    return KMedoidsCoreResult(
        labels = labels.copyOf(),
        medoidIndices = medoids.copyOf(),
        distortion = assigned.sum(),
        iterations = iterations,
        converged = converged,
        clusterSizes = clusterSizes(labels, k)
    )
}

D²-weighted seeding over the precomputed distances — the k-means++ rule, on whichever metric built the matrix. Picking the first medoid uniformly and then favouring far-away samples spreads the seeds instead of clumping them, which is what makes a handful of restarts enough.

source — 519 lines, from line 1247
private fun initMedoids(distances: Array<DoubleArray>, k: Int, rng: Random): IntArray {
    val n = distances.size
    if (k >= n) return IntArray(k) { it % n }

    val chosen = LinkedHashSet<Int>(k)
    val first = rng.nextInt(n)
    chosen.add(first)
    val nearest = DoubleArray(n) { distances[it][first] }

    while (chosen.size < k) {
        var total = 0.0
        for (i in 0 until n) {
            if (i in chosen) continue
            val w = nearest[i] * nearest[i]
            if (w.isFinite() && w > 0.0) total += w
        }

        var picked = -1
        if (total > 1e-12) {
            var r = rng.nextDouble() * total
            for (i in 0 until n) {
                if (i in chosen) continue
                val w = nearest[i] * nearest[i]
                if (w <= 0.0 || !w.isFinite()) continue
                r -= w
                if (r <= 0.0) {
                    picked = i
                    break
                }
            }
        }
        if (picked < 0) {
            // Either every remaining sample coincides with a chosen medoid, or the roulette ran off
            // the end of the weights through rounding. Any unchosen index will do.
            var pick = rng.nextInt(n)
            while (pick in chosen) pick = (pick + 1) % n
            picked = pick
        }
        val next: Int = picked

        chosen.add(next)
        for (i in 0 until n) {
            if (distances[i][next] < nearest[i]) nearest[i] = distances[i][next]
        }
    }

    return chosen.toIntArray()
}

private fun assignToMedoids(
    distances: Array<DoubleArray>,
    medoids: IntArray,
    labels: IntArray,
    assigned: DoubleArray
) {
    for (i in distances.indices) {
        var bestCluster = 0
        var bestDistance = Double.POSITIVE_INFINITY
        for (cluster in medoids.indices) {
            val d = distances[i][medoids[cluster]]
            if (d < bestDistance) {
                bestDistance = d
                bestCluster = cluster
            }
        }
        labels[i] = bestCluster
        assigned[i] = bestDistance
    }
}

private data class KMediansCoreResult(
    val labels: IntArray,
    val centers: Array<DoubleArray>,
    val objective: Double,
    val iterations: Int,
    val converged: Boolean,
    val clusterSizes: IntArray
)

private fun kMedians(
    rows: Array<DoubleArray>,
    k: Int,
    options: KMediansOptions,
    fn: String
): KMediansCoreResult {
    validateRows(rows, fn, minSamples = 1)
    require(k in 1..rows.size) { "$fn: k must be in [1, ${rows.size}], found $k" }
    validateKMediansOptions(options, fn)

    var best: KMediansCoreResult? = null
    val seedBase = options.randomSeed
    for (run in 0 until options.runs) {
        val rng = when (seedBase) {
            null -> Random.Default
            else -> Random(seedBase + run)
        }
        val candidate = runKMedians(rows, k, options, rng)
        if (best == null || candidate.objective < best.objective) best = candidate
    }
    return best!!
}

private fun runKMedians(
    rows: Array<DoubleArray>,
    k: Int,
    options: KMediansOptions,
    rng: Random
): KMediansCoreResult {
    val n = rows.size
    val d = rows[0].size
    var centers = initKMediansCenters(rows, k, rng)
    val labels = IntArray(n)
    val distances = DoubleArray(n)
    var prevObjective = Double.POSITIVE_INFINITY
    var converged = false
    var iter = 0

    while (iter < options.maxIterations) {
        iter++
        assignL1(rows, centers, labels, distances)
        val objective = distances.sum()
        val counts = clusterSizes(labels, k)
        val replacements = farthestReplacementOrder(distances)
        var replacementCursor = 0
        val newCenters = Array(k) { DoubleArray(d) }

        for (cluster in 0 until k) {
            if (counts[cluster] == 0) {
                val replacementIndex = replacements[replacementCursor % replacements.size]
                replacementCursor++
                newCenters[cluster] = rows[replacementIndex].copyOf()
            } else {
                for (dim in 0 until d) {
                    newCenters[cluster][dim] = coordinateMedian(rows, labels, cluster, dim, counts[cluster])
                }
            }
        }

        val shift = centersL1Shift(centers, newCenters)
        val delta = abs(prevObjective - objective)
        centers = newCenters
        if (delta <= options.tolerance && shift <= options.tolerance) {
            converged = true
            break
        }
        prevObjective = objective
    }

    assignL1(rows, centers, labels, distances)
    val objective = distances.sum()
    return KMediansCoreResult(
        labels = labels.copyOf(),
        centers = centers.map { it.copyOf() }.toTypedArray(),
        objective = objective,
        iterations = iter,
        converged = converged,
        clusterSizes = clusterSizes(labels, k)
    )
}

private fun initKMediansCenters(
    rows: Array<DoubleArray>,
    k: Int,
    rng: Random
): Array<DoubleArray> {
    if (k == rows.size) return Array(k) { i -> rows[i].copyOf() }

    val chosen = LinkedHashSet<Int>(k)
    val first = rng.nextInt(rows.size)
    chosen.add(first)

    val minDist = DoubleArray(rows.size) { i -> l1(rows[i], rows[first]) }
    while (chosen.size < k) {
        var total = 0.0
        for (i in rows.indices) {
            if (i in chosen) continue
            val w = minDist[i] * minDist[i]
            if (w.isFinite() && w > 0.0) total += w
        }

        val next = if (total <= 1e-12) {
            var pick = rng.nextInt(rows.size)
            while (pick in chosen) pick = (pick + 1) % rows.size
            pick
        } else {
            var r = rng.nextDouble() * total
            var chosenIndex = -1
            for (i in rows.indices) {
                if (i in chosen) continue
                val w = minDist[i] * minDist[i]
                if (w <= 0.0 || !w.isFinite()) continue
                r -= w
                if (r <= 0.0) {
                    chosenIndex = i
                    break
                }
            }
            if (chosenIndex >= 0) {
                chosenIndex
            } else {
                var fallback = 0
                while (fallback < rows.size && fallback in chosen) fallback++
                if (fallback < rows.size) fallback else 0
            }
        }

        chosen.add(next)
        for (i in rows.indices) {
            val d = l1(rows[i], rows[next])
            if (d < minDist[i]) minDist[i] = d
        }
    }

    return chosen.map { rows[it].copyOf() }.toTypedArray()
}

private fun assignL1(
    rows: Array<DoubleArray>,
    centers: Array<DoubleArray>,
    labels: IntArray,
    distances: DoubleArray
) {
    for (i in rows.indices) {
        var bestCluster = 0
        var bestDistance = Double.POSITIVE_INFINITY
        for (cluster in centers.indices) {
            val d = l1(rows[i], centers[cluster])
            if (d < bestDistance) {
                bestDistance = d
                bestCluster = cluster
            }
        }
        labels[i] = bestCluster
        distances[i] = bestDistance
    }
}

private fun coordinateMedian(
    rows: Array<DoubleArray>,
    labels: IntArray,
    cluster: Int,
    dim: Int,
    count: Int
): Double {
    val values = DoubleArray(count)
    var p = 0
    for (i in rows.indices) {
        if (labels[i] == cluster) {
            values[p] = rows[i][dim]
            p++
        }
    }
    values.sort()
    val mid = values.size / 2
    return if (values.size % 2 == 0) (values[mid - 1] + values[mid]) * 0.5 else values[mid]
}

private fun centersL1Shift(
    a: Array<DoubleArray>,
    b: Array<DoubleArray>
): Double {
    var shift = 0.0
    for (i in a.indices) {
        shift += l1(a[i], b[i])
    }
    return shift
}

private fun farthestReplacementOrder(distances: DoubleArray): IntArray =
    distances.indices.sortedByDescending { distances[it] }.toIntArray()

private fun validateKMedoidsOptions(options: KMedoidsOptions, fn: String) {
    require(options.maxIterations > 0) {
        "$fn: maxIterations must be > 0, found ${options.maxIterations}"
    }
    require(options.runs > 0) { "$fn: runs must be > 0, found ${options.runs}" }
}

private fun validateKMediansOptions(options: KMediansOptions, fn: String) {
    require(options.maxIterations > 0) {
        "$fn: maxIterations must be > 0, found ${options.maxIterations}"
    }
    require(options.tolerance.isFinite() && options.tolerance >= 0.0) {
        "$fn: tolerance must be finite and >= 0, found ${options.tolerance}"
    }
    require(options.runs > 0) { "$fn: runs must be > 0, found ${options.runs}" }
}

private fun validateRows(rows: Array<DoubleArray>, fn: String, minSamples: Int) {
    require(rows.size >= minSamples) {
        "$fn: at least $minSamples samples required, found ${rows.size}"
    }
    require(rows[0].isNotEmpty()) { "$fn: sample dimension must be > 0" }
    val d = rows[0].size
    rows.forEachIndexed { i, row ->
        require(row.size == d) {
            "$fn: inconsistent dimensions, row 0 has $d and row $i has ${row.size}"
        }
        row.forEachIndexed { j, v ->
            require(v.isFinite()) { "$fn: row[$i][$j] must be finite, found $v" }
        }
    }
}

private fun floatRows(values: List<Float>, fn: String): Array<DoubleArray> {
    require(values.isNotEmpty()) { "$fn: values must not be empty" }
    return Array(values.size) { i ->
        val v = values[i]
        require(v.isFinite()) { "$fn: values[$i] must be finite, found $v" }
        doubleArrayOf(v.toDouble())
    }
}

private fun vec2Rows(points: List<Vec2>, fn: String): Array<DoubleArray> {
    require(points.isNotEmpty()) { "$fn: points must not be empty" }
    return Array(points.size) { i ->
        val p = points[i]
        require(p.x.isFinite() && p.y.isFinite()) {
            "$fn: points[$i] must be finite, found (${p.x}, ${p.y})"
        }
        doubleArrayOf(p.x.toDouble(), p.y.toDouble())
    }
}

private fun vec3Rows(points: List<Vec3>, fn: String): Array<DoubleArray> {
    require(points.isNotEmpty()) { "$fn: points must not be empty" }
    return Array(points.size) { i ->
        val p = points[i]
        require(p.x.isFinite() && p.y.isFinite() && p.z.isFinite()) {
            "$fn: points[$i] must be finite, found (${p.x}, ${p.y}, ${p.z})"
        }
        doubleArrayOf(p.x.toDouble(), p.y.toDouble(), p.z.toDouble())
    }
}

private fun vec4Rows(points: List<Vec4>, fn: String): Array<DoubleArray> {
    require(points.isNotEmpty()) { "$fn: points must not be empty" }
    return Array(points.size) { i ->
        val p = points[i]
        require(p.x.isFinite() && p.y.isFinite() && p.z.isFinite() && p.w.isFinite()) {
            "$fn: points[$i] must be finite, found (${p.x}, ${p.y}, ${p.z}, ${p.w})"
        }
        doubleArrayOf(p.x.toDouble(), p.y.toDouble(), p.z.toDouble(), p.w.toDouble())
    }
}

private fun l1(a: DoubleArray, b: DoubleArray): Double {
    var s = 0.0
    for (i in a.indices) s += abs(a[i] - b[i])
    return s
}

private fun l2(a: DoubleArray, b: DoubleArray): Double {
    var s = 0.0
    for (i in a.indices) {
        val d = a[i] - b[i]
        s += d * d
    }
    return sqrt(s)
}

private fun clusterSizes(labels: IntArray, k: Int): IntArray {
    val sizes = IntArray(k)
    labels.forEach { label ->
        if (label in 0 until k) sizes[label]++
    }
    return sizes
}

private fun canonicalizeLabels(raw: IntArray): IntArray {
    val remap = LinkedHashMap<Int, Int>()
    return IntArray(raw.size) { i ->
        remap.getOrPut(raw[i]) { remap.size }
    }
}

private data class Bounds3(val min: Vec3, val max: Vec3, val size: Vec3)

private fun bounds3(points: List<Vec3>): Bounds3 {
    var minX = Float.POSITIVE_INFINITY
    var minY = Float.POSITIVE_INFINITY
    var minZ = Float.POSITIVE_INFINITY
    var maxX = Float.NEGATIVE_INFINITY
    var maxY = Float.NEGATIVE_INFINITY
    var maxZ = Float.NEGATIVE_INFINITY
    points.forEach { p ->
        if (p.x < minX) minX = p.x
        if (p.y < minY) minY = p.y
        if (p.z < minZ) minZ = p.z
        if (p.x > maxX) maxX = p.x
        if (p.y > maxY) maxY = p.y
        if (p.z > maxZ) maxZ = p.z
    }
    return Bounds3(
        min = vec(minX, minY, minZ),
        max = vec(maxX, maxY, maxZ),
        size = vec(
            max(1e-6f, maxX - minX),
            max(1e-6f, maxY - minY),
            max(1e-6f, maxZ - minZ)
        )
    )
}

private fun coordinateColor(point: Vec3, bounds: Bounds3, alpha: Float): Vec4 {
    val nx = ((point.x - bounds.min.x) / bounds.size.x).coerceIn(0f, 1f)
    val ny = ((point.y - bounds.min.y) / bounds.size.y).coerceIn(0f, 1f)
    val nz = ((point.z - bounds.min.z) / bounds.size.z).coerceIn(0f, 1f)
    val soften = { v: Float -> sqrt(v.toDouble()).toFloat().coerceIn(0f, 1f) }
    return vec(soften(nx), soften(ny), soften(nz), alpha)
}

private fun mixColor(a: Vec4, b: Vec4, t: Float, alpha: Float): Vec4 {
    val u = t.coerceIn(0f, 1f)
    return vec(
        a.x + (b.x - a.x) * u,
        a.y + (b.y - a.y) * u,
        a.z + (b.z - a.z) * u,
        alpha
    )
}

private fun withAlpha(color: Vec4, alpha: Float): Vec4 =
    vec(color.x, color.y, color.z, alpha)

private fun clusterCentroids(points: List<Vec3>, labels: IntArray): Array<Vec3?> {
    val k = (labels.maxOrNull() ?: -1) + 1
    val sums = Array(k) { vec(0f, 0f, 0f) }
    val counts = IntArray(k)
    for (i in points.indices) {
        val c = labels[i]
        if (c !in 0 until k) continue
        sums[c].add(points[i])
        counts[c]++
    }
    return Array(k) { i ->
        if (counts[i] == 0) null else sums[i].mul(1f / counts[i].toFloat(), vec(0f, 0f, 0f))
    }
}

private fun validateLayers(
    sampleCount: Int,
    layers: List<ClusterLayer>,
    fn: String
): List<ClusterLayer> {
    require(layers.isNotEmpty()) { "$fn: layers must not be empty" }
    layers.forEachIndexed { i, layer ->
        require(layer.sampleCount == sampleCount) {
            "$fn: layers[$i] sample count mismatch, expected $sampleCount found ${layer.sampleCount}"
        }
    }
    return layers
}

private fun pointCloudLine(pointSize: Double): FLine = fline {
    pointed = true
    stroked = false
    filled = false
    this.pointSize = pointSize
}

private fun edgeLine(thickness: Double): FLine = fline {
    pointed = false
    stroked = true
    filled = false
    this.thickness = thickness
}

private fun guideLine(): FLine = fline {
    pointed = false
    stroked = true
    filled = false
    this.thickness = 0.55
}

private fun FLine.addPoint(position: Vec3, color: Vec4) {
    moveTo(position)
    val node = nodes.last()
    node.properties["color"] = Vec4(color)
}

private fun FLine.addSegment(a: Vec3, b: Vec3, color: Vec4) {
    moveTo(a)
    nodes.last().properties["color"] = Vec4(color)
    lineTo(b)
    nodes.last().properties["color"] = Vec4(color)
}

private fun FLine.addRect(
    minX: Float,
    minY: Float,
    maxX: Float,
    maxY: Float,
    z: Float,
    color: Vec4
) {
    addSegment(vec(minX, minY, z), vec(maxX, minY, z), color)
    addSegment(vec(maxX, minY, z), vec(maxX, maxY, z), color)
    addSegment(vec(maxX, maxY, z), vec(minX, maxY, z), color)
    addSegment(vec(minX, maxY, z), vec(minX, minY, z), color)
}

private fun clusteredLineMap(
    pointHalo: FLine,
    pointCore: FLine,
    centerHalo: FLine,
    centerCore: FLine,
    links: FLine,
    guides: FLine?
): Map<String, List<FLine>> {
    val out = linkedMapOf<String, List<FLine>>()
    if (pointHalo.nodes.isNotEmpty()) out["cluster-points-halo"] = listOf(pointHalo)
    if (pointCore.nodes.isNotEmpty()) out["cluster-points"] = listOf(pointCore)
    if (centerHalo.nodes.isNotEmpty()) out["cluster-centers-halo"] = listOf(centerHalo)
    if (centerCore.nodes.isNotEmpty()) out["cluster-centers"] = listOf(centerCore)
    if (links.nodes.isNotEmpty()) out["cluster-links"] = listOf(links)
    if (guides != null && guides.nodes.isNotEmpty()) out["cluster-guides"] = listOf(guides)
    return out
}