transport.kt
src/jvmMain/kotlin/afield/geometry/spatial/transport.kt · 469 lines
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.geometry.spatial
import afield.math.linalg.Vec2
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
Optimal transport between point sets: which source goes to which target, or how much of each source goes to each target, at the least total cost. Three solvers, from exact and small to approximate and large:
- assignment (the Hungarian method) answers the one-to-one question exactly, for two equally
sized sets. It is
O(n³)over ann × ncost matrix, so it is for hundreds of points, maybe a few thousand. Flow matching's minibatch coupling and the FLine trajectory matcher use it. - matchPoints (BSP-OT, in
bspot.kt) answers the same one-to-one question approximately, for 2D clouds of a million points a side. - sinkhorn answers a softer question exactly: it transports mass, so the sets may differ in
size and carry weights, and every source may split between several targets. The answer is a
transport plan rather than a pairing. A small sinkhorn
epsilonmakes the plan nearly a permutation and its cost nearly the assignment cost; a large one blurs it.
Entropic transport
sinkhorn solves the entropy-regularised problem: minimise Σ πᵢⱼ Cᵢⱼ + ε Σ πᵢⱼ (ln πᵢⱼ − 1) over
plans π whose rows sum to the source weights a and whose columns sum to the target weights b.
The entropy term makes the problem strictly convex, so the answer is unique, smooth in the inputs,
and found by alternately rescaling rows and columns (Cuturi 2013). This implementation keeps the
scalings as log-domain dual potentials f, g, so a small ε does not underflow, and
πᵢⱼ = exp((fᵢ + gⱼ − Cᵢⱼ) / ε).
ε is in the units of the cost. With squared-distance costs it is an area: ε = 0.01 on points in
a unit square blurs over roughly √0.01 = 0.1. Each iteration is O(n·m), and smaller ε needs
more of them; multiscaleSinkhorn solves a coarse version first and starts the fine one from it.
fun assignment(cost: Array<DoubleArray>): IntArray = HungarianAssignment.solve(cost)
The exact one-to-one assignment for a square cost matrix by the Hungarian method: result[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) is
the column row i is matched to, and the total of cost[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240)[result[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240)] is the least possible.
O(n³); an empty matrix gives an empty result.
fun assignment(from: List<Vec2>, to: List<Vec2>): IntArray
The exact one-to-one matching of from onto to that minimises total squared distance:
result[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) is the index in to that from[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) goes to. The two lists must be the same size.
For more than a few thousand points use matchPoints, which is approximate and O(n log n).
fun assignment(from: List<Vec2>, to: List<Vec2>): IntArray {
require(from.size == to.size) { "assignment needs two sets of the same size, got ${from.size} and ${to.size}" }
return HungarianAssignment.solve(squaredDistanceCost(from, to))
}
object HungarianAssignment
The Hungarian method for square cost matrices, O(n³). assignment is the public way in; this
object is the one implementation that flow matching, the FLine trajectory matcher and the BSP-OT
tests share.
object HungarianAssignment {
fun solve(cost: Array<DoubleArray>): IntArray
result[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) is the column assigned to row i; see assignment.
body — 61 lines, from line 65
fun solve(cost: Array<DoubleArray>): IntArray {
val n = cost.size
if (n == 0) return IntArray(0)
require(cost.all { it.size == n }) { "Hungarian solver expects a square matrix" }
val u = DoubleArray(n + 1)
val v = DoubleArray(n + 1)
val p = IntArray(n + 1)
val way = IntArray(n + 1)
for (i in 1..n) {
p[0] = i
var j0 = 0
val minv = DoubleArray(n + 1) { Double.POSITIVE_INFINITY }
val used = BooleanArray(n + 1)
do {
used[j0] = true
val i0 = p[j0]
var delta = Double.POSITIVE_INFINITY
var j1 = 0
for (j in 1..n) {
if (!used[j]) {
val cur = cost[i0 - 1][j - 1] - u[i0] - v[j]
if (cur < minv[j]) {
minv[j] = cur
way[j] = j0
}
if (minv[j] < delta) {
delta = minv[j]
j1 = j
}
}
}
for (j in 0..n) {
if (used[j]) {
u[p[j]] += delta
v[j] -= delta
} else {
minv[j] -= delta
}
}
j0 = j1
} while (p[j0] != 0)
do {
val j1 = way[j0]
p[j0] = p[j1]
j0 = j1
} while (j0 != 0)
}
val assignment = IntArray(n) { -1 }
for (j in 1..n) {
if (p[j] != 0) assignment[p[j] - 1] = j - 1
}
return assignment
}
}
class TransportPlan internal constructor
The answer sinkhorn gives: a transport plan, the dual potentials that generate it, and how well it met its constraints.
class TransportPlan internal constructor(
/** `plan[i][j]` is how much of source `i` goes to target `j`. Rows sum to the source weights, columns to the target weights, and the whole plan to 1. */
val plan: Array<DoubleArray>,
/** The source weights the plan was solved for, normalised to sum to 1. */
val sourceWeights: DoubleArray,
/** The target weights, normalised to sum to 1. */
val targetWeights: DoubleArray,
/** The dual potential on each source; the plan is `exp((f[i] + g[j] − cost[i][j]) / ε)`. */
val f: DoubleArray,
/** The dual potential on each target. */
val g: DoubleArray,
/** The regularisation the plan was solved at. */
val epsilon: Double,
/** The transport cost `Σ plan[i][j] · cost[i][j]`, without the entropy term. */
val cost: Double,
/** The regularised objective `cost + ε Σ π (ln π − 1)` that was minimised. */
val objective: Double,
/** Row-then-column sweeps run. */
val iterations: Int,
/** Whether both marginal errors fell to the tolerance before the iteration limit. */
val converged: Boolean,
/** The largest difference between a row sum and its source weight. */
val rowError: Double,
/** The largest difference between a column sum and its target weight. */
val columnError: Double,
) {
val sources: Int get() = plan.size
The number of sources (rows).
val targets: Int get() = if (plan.isEmpty()) 0 else plan[0].size
The number of targets (columns).
fun barycentricMap(to: List<Vec2>): List<Vec2>
Where each source goes on average: the plan-weighted mean of the targets it sends mass to. With
a small ε this is close to the one target it is matched with; with a large one it is pulled
toward the middle of everything it spreads over.
fun barycentricMap(to: List<Vec2>): List<Vec2> {
require(to.size == targets) { "barycentricMap needs the ${targets} targets the plan was solved for, got ${to.size}" }
return plan.mapIndexed { i, row ->
val w = sourceWeights[i]
if (w <= 0.0) Vec2(Float.NaN, Float.NaN)
else {
var x = 0.0
var y = 0.0
for (j in row.indices) {
x += row[j] * to[j].x
y += row[j] * to[j].y
}
Vec2((x / w).toFloat(), (y / w).toFloat())
}
}
}
fun barycentricMap(to: Array<DoubleArray>): Array<DoubleArray>
barycentricMap for points of any dimension: toj` is targetj`'s coordinates.
fun barycentricMap(to: Array<DoubleArray>): Array<DoubleArray> {
require(to.size == targets) { "barycentricMap needs the ${targets} targets the plan was solved for, got ${to.size}" }
val d = if (to.isEmpty()) 0 else to[0].size
return Array(sources) { i ->
val w = sourceWeights[i]
DoubleArray(d) { k ->
if (w <= 0.0) Double.NaN else plan[i].indices.sumOf { j -> plan[i][j] * to[j][k] } / w
}
}
}
fun strongest(): IntArray
For each source, the target that receives the most of it.
fun strongest(): IntArray = IntArray(sources) { i ->
var best = 0
for (j in 1 until targets) if (plan[i][j] > plan[i][best]) best = j
best
}
override fun toString(): String
override fun toString(): String =
"TransportPlan(${sources}×${targets}, ε=$epsilon, cost=${"%.6g".format(cost)}, " +
"${if (converged) "converged" else "not converged"} in $iterations, error=${"%.2g".format(max(rowError, columnError))})"
}
fun sinkhorn
Entropy-regularised optimal transport by log-domain Sinkhorn iteration; see the file overview.
cost is n × m. sourceWeights (size n) and targetWeights (size m) are how much mass sits
at each point; they are normalised, so only their proportions matter, and the default is uniform.
Iteration stops when both marginals are within tolerance of the weights, checked every
checkEvery sweeps, or after maxIterations. initialF and initialG warm-start the dual
potentials, which is how multiscaleSinkhorn reuses a coarse solve.
body — 78 lines, from line 218
fun sinkhorn(
cost: Array<DoubleArray>,
epsilon: Double,
sourceWeights: DoubleArray? = null,
targetWeights: DoubleArray? = null,
maxIterations: Int = 10_000,
tolerance: Double = 1e-9,
checkEvery: Int = 20,
initialF: DoubleArray? = null,
initialG: DoubleArray? = null,
): TransportPlan {
val n = cost.size
val m = if (n == 0) 0 else cost[0].size
require(epsilon > 0.0) { "epsilon must be positive, got $epsilon" }
require(n > 0 && m > 0) { "sinkhorn needs at least one source and one target" }
require(cost.all { it.size == m }) { "cost must be a rectangular n × m matrix" }
require(checkEvery >= 1) { "checkEvery must be at least 1" }
val a = normalisedWeights(sourceWeights, n, "sourceWeights")
val b = normalisedWeights(targetWeights, m, "targetWeights")
val logA = DoubleArray(n) { ln(max(a[it], MIN_MASS)) }
val logB = DoubleArray(m) { ln(max(b[it], MIN_MASS)) }
val f = initialF?.also { require(it.size == n) { "initialF must have $n entries" } }?.copyOf() ?: DoubleArray(n)
val g = initialG?.also { require(it.size == m) { "initialG must have $m entries" } }?.copyOf() ?: DoubleArray(m)
val invEps = 1.0 / epsilon
val work = DoubleArray(max(n, m))
var iteration = 0
while (true) {
// rows to a, then columns to b; each a stabilised log-sum-exp over the plan's exponent
for (i in 0 until n) {
var top = Double.NEGATIVE_INFINITY
for (j in 0 until m) {
val v = (f[i] + g[j] - cost[i][j]) * invEps
work[j] = v
if (v > top) top = v
}
var sum = 0.0
for (j in 0 until m) sum += exp(work[j] - top)
f[i] += epsilon * (logA[i] - (top + ln(sum)))
}
for (j in 0 until m) {
var top = Double.NEGATIVE_INFINITY
for (i in 0 until n) {
val v = (f[i] + g[j] - cost[i][j]) * invEps
work[i] = v
if (v > top) top = v
}
var sum = 0.0
for (i in 0 until n) sum += exp(work[i] - top)
g[j] += epsilon * (logB[j] - (top + ln(sum)))
}
iteration++
if (iteration % checkEvery == 0 || iteration >= maxIterations) {
val plan = Array(n) { i -> DoubleArray(m) { j -> exp((f[i] + g[j] - cost[i][j]) * invEps) } }
var rowError = 0.0
for (i in 0 until n) rowError = max(rowError, abs(plan[i].sum() - a[i]))
var columnError = 0.0
for (j in 0 until m) columnError = max(columnError, abs((0 until n).sumOf { plan[it][j] } - b[j]))
val converged = max(rowError, columnError) <= tolerance
if (converged || iteration >= maxIterations) {
var transport = 0.0
var entropy = 0.0
for (i in 0 until n) for (j in 0 until m) {
val p = plan[i][j]
if (p > 0.0) {
transport += p * cost[i][j]
entropy += p * (ln(p) - 1.0)
}
}
return TransportPlan(plan, a, b, f, g, epsilon, transport, transport + epsilon * entropy,
iteration, converged, rowError, columnError)
}
}
}
}
fun sinkhorn
sinkhorn between two 2D point sets with squared-distance cost, so epsilon is an area in the
points' units. The sets may differ in size; the weights default to uniform.
fun sinkhorn(
from: List<Vec2>,
to: List<Vec2>,
epsilon: Double,
sourceWeights: DoubleArray? = null,
targetWeights: DoubleArray? = null,
maxIterations: Int = 10_000,
tolerance: Double = 1e-9,
): TransportPlan = sinkhorn(squaredDistanceCost(from, to), epsilon, sourceWeights, targetWeights, maxIterations, tolerance)
fun sinkhornDivergence
The Sinkhorn divergence OTε(μ, ν) − (OTε(μ, μ) + OTε(ν, ν)) / 2 between two weighted 2D point
sets, with squared-distance cost: a distance-like measure of how different two distributions are
that, unlike the regularised cost alone, is zero when they are the same and does not reward
blurring. It costs three sinkhorn solves.
fun sinkhornDivergence(
from: List<Vec2>,
to: List<Vec2>,
epsilon: Double,
sourceWeights: DoubleArray? = null,
targetWeights: DoubleArray? = null,
maxIterations: Int = 10_000,
tolerance: Double = 1e-9,
): Double {
val xy = sinkhorn(squaredDistanceCost(from, to), epsilon, sourceWeights, targetWeights, maxIterations, tolerance)
val xx = sinkhorn(squaredDistanceCost(from, from), epsilon, sourceWeights, sourceWeights, maxIterations, tolerance)
val yy = sinkhorn(squaredDistanceCost(to, to), epsilon, targetWeights, targetWeights, maxIterations, tolerance)
return xy.objective - 0.5 * (xx.objective + yy.objective)
}
fun multiscaleSinkhorn
sinkhorn for larger point sets, coarse to fine. Each set is reduced to a pyramid of
farthest-point-sampled centres, each centre carrying the weight of the points nearest it, halving
until about minSize remain; the coarsest pair is solved first, and each finer solve starts from
the potentials of the one before, copied from each point's centre. The finest level is the full
problem, so the answer is the same plan sinkhorn would reach; what changes is how many sweeps it
takes to get there. Points are rows of coordinates of any dimension, with squared-distance cost.
body — 43 lines, from line 340
fun multiscaleSinkhorn(
from: Array<DoubleArray>,
to: Array<DoubleArray>,
epsilon: Double,
sourceWeights: DoubleArray? = null,
targetWeights: DoubleArray? = null,
minSize: Int = 64,
maxIterationsPerLevel: Int = 5_000,
tolerance: Double = 1e-9,
): TransportPlan {
require(from.isNotEmpty() && to.isNotEmpty()) { "multiscaleSinkhorn needs at least one source and one target" }
require(minSize >= 1) { "minSize must be at least 1" }
val pyramidFrom = transportPyramid(from, normalisedWeights(sourceWeights, from.size, "sourceWeights"), minSize, seed = 0)
val pyramidTo = transportPyramid(to, normalisedWeights(targetWeights, to.size, "targetWeights"), minSize, seed = 1)
// both pyramids end at the full sets; drop the coarsest levels of the deeper one
val levels = min(pyramidFrom.size, pyramidTo.size)
val fromLevels = pyramidFrom.takeLast(levels)
val toLevels = pyramidTo.takeLast(levels)
var f: DoubleArray? = null
var g: DoubleArray? = null
var result: TransportPlan? = null
for (level in 0 until levels) {
val x = fromLevels[level]
val y = toLevels[level]
val finest = level == levels - 1
result = sinkhorn(
squaredDistanceCost(x.points, y.points), epsilon, x.weights, y.weights,
maxIterationsPerLevel, if (finest) tolerance else max(1e-6, tolerance), initialF = f, initialG = g
)
if (!finest) {
// each finer point starts from its centre's potential
val fineFrom = fromLevels[level + 1]
val fineTo = toLevels[level + 1]
val coarseF = result.f
val coarseG = result.g
f = DoubleArray(fineFrom.points.size) { coarseF[fineFrom.parent[it]] }
g = DoubleArray(fineTo.points.size) { coarseG[fineTo.parent[it]] }
}
}
return result!!
}
fun squaredDistanceCost(from: List<Vec2>, to: List<Vec2>): Array<DoubleArray>
The n × m matrix of squared distances from each of from to each of to.
fun squaredDistanceCost(from: List<Vec2>, to: List<Vec2>): Array<DoubleArray> = Array(from.size) { i ->
val p = from[i]
DoubleArray(to.size) { j ->
val dx = (p.x - to[j].x).toDouble()
val dy = (p.y - to[j].y).toDouble()
dx * dx + dy * dy
}
}
fun squaredDistanceCost(from: Array<DoubleArray>, to: Array<DoubleArray>): Array<DoubleArray>
squaredDistanceCost for points of any dimension, one row of coordinates per point.
fun squaredDistanceCost(from: Array<DoubleArray>, to: Array<DoubleArray>): Array<DoubleArray> = Array(from.size) { i ->
DoubleArray(to.size) { j -> squaredDistance(from[i], to[j]) }
}
private const val MIN_MASS = 1e-300
private fun normalisedWeights(weights: DoubleArray?, size: Int, name: String): DoubleArray {
if (weights == null) return DoubleArray(size) { 1.0 / size }
require(weights.size == size) { "$name has ${weights.size} entries for $size points" }
require(weights.all { it >= 0.0 && it.isFinite() }) { "$name must be finite and non-negative" }
val total = weights.sum()
require(total > 0.0) { "$name must not all be zero" }
return DoubleArray(size) { weights[it] / total }
}
private fun squaredDistance(p: DoubleArray, q: DoubleArray): Double {
require(p.size == q.size) { "points of different dimension: ${p.size} and ${q.size}" }
var s = 0.0
for (k in p.indices) {
val t = p[k] - q[k]
s += t * t
}
return s
}
One level of a multiscaleSinkhorn pyramid; parent[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) is the index, one level coarser, of point i's centre.
private class TransportLevel(val points: Array<DoubleArray>, val weights: DoubleArray, val parent: IntArray)
Coarse to fine, ending with the input itself.
private fun transportPyramid(points: Array<DoubleArray>, weights: DoubleArray, minSize: Int, seed: Int): List<TransportLevel> {
val fineToCoarse = mutableListOf(TransportLevel(points, weights, IntArray(0)))
var current = fineToCoarse.last()
var levelSeed = seed
while (current.points.size / 2 >= minSize) {
val k = current.points.size / 2
val centres = farthestPointSample(current.points, k, levelSeed++)
val centrePoints = Array(k) { current.points[centres[it]].copyOf() }
val parent = IntArray(current.points.size)
val centreWeights = DoubleArray(k)
for (i in current.points.indices) {
var best = 0
var bestDistance = Double.POSITIVE_INFINITY
for (c in 0 until k) {
val d = squaredDistance(current.points[i], centrePoints[c])
if (d < bestDistance) {
bestDistance = d
best = c
}
}
parent[i] = best
centreWeights[best] += current.weights[i]
}
// the finer level records its parents in this coarser one
fineToCoarse[fineToCoarse.lastIndex] = TransportLevel(current.points, current.weights, parent)
current = TransportLevel(centrePoints, centreWeights, IntArray(0))
fineToCoarse += current
}
return fineToCoarse.reversed()
}
k indices into points, each the farthest from those already chosen, starting from a seeded one.
private fun farthestPointSample(points: Array<DoubleArray>, k: Int, seed: Int): IntArray {
val n = points.size
val chosen = IntArray(k)
chosen[0] = ((seed % n) + n) % n
val nearest = DoubleArray(n) { squaredDistance(points[it], points[chosen[0]]) }
for (t in 1 until k) {
var far = 0
for (i in 1 until n) if (nearest[i] > nearest[far]) far = i
chosen[t] = far
for (i in 0 until n) nearest[i] = min(nearest[i], squaredDistance(points[i], points[far]))
}
return chosen
}