EmbeddingSimplex.kt
src/jvmMain/kotlin/afield/math/statistics/EmbeddingSimplex.kt · 249 lines
@file:afield.runtime.completion.CompletionCategory("Data")
package afield.math.statistics
import afield.ai.inference.onnx.ClipVector
import afield.math.linalg.Vec3
import afield.math.linalg.vec
import kotlin.math.max
import kotlin.math.sqrt
A handful of embeddings as a shape you can look at: every pairwise distance, and the points placed in 3D so that those distances are the lengths of the edges between them.
Any four points of a Euclidean space sit exactly in three dimensions, however many the space
has. So four 512-dimensional CLIP vectors make a tetrahedron whose six edges are their six
distances, not an approximation of them: classical MDS on four points recovers them up to rotation
and reflection. Three points make a triangle (exact in a plane), two a segment. Five or more do not
fit in general: EmbeddingSimplex.stress1 says by how much, and the edges drawn are then the best
3D fit rather than the distances in the table.
val simplex = embeddingSimplex(
listOf(clipEncodeImage(cat), clipEncodeImage(dog), clipEncodeImage(tiger), clipEncode("a photo of a cat")),
labels = listOf("cat", "dog", "tiger", "text"),
)
simplex.edges.maxBy { it.distance } // the two that are furthest apart
showSimplex(simplex) // draw it (afield.graphics.three)
Which distance
With normalize on (the default), each vector is scaled to unit length and
EmbeddingSimplex.distances are chord lengths, |a − b| = √(2 − 2 cos θ), which range over
0..2. Chord length is a true Euclidean distance between the points on the sphere, which is what
makes the embedding exact; cosine distance 1 − cos θ is half the square of it and is not a
metric, so it is carried along in EmbeddingSimplex.cosineDistances for reading but never fed
to MDS.
The 3D coordinates
EmbeddingSimplex.points come from smileMdsDistances with positiveCorrection = false:
Torgerson's classical MDS, the double-centred −½ D² and its leading eigenvectors scaled by √λ,
three of them once there are four points or more. They are centred on the origin and multiplied by
EmbeddingSimplex.scale so that the point farthest from the centre lies at
EmbeddingSimplex.radius; multiply a distance by that scale to get an edge's drawn length. Signs
are not canonicalised, so two machines may draw mirror images of one simplex.
class EmbeddingSimplex internal constructor
The distances among a few embeddings, with a 3D placement of them whose edge lengths are those
distances. exact says whether the drawing is faithful; showSimplex draws it.
class EmbeddingSimplex internal constructor(
/** One per point, in input order. */
val labels: List<String>,
/** Chord distances between the embeddings, `n × n`; plain Euclidean ones with `normalize = false`. */
val distances: Array<DoubleArray>,
/** `1 − cos θ` between the points, `n × n`. Carried for reading; never fed to MDS. */
val cosineDistances: Array<DoubleArray>,
/** The 3D positions, centred on the origin and scaled by [scale]; `points[i]` is input `i`. */
val points: List<Vec3>,
/** Display units per unit of distance. */
val scale: Double,
/** Where the point farthest from the centre is drawn, in display units. */
val radius: Double,
/** Every pair `a < b`, in the order (0,1), (0,2), … (n−2,n−1). */
val edges: List<SimplexEdge>,
/**
* Kruskal's stress-1 of the 3D placement against [distances]. It is at or below the threshold
* [exact] tests against whenever the fit is exact (four points or fewer), and larger when the
* points need more than three dimensions.
*/
val stress1: Double,
) {
val size: Int get() = labels.size
How many points.
val exact: Boolean get() = stress1 <= EXACT_STRESS
Whether the drawn edges are the distances, to EXACT_STRESS: always for four or fewer points.
fun edge(a: Int, b: Int): SimplexEdge
The edge between a and b, in either order.
fun edge(a: Int, b: Int): SimplexEdge = edges.first { (it.a == a && it.b == b) || (it.a == b && it.b == a) }
fun edgesFrom(i: Int): List<SimplexEdge>
The edges that touch point i, closest first.
fun edgesFrom(i: Int): List<SimplexEdge> = edges.filter { it.a == i || it.b == i }.sortedBy { it.rankFrom(i) }
override fun toString(): String
A header line, then one line per edge: the two labels, the distance, and the cosine distance.
override fun toString(): String = buildString {
append("EmbeddingSimplex(${labels.size} points, stress1 %.2e)".format(stress1))
for (e in edges) append("\n ${labels[e.a]} – ${labels[e.b]}: %.3f (cos %.3f)".format(e.distance, e.cosineDistance))
}
companion object
companion object {
const val EXACT_STRESS = 1e-5
The stress1 below which a fit counts as exact; float32 embeddings land near 1e-8.
data class SimplexEdge
One pair of an EmbeddingSimplex: the two points, how far apart they are, and where that distance ranks at each end.
data class SimplexEdge(
/** Index of one end, the lower of the two. */
val a: Int,
/** Index of the other end, always greater than [a]. */
val b: Int,
/** Chord (or Euclidean) distance between the two embeddings. */
val distance: Double,
/** `1 − cos θ` between them. */
val cosineDistance: Double,
/**
* Where this edge comes among the edges that touch [a], `0` being the closest of them; ties go to
* the lower point index. With four points the ranks run 0, 1, 2, and [showSimplex] with
* `flags = true` draws them as green, orange and red diamonds at each end of each edge.
*/
val rankFromA: Int,
/** The same for [b]. */
val rankFromB: Int,
) {
fun rankFrom(i: Int): Int
rankFromA or rankFromB, for whichever end i is.
fun rankFrom(i: Int): Int = when (i) {
a -> rankFromA
b -> rankFromB
else -> throw IllegalArgumentException("point $i is not an end of edge $a–$b")
}
}
fun embeddingSimplex
Build an EmbeddingSimplex from equal-length vectors: any embedding, not only CLIP.
@param labels one per vector; defaults to 1, 2, ….
@param normalize scale every vector to unit length first, so distances are chords (see
EmbeddingSimplex); turn it off for an embedding whose length means something.
@param radius where the point farthest from the centre is drawn.
body — 62 lines, from line 138
fun embeddingSimplex(
vectors: List<DoubleArray>,
labels: List<String> = vectors.indices.map { "${it + 1}" },
normalize: Boolean = true,
radius: Double = 3.0,
): EmbeddingSimplex {
val n = vectors.size
require(n >= 1) { "embeddingSimplex: at least one vector is required" }
require(labels.size == n) { "embeddingSimplex: ${labels.size} labels for $n vectors" }
require(radius.isFinite() && radius > 0) { "embeddingSimplex: radius must be positive, got $radius" }
val dims = vectors[0].size
require(dims > 0) { "embeddingSimplex: vectors must not be empty" }
vectors.forEachIndexed { i, v ->
require(v.size == dims) { "embeddingSimplex: vector $i has ${v.size} values; expected $dims" }
require(v.all { it.isFinite() }) { "embeddingSimplex: vector $i has a value that is not finite" }
}
val unit = vectors.mapIndexed { i, v ->
val norm = sqrt(v.sumOf { it * it })
if (normalize) {
require(norm > 0) { "embeddingSimplex: vector $i is zero and has no direction" }
DoubleArray(dims) { v[it] / norm }
} else v
}
val distances = Array(n) { DoubleArray(n) }
val cosine = Array(n) { DoubleArray(n) }
for (a in 0 until n) for (b in 0 until a) {
var dot = 0.0
var sq = 0.0
var na = 0.0
var nb = 0.0
for (k in 0 until dims) {
val x = unit[a][k]
val y = unit[b][k]
dot += x * y; na += x * x; nb += y * y
sq += (x - y) * (x - y)
}
val d = sqrt(max(0.0, sq))
val c = (1.0 - dot / sqrt(na * nb)).coerceIn(0.0, 2.0)
distances[a][b] = d; distances[b][a] = d
cosine[a][b] = c; cosine[b][a] = c
}
val coordinates = classicalMds3(distances)
val reach = coordinates.maxOf { sqrt(it[0] * it[0] + it[1] * it[1] + it[2] * it[2]) }
val scale = if (reach > 0) radius / reach else 1.0
val points = coordinates.map { vec(it[0] * scale, it[1] * scale, it[2] * scale) }
// Rank each point's incident edges, closest first, ties to the lower index.
val rank = Array(n) { IntArray(n) }
for (i in 0 until n) {
(0 until n).filter { it != i }
.sortedWith(compareBy<Int> { distances[i][it] }.thenBy { it })
.forEachIndexed { r, j -> rank[i][j] = r }
}
val edges = ArrayList<SimplexEdge>()
for (a in 0 until n) for (b in a + 1 until n) {
edges += SimplexEdge(a, b, distances[a][b], cosine[a][b], rank[a][b], rank[b][a])
}
return EmbeddingSimplex(labels.toList(), distances, cosine, points, scale, radius, edges, stress1(distances, coordinates))
}
fun embeddingSimplex
embeddingSimplex over FloatArrays.
@JvmName("embeddingSimplexFloat")
fun embeddingSimplex(
vectors: List<FloatArray>,
labels: List<String> = vectors.indices.map { "${it + 1}" },
normalize: Boolean = true,
radius: Double = 3.0,
): EmbeddingSimplex = embeddingSimplex(vectors.map { v -> DoubleArray(v.size) { v[it].toDouble() } }, labels, normalize, radius)
fun embeddingSimplex
embeddingSimplex over CLIP vectors, from images and prompts alike: they share one space.
@JvmName("embeddingSimplexClip")
fun embeddingSimplex(
vectors: List<ClipVector>,
labels: List<String> = vectors.indices.map { "${it + 1}" },
radius: Double = 3.0,
): EmbeddingSimplex = embeddingSimplex(vectors.map { it.values }, labels, normalize = true, radius = radius)
Classical MDS of distances into three columns: smileMdsDistances wherever it applies (it needs
at least one output dimension, fewer than the number of points), the obvious answer for one or two
points, and zero columns past what the points can span.
private fun classicalMds3(distances: Array<DoubleArray>): List<DoubleArray> {
val n = distances.size
return when (n) {
1 -> listOf(DoubleArray(3))
2 -> listOf(doubleArrayOf(-distances[0][1] / 2, 0.0, 0.0), doubleArrayOf(distances[0][1] / 2, 0.0, 0.0))
else -> {
val dims = minOf(3, n - 1)
val fit = smileMdsDistances(distances, dimensions = dims, label = "chord", positiveCorrection = false)
fit.coordinates.map { row -> DoubleArray(3) { if (it < dims) row[it] else 0.0 } }
}
}
}
Kruskal's stress-1 of coordinates against distances: √(Σ(d − δ)² / Σd²) over pairs.
private fun stress1(distances: Array<DoubleArray>, coordinates: List<DoubleArray>): Double {
var num = 0.0
var den = 0.0
for (a in distances.indices) for (b in 0 until a) {
val p = coordinates[a]
val q = coordinates[b]
val fitted = sqrt((0 until 3).sumOf { (p[it] - q[it]) * (p[it] - q[it]) })
num += (distances[a][b] - fitted) * (distances[a][b] - fitted)
den += distances[a][b] * distances[a][b]
}
return if (den > 0) sqrt(num / den) else 0.0
}