§ torch.ops.assignment · torch.ops.transport

torchmatch

LAP solvers (JV, Munkres, Greedy) and optimal transport (Sinkhorn, EMD) registered as PyTorch custom ops. Batch-native. torch.compile-ready.

python · cu128
>>> import torch, torchmatch
>>> cost = torch.rand(8, 8)
>>> torchmatch.assignment.solve(cost)
tensor([5, 7, 0, 4, 1, 6, 2, 3])
>>> costs = torch.rand(64, 32, 32, device="cuda")
>>> torchmatch.assignment.solve(costs).shape
torch.Size([64, 32])
>>> x = torch.randn(512, 3, device="cuda")
>>> y = torch.randn(512, 3, device="cuda")
>>> torchmatch.transport.samples.loss(x, y)
tensor(0.0342, device='cuda:0')
01

Use cases

Assignment covers discrete one-to-one matching; transport covers continuous distributions and point clouds.

Assignment
U.01 — OBJECT TRACKINGSORT · ByteTrack · BoT-SORT · DeepSORT

Detection-to-track association

A tracker reads detections each frame and assigns each one to a track. Build the per-frame (N_tracks × M_detections) cost matrix — typically 1 − IoU (intersection-over-union overlap) between predicted and detected bounding boxes, optionally filtered by distance between box centers (centroid gating) or visual similarity (appearance distance) — stack frames into a batch, and jonker_dense_batch solves the whole batch in one CUDA launch.

The _unpacked variant returns (matches, unmatched_tracks, unmatched_dets, n_matched) directly — removing the per-frame Python loop that most codebases use to separate matched from unmatched indices after getting the raw assignment.

import torch
import torchmatch

# costs: (B, N_tracks, M_dets) of `1 - IoU`,
# with +inf where centroid distance > gate.
costs = build_iou_cost_batch(
    tracks, detections, gate=0.3,
)                                          # (B, N, M)

matches, ur, uc, n_matched = torchmatch.assignment.solve(
    costs, unpack=True,
)
# matches[b, :n_matched[b]] = (track_idx, det_idx) pairs
U.02 — SET PREDICTIONDETR · MaskFormer · Mask2Former · RT-DETR

Hungarian matcher for the loss

DETR-style object detectors find the lowest-cost one-to-one pairing (the Hungarian assignment) between model predictions and ground-truth targets before computing the per-pair loss term. The matcher runs once per image every training step, so it dominates training wall time when called naively in a Python loop. Batch the per-image cost matrices into one (B, N_pred, N_gt) tensor; jonker_dense_batch solves them all at once.

When every problem is square (prediction count fixed, GT padded to the same count), jonker_compact_batch runs the tighter AVX2-gather inner loop on CPU. For mixed shapes, use jonker_dense_batch.

import torch
import torchmatch

# Combined L1 + GIoU + class cost between every
# prediction and every ground-truth target, padded
# to a common N_gt with +inf in padded columns.
costs = compute_match_cost(preds, targets)
# → (B, N_pred, N_gt + N_pad)

matches = torchmatch.assignment.solve(costs)
# matches[b, i] == -1 marks pad-column hits;
# mask them out of the per-pair loss.
U.03 — EVALUATIONUnsupervised semseg · ARI · ReID · Codebook alignment

Cluster label matching

Clustering algorithms assign arbitrary label numbers, so "cluster 0" in one run may correspond to "cluster 2" in another. Metrics like Adjusted Rand Index therefore need to find the optimal one-to-one relabelling between predicted and ground-truth cluster IDs before counting agreements — that relabelling is exactly the assignment problem. Build the cost as the negative confusion-matrix entry; the linear assignment solver (LAP) returns the optimal cluster-to-cluster mapping.

Square dense costs at moderate K (≤ 256) are the regime jonker_compact was built for. For K ≥ 512, jonker_dense takes over.

import torch
import torchmatch

# K x K confusion-style matrix. Negate so the LAP
# minimizes disagreement instead of maximizing it.
cost = -confusion_matrix.to(torch.float32)
mapping = torchmatch.assignment.solve(cost)               # (K,)

relabelled = mapping[predicted_labels]
accuracy = (relabelled == ground_truth).float().mean()
U.04 — DROP-INscipy.optimize.linear_sum_assignment · Hungarian solvers

A faster scipy LAP

When a linear assignment problem (LAP) appears in a tight Python loop and SciPy is the bottleneck, swap the call. jonker_dense produces the same optimal cost, accepts the same rectangular input, and stays inside torch so the cost matrix never round-trips through numpy. For a batch of independent LAPs, jonker_dense_batch runs at::parallel_for across problems on CPU or the tiled CUDA kernel on GPU.

import torch
import torchmatch

# Before:
#   from scipy.optimize import linear_sum_assignment
#   r, c = linear_sum_assignment(cost.cpu().numpy())

# After: stays in torch, batches naturally.
row_to_col = torchmatch.assignment.solve(cost)
matched = row_to_col >= 0
total = cost[
    torch.arange(cost.size(0))[matched],
    row_to_col[matched],
].sum()
Optimal transport
U.01 — GEOMETRIC LEARNINGPointNet · ShapeFlow · 3D-LFM · Wasserstein AE

Point-cloud Wasserstein loss

For training generative models over point sets, transport.samples.loss(x, y) computes the Sinkhorn approximation of the Wasserstein distance between two point clouds — a measure of how much work it takes to move one distribution onto the other — without ever building the full N×M pairwise-cost matrix in memory. A Triton streaming kernel handles the computation directly in fast CUDA registers. Gradients flow analytically through both x and y; pass debias=True for the symmetric Sinkhorn divergence variant.

import torch
import torchmatch

# predicted and ground-truth 3-D point clouds
pred = model(z)                                 # (N, 3)
gt   = target_cloud.to(pred.device)             # (M, 3)

loss = torchmatch.transport.samples.loss(pred, gt)
loss.backward()
U.02 — SET PREDICTION / TRAININGSlot Attention · Soft-DETR · Differentiable permutations

Differentiable soft matching

Hard one-to-one matching (like the Hungarian algorithm) is not differentiable — gradients cannot flow back through a discrete matching step. Replace it with a Sinkhorn plan: transport.matrix.solve(cost) returns a regularized transport plan T ∈ 0,1^{N×M} — fractional soft assignments stored in log-domain for numerical stability — that differentiate smoothly through the cost matrix. Reduce the regularization (reg) toward zero to sharpen the plan toward a hard permutation. Both 2-D (N, M) and batched 3-D (B, N, M) cost tensors are accepted.

import torch
import torchmatch
from torchmatch.transport.matrix import Backend

# (B, N, M) cost: negative cosine or L2 similarity
cost = -torch.einsum("bnd,bmd->bnm", pred_feats, gt_feats)

# log-plan (B, N, M); differentiable w.r.t. cost
log_plan = torchmatch.transport.matrix.solve(
    cost,
    backend=Backend.LOG_SINKHORN,
)
loss = (log_plan.exp() * cost).sum(-1).mean()
loss.backward()
U.03 — DOMAIN ADAPTATION / ROBUST OTDomain shift · Partial shape matching · Noisy labels

Unbalanced transport

Standard optimal transport requires the source and target distributions to have exactly equal total mass — every point must be fully accounted for. If one set contains outliers or the two domains differ in size, this constraint forces those outliers into the plan and corrupts the result. UNBALANCED_SINKHORN relaxes the marginal constraints via a KL-divergence penalty controlled by the reach (or reach_x, reach_y) parameter. Smaller reach is more lenient; reach → ∞ recovers balanced OT. Both the matrix and samples faces support unbalanced mode.

import torch
import torchmatch
from torchmatch.transport.matrix import Backend

cost = compute_cost(source_features, target_features)

# reach controls the KL marginal penalty; smaller = more lenient
plan = torchmatch.transport.matrix.solve(
    cost,
    backend=Backend.UNBALANCED_SINKHORN,
    reach=0.5,
)
02

What AUTO picks

The assignment dispatcher routes by device, shape, and size. Seven rows of the decision tree; the full version is in the choosing tutorial.

WHAT YOU HAVEUSEWHY
One cost matrix on CPU · N×M ≤ 64jonker_scalarSequential reference; lowest overhead at tiny sizes
One cost matrix on CPU · square · any sizejonker_compactTightest AVX2-gather inner loop
One cost matrix on CPU · rectangular · any sizejonker_denseAVX2 flat-pointer; rectangular-capable
A batch of cost matrices on CPUjonker_compact_batch ·or· jonker_dense_batchat::parallel_for over problems; compact when square
A batch of square K ≤ 64 matrices on CUDAjonker_dense_batch (CUDA)Single-block-per-problem tiled kernel; CUDA-graph-safe
One cost matrix on CUDA · N ≥ 32lawlerLawler's parallel-BFS tree augmentation; dense-favored
One cost matrix on CUDA · N < 32 or tied / quantizedmunkresMunkres' single-path Hungarian; sparse-favored

Full decision tree →

03

Transport backends

The transport dispatcher resolves by problem type. Pick the backend that matches your cost representation, marginal constraints, and differentiability needs.

WHAT YOU HAVEUSEWHY
Differentiable soft plan from cost matrixLOG_SINKHORNDefault AUTO; Sinkhorn LSE loop; returns log-plan (B, N, M); grads w.r.t. cost
Symmetric OT metric / training lossSINKHORN_DIVERGENCEDebiased; positive; symmetric; cancels self-transport bias; returns scalar per batch
Partial matching or unequal total massUNBALANCED_SINKHORNKL-relaxed marginal constraints; tune via reach; handles outliers
Two raw point clouds on CUDAtransport.samples.lossTriton streaming kernel; no N×M allocation; fuses cost + LSE; grads through both sets
Exact Earth Mover's Distance (no regularization)EXACT_EMDNetwork simplex; no bias; CPU-only; reference quality for small problems

Transport reference →

INSTALL
$ pip install torchmatch

REQUIRES Python 3.13 · PyTorch ≥ 2.11 · x86-64 LinuxCUDA WHEELS cu126 · cu128 · cu130