Sinkhorn Algorithm
Sinkhorn solves entropy-smoothed transport by alternately rescaling matrix rows and columns, giving fast GPU-friendly approximate couplings.
Why Does This Exist?
Exact OT linear programs scale cubically and resist GPUs. Adding a little entropy turns the problem into matrix scaling with a unique solution reachable by alternating normalization: divide rows to hit source margins, divide columns to hit target margins, repeat. Each step is a batched multiply, so thousands of transport problems solve in parallel on accelerators.
It is the default OT solver in ML pipelines. Theory: entropic regularization. Primal: Monge and Kantorovich.
The one idea you need first
The Gibbs kernel turns costs into affinities: cheap routes get weight near 1, expensive ones near 0. The optimal coupling has factored form .
Think of It Like This
Balancing a spreadsheet two ways
A budget table must hit row totals and column totals at once. Fix the rows and columns break; fix the columns and rows break. So you alternate: scale each row to its target, then each column, then rows again.
Each pass halves the remaining error and the table settles on the unique sheet satisfying both. Sinkhorn is that alternating fix with multiplicative instead of additive corrections.
Where the analogy stops: the kernel encodes costs exponentially, so tiny regularization makes entries underflow without log-domain care.
How It Actually Works
Iterate , from . Each sweep is . Convergence is linear with rate tied to the kernel's contraction; small sharpens the plan but slows and destabilizes iterations. Log-domain (softmin updates on potentials ) fixes underflow.
Worked example
, , : :
- Init : . .
- . . Already converged in one step by symmetry.
- Plan : diagonal , off-diagonal . Cost plus entropy smoothing. Exact LP would put on the diagonal with cost . The gap is the smoothing price at .
Code
import numpy as np
def sinkhorn(mu: np.ndarray, nu: np.ndarray, C: np.ndarray, eps: float, iters: int = 100) -> np.ndarray: K = np.exp(-C / eps) # Gibbs kernel from costs v = np.ones_like(nu) for _ in range(iters): u = mu / (K @ v) # match row margins v = nu / (K.T @ u) # match column margins return np.diag(u) @ K @ np.diag(v)
C = np.array([[0.0, 1.0], [1.0, 0.0]])print(np.round(sinkhorn(np.array([0.5, 0.5]), np.array([0.5, 0.5]), C, 0.5), 3))# -> [[0.44 0.06] [0.06 0.44]] matching the hand computationWatch Out For
Running Sinkhorn in raw space with tiny epsilon
Entries underflow to zero below about in float32, producing division by zero and NaN plans. Small triggers it first.
Switch to log-domain softmin updates or stabilize with periodic rescaling once drops under the cost median.
Stopping after a fixed 10 iterations always
Loose tolerance leaves margin errors that downstream barycenters amplify into visible artifacts. Fixed counts ignore problem difficulty.
Check marginal violation and iterate to or your loss tolerance.
The Quick Version
- Sinkhorn solves entropic OT by alternating row and column rescaling.
- The Gibbs kernel encodes costs; scaling vectors enforce margins.
- Each iteration is matrix-vector products, hence GPU friendly.
- Small regularization sharpens plans but needs log-domain arithmetic.
- Verify marginal errors instead of trusting fixed iteration counts.