From 04c12a0b91c058fec187bb69c7a81a7e80c4dc22 Mon Sep 17 00:00:00 2001 From: nathanneike Date: Tue, 28 Oct 2025 16:01:32 +0100 Subject: [PATCH 1/9] Add sparse EMD solver with unit tests - Implement sparse bipartite graph EMD solver in C++ - Add Python bindings for sparse solver (emd_wrap.pyx, _network_simplex.py) - Add unit tests to verify sparse and dense solvers produce identical results - Tests use augmented k-NN approach to ensure fair comparison - Update setup.py to include sparse solver compilation Both test_emd_sparse_vs_dense() and test_emd2_sparse_vs_dense() verify: * Identical costs between sparse and dense solvers * Marginal constraint satisfaction for both solvers --- ot/lp/EMD.h | 18 ++ ot/lp/EMD_wrapper.cpp | 155 +++++++++++++++++ ot/lp/_network_simplex.py | 302 +++++++++++++++++++++++++++------- ot/lp/emd_wrap.pyx | 76 +++++++++ ot/lp/sparse_bipartitegraph.h | 281 +++++++++++++++++++++++++++++++ setup.py | 14 +- test/test_ot.py | 149 ++++++++++++++++- 7 files changed, 932 insertions(+), 63 deletions(-) create mode 100644 ot/lp/sparse_bipartitegraph.h diff --git a/ot/lp/EMD.h b/ot/lp/EMD.h index b56f0601b..efa839bcf 100644 --- a/ot/lp/EMD.h +++ b/ot/lp/EMD.h @@ -32,6 +32,24 @@ enum ProblemType { int EMD_wrap(int n1,int n2, double *X, double *Y,double *D, double *G, double* alpha, double* beta, double *cost, uint64_t maxIter); int EMD_wrap_omp(int n1,int n2, double *X, double *Y,double *D, double *G, double* alpha, double* beta, double *cost, uint64_t maxIter, int numThreads); +int EMD_wrap_sparse( + int n1, + int n2, + double *X, + double *Y, + uint64_t n_edges, // Number of edges in sparse graph + int64_t *edge_sources, // Source indices for each edge (n_edges) + int64_t *edge_targets, // Target indices for each edge (n_edges) + double *edge_costs, // Cost for each edge (n_edges) + int64_t *flow_sources_out, // Output: source indices of non-zero flows + int64_t *flow_targets_out, // Output: target indices of non-zero flows + double *flow_values_out, // Output: flow values + uint64_t *n_flows_out, + double *alpha, // Output: dual variables for sources (n1) + double *beta, // Output: dual variables for targets (n2) + double *cost, // Output: total transportation cost + uint64_t maxIter // Maximum iterations for solver +); #endif diff --git a/ot/lp/EMD_wrapper.cpp b/ot/lp/EMD_wrapper.cpp index 4aa5a6e72..7b4b9ed6e 100644 --- a/ot/lp/EMD_wrapper.cpp +++ b/ot/lp/EMD_wrapper.cpp @@ -15,8 +15,10 @@ #include "network_simplex_simple.h" #include "network_simplex_simple_omp.h" +#include "sparse_bipartitegraph.h" #include "EMD.h" #include +#include int EMD_wrap(int n1, int n2, double *X, double *Y, double *D, double *G, @@ -216,3 +218,156 @@ int EMD_wrap_omp(int n1, int n2, double *X, double *Y, double *D, double *G, return ret; } + +// ============================================================================ +// SPARSE VERSION: Accepts edge list instead of dense cost matrix +// ============================================================================ +int EMD_wrap_sparse( + int n1, + int n2, + double *X, + double *Y, + uint64_t n_edges, + int64_t *edge_sources, + int64_t *edge_targets, + double *edge_costs, + int64_t *flow_sources_out, + int64_t *flow_targets_out, + double *flow_values_out, + uint64_t *n_flows_out, + double *alpha, + double *beta, + double *cost, + uint64_t maxIter +) { + using namespace lemon; + + uint64_t n = 0; + for (int i = 0; i < n1; i++) { + double val = *(X + i); + if (val > 0) { + n++; + } else if (val < 0) { + return INFEASIBLE; + } + } + + uint64_t m = 0; + for (int i = 0; i < n2; i++) { + double val = *(Y + i); + if (val > 0) { + m++; + } else if (val < 0) { + return INFEASIBLE; + } + } + + std::vector indI(n); // indI[graph_idx] = original_source_idx + std::vector indJ(m); // indJ[graph_idx] = original_target_idx + std::vector weights1(n); // Source masses (positive only) + std::vector weights2(m); // Target masses (negative for demand) + + // Create reverse mapping: original_idx → graph_idx + std::vector source_to_graph(n1, -1); + std::vector target_to_graph(n2, -1); + + uint64_t cur = 0; + for (int i = 0; i < n1; i++) { + double val = *(X + i); + if (val > 0) { + weights1[cur] = val; // Store the mass + indI[cur] = i; // Forward map: graph → original + source_to_graph[i] = cur; // Reverse map: original → graph + cur++; + } + } + + cur = 0; + for (int i = 0; i < n2; i++) { + double val = *(Y + i); + if (val > 0) { + weights2[cur] = -val; + indJ[cur] = i; // Forward map: graph → original + target_to_graph[i] = cur; // Reverse map: original → graph + cur++; + } + } + + typedef SparseBipartiteDigraph Digraph; + DIGRAPH_TYPEDEFS(Digraph); + + Digraph di(n, m); + + std::vector> edges; // (source, target) pairs + std::vector edge_to_arc; // edge_to_arc[k] = arc ID for edge k + std::vector arc_costs; // arc_costs[arc_id] = cost (for O(1) lookup) + edges.reserve(n_edges); + edge_to_arc.reserve(n_edges); + + uint64_t valid_edge_count = 0; + for (uint64_t k = 0; k < n_edges; k++) { + int64_t src_orig = edge_sources[k]; + int64_t tgt_orig = edge_targets[k]; + int64_t src = source_to_graph[src_orig]; + int64_t tgt = target_to_graph[tgt_orig]; + + if (src >= 0 && tgt >= 0) { + edges.emplace_back(src, tgt + n); + edge_to_arc.push_back(valid_edge_count); + arc_costs.push_back(edge_costs[k]); // Store cost indexed by arc ID + valid_edge_count++; + } else { + edge_to_arc.push_back(UINT64_MAX); + } + } + + + di.buildFromEdges(edges); + + NetworkSimplexSimple net( + di, true, (int)(n + m), di.arcNum(), maxIter + ); + + net.supplyMap(&weights1[0], (int)n, &weights2[0], (int)m); + + for (uint64_t k = 0; k < n_edges; k++) { + if (edge_to_arc[k] != UINT64_MAX) { + net.setCost(edge_to_arc[k], edge_costs[k]); + } + } + + int ret = net.run(); + + if (ret == (int)net.OPTIMAL || ret == (int)net.MAX_ITER_REACHED) { + *cost = 0; + *n_flows_out = 0; + + Arc a; + di.first(a); + for (; a != INVALID; di.next(a)) { + uint64_t i = di.source(a); + uint64_t j = di.target(a); + double flow = net.flow(a); + + uint64_t orig_i = indI[i]; + uint64_t orig_j = indJ[j - n]; + + + double arc_cost = arc_costs[a]; + + *cost += flow * arc_cost; + + + *(alpha + orig_i) = -net.potential(i); + *(beta + orig_j) = net.potential(j); + + if (flow > 1e-15) { + flow_sources_out[*n_flows_out] = orig_i; + flow_targets_out[*n_flows_out] = orig_j; + flow_values_out[*n_flows_out] = flow; + (*n_flows_out)++; + } + } + } + return ret; +} \ No newline at end of file diff --git a/ot/lp/_network_simplex.py b/ot/lp/_network_simplex.py index 492e4c7ac..3ce63a874 100644 --- a/ot/lp/_network_simplex.py +++ b/ot/lp/_network_simplex.py @@ -11,9 +11,11 @@ import numpy as np import warnings +import scipy.sparse as sp +import time from ..utils import list_to_array, check_number_threads from ..backend import get_backend -from .emd_wrap import emd_c, check_result +from .emd_wrap import emd_c, emd_c_sparse, check_result def center_ot_dual(alpha0, beta0, a=None, b=None): @@ -172,6 +174,8 @@ def emd( center_dual=True, numThreads=1, check_marginals=True, + sparse=False, + return_matrix=False, ): r"""Solves the Earth Movers distance problem and returns the OT matrix @@ -232,6 +236,12 @@ def emd( check_marginals: bool, optional (default=True) If True, checks that the marginals mass are equal. If False, skips the check. + sparse: bool, optional (default=False) + If True, uses the sparse solver that only stores edges with finite costs. + When sparse=True, M should be a scipy.sparse matrix. + return_matrix: bool, optional (default=True) + If True, returns the transport matrix. If False and sparse=True, returns + sparse flow representation in log. Returns @@ -272,38 +282,64 @@ def emd( ot.optim.cg : General regularized OT """ - a, b, M = list_to_array(a, b, M) - nx = get_backend(M, a, b) + edge_sources = None + edge_targets = None + edge_costs = None + n1, n2 = None, None + + if sparse: + if sp.issparse(M): + if not isinstance(M, sp.coo_matrix): + M_coo = sp.coo_matrix(M) + else: + M_coo = M + + edge_sources = M_coo.row if M_coo.row.dtype == np.int64 else M_coo.row.astype(np.int64) + edge_targets = M_coo.col if M_coo.col.dtype == np.int64 else M_coo.col.astype(np.int64) + edge_costs = M_coo.data if M_coo.data.dtype == np.float64 else M_coo.data.astype(np.float64) + n1, n2 = M_coo.shape + elif isinstance(M, tuple) and len(M) == 3: + edge_sources = np.asarray(M[0], dtype=np.int64) + edge_targets = np.asarray(M[1], dtype=np.int64) + edge_costs = np.asarray(M[2], dtype=np.float64) + n1 = int(edge_sources.max() + 1) + n2 = int(edge_targets.max() + 1) + else: + raise ValueError("When sparse=True, M must be a scipy sparse matrix or a tuple (row, col, data)") + + a, b = list_to_array(a, b) + else: + a, b, M = list_to_array(a, b, M) + + nx = get_backend(a, b) if len(a) != 0: type_as = a elif len(b) != 0: type_as = b else: - type_as = M + type_as = a - # if empty array given then use uniform distributions if len(a) == 0: - a = nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] + a = nx.ones((n1,), type_as=type_as) / n1 if n1 else nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] if len(b) == 0: - b = nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] + b = nx.ones((n2,), type_as=type_as) / n2 if n2 else nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] - # convert to numpy - M, a, b = nx.to_numpy(M, a, b) + if sparse: + a, b = nx.to_numpy(a, b) + else: + M, a, b = nx.to_numpy(M, a, b) + M = np.asarray(M, dtype=np.float64, order="C") - # ensure float64 a = np.asarray(a, dtype=np.float64) b = np.asarray(b, dtype=np.float64) - M = np.asarray(M, dtype=np.float64, order="C") - # if empty array given then use uniform distributions - if len(a) == 0: - a = np.ones((M.shape[0],), dtype=np.float64) / M.shape[0] - if len(b) == 0: - b = np.ones((M.shape[1],), dtype=np.float64) / M.shape[1] + + if n1 is None: + n1, n2 = M.shape assert ( - a.shape[0] == M.shape[0] and b.shape[0] == M.shape[1] + a.shape[0] == n1 and b.shape[0] == n2 ), "Dimension mismatch, check dimensions of M with a and b" # ensure that same mass @@ -321,13 +357,26 @@ def emd( numThreads = check_number_threads(numThreads) - G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) + if edge_sources is not None: + flow_sources, flow_targets, flow_values, cost, u, v, result_code = emd_c_sparse( + a, b, edge_sources, edge_targets, edge_costs, numItermax + ) + if return_matrix: + G = np.zeros((len(a), len(b)), dtype=np.float64) + G[flow_sources, flow_targets] = flow_values + else: + G = None + else: + G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) if center_dual: u, v = center_ot_dual(u, v, a, b) if np.any(~asel) or np.any(~bsel): - u, v = estimate_dual_null_weights(u, v, a, b, M) + if edge_sources is not None: + u, v = center_ot_dual(u, v, a, b) + else: + u, v = estimate_dual_null_weights(u, v, a, b, M) result_code_string = check_result(result_code) if not nx.is_floating_point(type_as): @@ -338,15 +387,29 @@ def emd( "histogram consists of floating point elements.", stacklevel=2, ) + if log: - log = {} - log["cost"] = cost - log["u"] = nx.from_numpy(u, type_as=type_as) - log["v"] = nx.from_numpy(v, type_as=type_as) - log["warning"] = result_code_string - log["result_code"] = result_code - return nx.from_numpy(G, type_as=type_as), log - return nx.from_numpy(G, type_as=type_as) + log_dict = {} + log_dict["cost"] = cost + log_dict["u"] = nx.from_numpy(u, type_as=type_as) + log_dict["v"] = nx.from_numpy(v, type_as=type_as) + log_dict["warning"] = result_code_string + log_dict["result_code"] = result_code + + if edge_sources is not None and not return_matrix: + log_dict["flow_sources"] = flow_sources + log_dict["flow_targets"] = flow_targets + log_dict["flow_values"] = flow_values + + if G is not None: + return nx.from_numpy(G, type_as=type_as), log_dict + else: + return None, log_dict + + if G is not None: + return nx.from_numpy(G, type_as=type_as) + else: + raise ValueError("Cannot return matrix when return_matrix=False and sparse=True without log=True") def emd2( @@ -356,10 +419,12 @@ def emd2( processes=1, numItermax=100000, log=False, - return_matrix=False, + center_dual=True, numThreads=1, check_marginals=True, + sparse=False, + return_matrix=False ): r"""Solves the Earth Movers distance problem and returns the loss @@ -420,6 +485,12 @@ def emd2( check_marginals: bool, optional (default=True) If True, checks that the marginals mass are equal. If False, skips the check. + sparse: bool, optional (default=False) + If True, uses the sparse solver that only stores edges with finite costs. + This is memory-efficient when M has many infinite or forbidden edges. + When sparse=True, M should be a scipy.sparse matrix (coo, csr, or csc format) + or a tuple (row_indices, col_indices, costs) representing the edge list. + Edges not included are treated as having infinite cost (forbidden). Returns @@ -460,34 +531,78 @@ def emd2( ot.optim.cg : General regularized OT """ - a, b, M = list_to_array(a, b, M) - nx = get_backend(M, a, b) + edge_sources = None + edge_targets = None + edge_costs = None + n1, n2 = None, None + + if sparse: + if sp.issparse(M): + t0 = time.perf_counter() + if not isinstance(M, sp.coo_matrix): + M_coo = sp.coo_matrix(M) + else: + M_coo = M + t1 = time.perf_counter() + + edge_sources = M_coo.row if M_coo.row.dtype == np.int64 else M_coo.row.astype(np.int64) + edge_targets = M_coo.col if M_coo.col.dtype == np.int64 else M_coo.col.astype(np.int64) + edge_costs = M_coo.data if M_coo.data.dtype == np.float64 else M_coo.data.astype(np.float64) + t2 = time.perf_counter() + print(f"[PY SPARSE] COO conversion: {(t1-t0)*1000:.3f} ms, array copies: {(t2-t1)*1000:.3f} ms") + n1, n2 = M_coo.shape + elif isinstance(M, tuple) and len(M) == 3: + edge_sources = np.asarray(M[0], dtype=np.int64) + edge_targets = np.asarray(M[1], dtype=np.int64) + edge_costs = np.asarray(M[2], dtype=np.float64) + n1 = int(edge_sources.max() + 1) + n2 = int(edge_targets.max() + 1) + else: + raise ValueError( + "When sparse=True, M must be a scipy sparse matrix or a tuple (row, col, data)" + ) + + a, b = list_to_array(a, b) + else: + a, b, M = list_to_array(a, b, M) + + nx = get_backend(a, b) if len(a) != 0: type_as = a elif len(b) != 0: type_as = b else: - type_as = M + type_as = a # Can't use M for sparse case # if empty array given then use uniform distributions if len(a) == 0: - a = nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] + a = nx.ones((n1,), type_as=type_as) / n1 if n1 else nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] if len(b) == 0: - b = nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] + b = nx.ones((n2,), type_as=type_as) / n2 if n2 else nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] + + a0, b0 = a, b + M0 = None if sparse else M - # store original tensors - a0, b0, M0 = a, b, M + if sparse: + edge_costs_original = nx.from_numpy(edge_costs, type_as=type_as) + else: + edge_costs_original = None - # convert to numpy - M, a, b = nx.to_numpy(M, a, b) + if sparse: + a, b = nx.to_numpy(a, b) + else: + M, a, b = nx.to_numpy(M, a, b) + M = np.asarray(M, dtype=np.float64, order="C") a = np.asarray(a, dtype=np.float64) b = np.asarray(b, dtype=np.float64) - M = np.asarray(M, dtype=np.float64, order="C") + + if n1 is None: + n1, n2 = M.shape assert ( - a.shape[0] == M.shape[0] and b.shape[0] == M.shape[1] + a.shape[0] == n1 and b.shape[0] == n2 ), "Dimension mismatch, check dimensions of M with a and b" # ensure that same mass @@ -509,13 +624,36 @@ def emd2( def f(b): bsel = b != 0 - G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) + if edge_sources is not None: + flow_sources, flow_targets, flow_values, cost, u, v, result_code = emd_c_sparse( + a, b, edge_sources, edge_targets, edge_costs, numItermax + ) + + edge_to_idx = {(edge_sources[k], edge_targets[k]): k for k in range(len(edge_sources))} + + grad_edge_costs = np.zeros(len(edge_costs), dtype=np.float64) + for idx in range(len(flow_sources)): + src, tgt, flow = flow_sources[idx], flow_targets[idx], flow_values[idx] + edge_idx = edge_to_idx.get((src, tgt), -1) + if edge_idx >= 0: + grad_edge_costs[edge_idx] = flow + + if return_matrix: + G = np.zeros((len(a), len(b)), dtype=np.float64) + G[flow_sources, flow_targets] = flow_values + else: + G = None + else: + G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) if center_dual: u, v = center_ot_dual(u, v, a, b) if np.any(~asel) or np.any(~bsel): - u, v = estimate_dual_null_weights(u, v, a, b, M) + if edge_sources is not None: + u, v = center_ot_dual(u, v, a, b) + else: + u, v = estimate_dual_null_weights(u, v, a, b, M) result_code_string = check_result(result_code) log = {} @@ -527,30 +665,59 @@ def f(b): "histogram consists of floating point elements.", stacklevel=2, ) - G = nx.from_numpy(G, type_as=type_as) - if return_matrix: - log["G"] = G + + if G is not None: + G = nx.from_numpy(G, type_as=type_as) + if return_matrix: + log["G"] = G log["u"] = nx.from_numpy(u, type_as=type_as) log["v"] = nx.from_numpy(v, type_as=type_as) log["warning"] = result_code_string log["result_code"] = result_code - cost = nx.set_gradients( - nx.from_numpy(cost, type_as=type_as), - (a0, b0, M0), - (log["u"] - nx.mean(log["u"]), log["v"] - nx.mean(log["v"]), G), - ) + + if edge_sources is not None: + cost = nx.set_gradients( + nx.from_numpy(cost, type_as=type_as), + (a0, b0, edge_costs_original), + (log["u"] - nx.mean(log["u"]), log["v"] - nx.mean(log["v"]), nx.from_numpy(grad_edge_costs, type_as=type_as)), + ) + else: + cost = nx.set_gradients( + nx.from_numpy(cost, type_as=type_as), + (a0, b0, M0), + (log["u"] - nx.mean(log["u"]), log["v"] - nx.mean(log["v"]), G), + ) return [cost, log] else: def f(b): bsel = b != 0 - G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) + + if edge_sources is not None: + flow_sources, flow_targets, flow_values, cost, u, v, result_code = emd_c_sparse( + a, b, edge_sources, edge_targets, edge_costs, numItermax + ) + + edge_to_idx = {(edge_sources[k], edge_targets[k]): k for k in range(len(edge_sources))} + grad_edge_costs = np.zeros(len(edge_costs), dtype=np.float64) + for idx in range(len(flow_sources)): + src, tgt, flow = flow_sources[idx], flow_targets[idx], flow_values[idx] + edge_idx = edge_to_idx.get((src, tgt), -1) + if edge_idx >= 0: + grad_edge_costs[edge_idx] = flow + + G = None + else: + G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) if center_dual: u, v = center_ot_dual(u, v, a, b) if np.any(~asel) or np.any(~bsel): - u, v = estimate_dual_null_weights(u, v, a, b, M) + if edge_sources is not None: + u, v = center_ot_dual(u, v, a, b) + else: + u, v = estimate_dual_null_weights(u, v, a, b, M) if not nx.is_floating_point(type_as): warnings.warn( @@ -560,16 +727,29 @@ def f(b): "histogram consists of floating point elements.", stacklevel=2, ) - G = nx.from_numpy(G, type_as=type_as) - cost = nx.set_gradients( - nx.from_numpy(cost, type_as=type_as), - (a0, b0, M0), - ( - nx.from_numpy(u - np.mean(u), type_as=type_as), - nx.from_numpy(v - np.mean(v), type_as=type_as), - G, - ), - ) + + if edge_sources is not None: + # Sparse: gradient w.r.t. edge_costs (no need to convert G) + cost = nx.set_gradients( + nx.from_numpy(cost, type_as=type_as), + (a0, b0, edge_costs_original), + ( + nx.from_numpy(u - np.mean(u), type_as=type_as), + nx.from_numpy(v - np.mean(v), type_as=type_as), + nx.from_numpy(grad_edge_costs, type_as=type_as), + ), + ) + else: + G = nx.from_numpy(G, type_as=type_as) + cost = nx.set_gradients( + nx.from_numpy(cost, type_as=type_as), + (a0, b0, M0), + ( + nx.from_numpy(u - np.mean(u), type_as=type_as), + nx.from_numpy(v - np.mean(v), type_as=type_as), + G, + ), + ) check_result(result_code) return cost diff --git a/ot/lp/emd_wrap.pyx b/ot/lp/emd_wrap.pyx index 53df54fc3..b4f603605 100644 --- a/ot/lp/emd_wrap.pyx +++ b/ot/lp/emd_wrap.pyx @@ -22,6 +22,7 @@ import warnings cdef extern from "EMD.h": int EMD_wrap(int n1,int n2, double *X, double *Y,double *D, double *G, double* alpha, double* beta, double *cost, uint64_t maxIter) nogil int EMD_wrap_omp(int n1,int n2, double *X, double *Y,double *D, double *G, double* alpha, double* beta, double *cost, uint64_t maxIter, int numThreads) nogil + int EMD_wrap_sparse(int n1, int n2, double *X, double *Y, uint64_t n_edges, long long *edge_sources, long long *edge_targets, double *edge_costs, long long *flow_sources_out, long long *flow_targets_out, double *flow_values_out, uint64_t *n_flows_out, double *alpha, double *beta, double *cost, uint64_t maxIter) nogil cdef enum ProblemType: INFEASIBLE, OPTIMAL, UNBOUNDED, MAX_ITER_REACHED @@ -206,3 +207,78 @@ def emd_1d_sorted(np.ndarray[double, ndim=1, mode="c"] u_weights, cur_idx += 1 cur_idx += 1 return G[:cur_idx], indices[:cur_idx], cost + +@cython.boundscheck(False) +@cython.wraparound(False) +def emd_c_sparse(np.ndarray[double, ndim=1, mode="c"] a, + np.ndarray[double, ndim=1, mode="c"] b, + np.ndarray[long long, ndim=1, mode="c"] edge_sources, + np.ndarray[long long, ndim=1, mode="c"] edge_targets, + np.ndarray[double, ndim=1, mode="c"] edge_costs, + uint64_t max_iter): + """ + Sparse EMD solver - only considers edges in edge_sources/edge_targets + + Parameters + ---------- + a : (n1,) array + Source histogram + b : (n2,) array + Target histogram + edge_sources : (k,) array, int64 + Source indices for each edge + edge_targets : (k,) array, int64 + Target indices for each edge + edge_costs : (k,) array, float64 + Cost for each edge + max_iter : uint64_t + Maximum iterations + + Returns + ------- + flow_sources : (n_flows,) array, int64 + Source indices of non-zero flows + flow_targets : (n_flows,) array, int64 + Target indices of non-zero flows + flow_values : (n_flows,) array, float64 + Flow values + cost : float + Total cost + alpha : (n1,) array + Dual variables for sources + beta : (n2,) array + Dual variables for targets + result_code : int + Result status + """ + cdef int n1 = a.shape[0] + cdef int n2 = b.shape[0] + cdef uint64_t n_edges = edge_sources.shape[0] + cdef uint64_t n_flows_out = 0 + cdef int result_code = 0 + cdef double cost = 0 + + # Allocate output arrays (max size = n_edges) + cdef np.ndarray[long long, ndim=1, mode="c"] flow_sources = np.zeros(n_edges, dtype=np.int64) + cdef np.ndarray[long long, ndim=1, mode="c"] flow_targets = np.zeros(n_edges, dtype=np.int64) + cdef np.ndarray[double, ndim=1, mode="c"] flow_values = np.zeros(n_edges, dtype=np.float64) + cdef np.ndarray[double, ndim=1, mode="c"] alpha = np.zeros(n1) + cdef np.ndarray[double, ndim=1, mode="c"] beta = np.zeros(n2) + + with nogil: + result_code = EMD_wrap_sparse( + n1, n2, + a.data, b.data, + n_edges, + edge_sources.data, edge_targets.data, edge_costs.data, + flow_sources.data, flow_targets.data, flow_values.data, + &n_flows_out, + alpha.data, beta.data, &cost, max_iter + ) + + # Trim to actual number of flows + flow_sources = flow_sources[:n_flows_out] + flow_targets = flow_targets[:n_flows_out] + flow_values = flow_values[:n_flows_out] + + return flow_sources, flow_targets, flow_values, cost, alpha, beta, result_code \ No newline at end of file diff --git a/ot/lp/sparse_bipartitegraph.h b/ot/lp/sparse_bipartitegraph.h new file mode 100644 index 000000000..7ba13b41a --- /dev/null +++ b/ot/lp/sparse_bipartitegraph.h @@ -0,0 +1,281 @@ +/* -*- mode: C++; indent-tabs-mode: nil; -*- + * + * Sparse bipartite graph for optimal transport + * Only stores edges that are explicitly added (not all n1×n2 edges) + * + * Uses CSR (Compressed Sparse Row) format for better cache locality and performance + * - Binary search for arc lookup: O(log k) where k = avg edges per node + * - Compact memory layout for better cache performance + * - Requires edges to be provided in sorted order during construction + */ + +#pragma once + +#include "core.h" +#include +#include +#include + +namespace lemon { + + class SparseBipartiteDigraphBase { + public: + + typedef SparseBipartiteDigraphBase Digraph; + typedef int Node; + typedef int64_t Arc; + + protected: + + int _node_num; + int64_t _arc_num; + int _n1, _n2; + + std::vector _arc_sources; // _arc_sources[arc_id] = source node + std::vector _arc_targets; // _arc_targets[arc_id] = target node + + // CSR format + // _row_ptr[i] = start index in _col_indices for source node i + // _row_ptr[i+1] - _row_ptr[i] = number of outgoing edges from node i + std::vector _row_ptr; + std::vector _col_indices; + std::vector _arc_ids; + + mutable std::vector> _in_arcs; // _in_arcs[node] = incoming arc IDs + mutable bool _in_arcs_built; + + SparseBipartiteDigraphBase() : _node_num(0), _arc_num(0), _n1(0), _n2(0), _in_arcs_built(false) {} + + void construct(int n1, int n2) { + _node_num = n1 + n2; + _n1 = n1; + _n2 = n2; + _arc_num = 0; + _arc_sources.clear(); + _arc_targets.clear(); + _row_ptr.clear(); + _col_indices.clear(); + _arc_ids.clear(); + _in_arcs.clear(); + _in_arcs_built = false; + } + + void build_in_arcs() const { + if (_in_arcs_built) return; + + _in_arcs.resize(_node_num); + + for (Arc a = 0; a < _arc_num; ++a) { + Node tgt = _arc_targets[a]; + _in_arcs[tgt].push_back(a); + } + + _in_arcs_built = true; + } + + public: + + Node operator()(int ix) const { return Node(ix); } + static int index(const Node& node) { return node; } + + void buildFromEdges(const std::vector>& edges) { + _arc_num = edges.size(); + + if (_arc_num == 0) { + _row_ptr.assign(_n1 + 1, 0); + return; + } + + // Create indexed edges: (source, target, original_arc_id) + std::vector> indexed_edges; + indexed_edges.reserve(_arc_num); + for (Arc i = 0; i < _arc_num; ++i) { + indexed_edges.emplace_back(edges[i].first, edges[i].second, i); + } + + // Sort by source node, then by target node CSR requirement + std::sort(indexed_edges.begin(), indexed_edges.end(), + [](const auto& a, const auto& b) { + if (std::get<0>(a) != std::get<0>(b)) + return std::get<0>(a) < std::get<0>(b); + return std::get<1>(a) < std::get<1>(b); + }); + + _arc_sources.resize(_arc_num); + _arc_targets.resize(_arc_num); + _col_indices.resize(_arc_num); + _arc_ids.resize(_arc_num); + _row_ptr.resize(_n1 + 1); + + _row_ptr[0] = 0; + int current_row = 0; + + for (int64_t i = 0; i < _arc_num; ++i) { + Node src = std::get<0>(indexed_edges[i]); + Node tgt = std::get<1>(indexed_edges[i]); + Arc orig_arc_id = std::get<2>(indexed_edges[i]); + + // Fill out row_ptr for rows with no outgoing edges + while (current_row < src) { + _row_ptr[++current_row] = i; + } + + _arc_sources[orig_arc_id] = src; + _arc_targets[orig_arc_id] = tgt; + _col_indices[i] = tgt; + _arc_ids[i] = orig_arc_id; + } + + // Fill remaining row_ptr entries + while (current_row < _n1) { + _row_ptr[++current_row] = _arc_num; + } + + _in_arcs_built = false; + } + + // Find arc from s to t using binary search (returns -1 if not found) + Arc arc(const Node& s, const Node& t) const { + if (s < 0 || s >= _n1 || t < _n1 || t >= _node_num) { + return Arc(-1); + } + + int64_t start = _row_ptr[s]; + int64_t end = _row_ptr[s + 1]; + + // Binary search for target t in col_indices[start:end] + auto it = std::lower_bound( + _col_indices.begin() + start, + _col_indices.begin() + end, + t + ); + + if (it != _col_indices.begin() + end && *it == t) { + int64_t pos = it - _col_indices.begin(); + return _arc_ids[pos]; + } + + return Arc(-1); + } + + int nodeNum() const { return _node_num; } + int64_t arcNum() const { return _arc_num; } + + int maxNodeId() const { return _node_num - 1; } + int64_t maxArcId() const { return _arc_num - 1; } + + Node source(Arc arc) const { + return (arc >= 0 && arc < _arc_num) ? _arc_sources[arc] : Node(-1); + } + + Node target(Arc arc) const { + return (arc >= 0 && arc < _arc_num) ? _arc_targets[arc] : Node(-1); + } + + static int id(Node node) { return node; } + static int64_t id(Arc arc) { return arc; } + + static Node nodeFromId(int id) { return Node(id); } + static Arc arcFromId(int64_t id) { return Arc(id); } + + Arc findArc(Node s, Node t, Arc prev = -1) const { + return prev == -1 ? arc(s, t) : Arc(-1); + } + + void first(Node& node) const { + node = _node_num - 1; + } + + static void next(Node& node) { + --node; + } + + void first(Arc& arc) const { + arc = _arc_num - 1; + } + + static void next(Arc& arc) { + --arc; + } + + void firstOut(Arc& arc, const Node& node) const { + if (node < 0 || node >= _n1) { + arc = -1; + return; + } + + int64_t start = _row_ptr[node]; + int64_t end = _row_ptr[node + 1]; + + arc = (start < end) ? _arc_ids[start] : Arc(-1); + } + + void nextOut(Arc& arc) const { + if (arc < 0) return; + + Node src = _arc_sources[arc]; + int64_t start = _row_ptr[src]; + int64_t end = _row_ptr[src + 1]; + + for (int64_t i = start; i < end; ++i) { + if (_arc_ids[i] == arc) { + arc = (i + 1 < end) ? _arc_ids[i + 1] : Arc(-1); + return; + } + } + arc = -1; + } + + void firstIn(Arc& arc, const Node& node) const { + build_in_arcs(); // Lazy build on first call + + if (node < 0 || node >= _node_num || node < _n1) { + arc = -1; // Invalid node or source nodes have no incoming arcs + return; + } + + const std::vector& in = _in_arcs[node]; + arc = in.empty() ? Arc(-1) : in[0]; + } + + void nextIn(Arc& arc) const { + if (arc < 0) return; + + Node tgt = _arc_targets[arc]; + const std::vector& in = _in_arcs[tgt]; + + // Find current arc in the list and return next one + for (size_t i = 0; i < in.size(); ++i) { + if (in[i] == arc) { + arc = (i + 1 < in.size()) ? in[i + 1] : Arc(-1); + return; + } + } + arc = -1; + } + }; + + /// Sparse bipartite digraph - only stores edges that are explicitly added + class SparseBipartiteDigraph : public SparseBipartiteDigraphBase { + typedef SparseBipartiteDigraphBase Parent; + + public: + + SparseBipartiteDigraph() { construct(0, 0); } + + SparseBipartiteDigraph(int n1, int n2) { construct(n1, n2); } + + Node operator()(int ix) const { return Parent::operator()(ix); } + static int index(const Node& node) { return Parent::index(node); } + + void buildFromEdges(const std::vector>& edges) { + Parent::buildFromEdges(edges); + } + + Arc arc(Node s, Node t) const { return Parent::arc(s, t); } + + int nodeNum() const { return Parent::nodeNum(); } + int64_t arcNum() const { return Parent::arcNum(); } + }; + +} //namespace lemon diff --git a/setup.py b/setup.py index acbe5aed9..c8cefb729 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,19 @@ link_args += flags if sys.platform.startswith("darwin"): - compile_args.append("-stdlib=libc++") + # Only add -stdlib=libc++ for Clang, not GCC + # GCC uses libstdc++ by default and doesn't recognize -stdlib flag + import subprocess + try: + # Check if using clang + compiler = os.environ.get('CXX', 'c++') + version_output = subprocess.check_output([compiler, '--version'], stderr=subprocess.STDOUT).decode() + if 'clang' in version_output.lower(): + compile_args.append("-stdlib=libc++") + except Exception: + # If we can't determine, don't add the flag (safer for GCC) + pass + sdk_path = subprocess.check_output(["xcrun", "--show-sdk-path"]) os.environ["CFLAGS"] = '-isysroot "{}"'.format(sdk_path.rstrip().decode("utf-8")) diff --git a/test/test_ot.py b/test/test_ot.py index e8217d54d..e4c55f6f4 100644 --- a/test/test_ot.py +++ b/test/test_ot.py @@ -12,7 +12,7 @@ import ot from ot.datasets import make_1D_gauss as gauss from ot.backend import torch, tf, get_backend - +from scipy.sparse import coo_matrix def test_emd_dimension_and_mass_mismatch(): # test emd and emd2 for dimension mismatch @@ -914,6 +914,153 @@ def test_dual_variables(): assert constraint_violation.max() < 1e-8 +def test_emd_sparse_vs_dense(): + + n_source = 100 + n_target = 100 + k = 10 + + rng = np.random.RandomState(42) + + x_source = rng.randn(n_source, 2) + x_target = rng.randn(n_target, 2) + 0.5 + + a = ot.utils.unif(n_source) + b = ot.utils.unif(n_target) + + C = ot.dist(x_source, x_target) + + rows = [] + cols = [] + data = [] + + for i in range(n_source): + distances = C[i, :] + nearest_k = np.argpartition(distances, k)[:k] + for j in nearest_k: + rows.append(i) + cols.append(j) + data.append(C[i, j]) + + C_knn = coo_matrix((data, (rows, cols)), shape=(n_source, n_target)) + + large_cost = 1e8 + C_dense_infty = np.full((n_source, n_target), large_cost) + C_knn_array = C_knn.toarray() + C_dense_infty[C_knn_array > 0] = C_knn_array[C_knn_array > 0] + + G_dense_initial = ot.emd(a, b, C_dense_infty) + eps = 1e-9 + active_mask = G_dense_initial > eps + knn_mask = C_knn_array > 0 + extra_edges_mask = active_mask & ~knn_mask + + rows_aug = [] + cols_aug = [] + data_aug = [] + + knn_rows, knn_cols = np.where(knn_mask) + for i, j in zip(knn_rows, knn_cols): + rows_aug.append(i) + cols_aug.append(j) + data_aug.append(C[i, j]) + + extra_rows, extra_cols = np.where(extra_edges_mask) + for i, j in zip(extra_rows, extra_cols): + rows_aug.append(i) + cols_aug.append(j) + data_aug.append(C[i, j]) + + C_augmented = coo_matrix((data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target)) + + C_augmented_dense = np.full((n_source, n_target), large_cost) + C_augmented_array = C_augmented.toarray() + C_augmented_dense[C_augmented_array > 0] = C_augmented_array[C_augmented_array > 0] + + G_dense, log_dense = ot.emd(a, b, C_augmented_dense, log=True) + G_sparse, log_sparse = ot.emd(a, b, C_augmented, log=True, sparse=True, return_matrix=True) + + cost_dense = log_dense['cost'] + cost_sparse = log_sparse['cost'] + + np.testing.assert_allclose(cost_dense, cost_sparse, rtol=1e-5, atol=1e-7) + + np.testing.assert_allclose(a, G_dense.sum(1), rtol=1e-5, atol=1e-7) + np.testing.assert_allclose(b, G_dense.sum(0), rtol=1e-5, atol=1e-7) + np.testing.assert_allclose(a, G_sparse.sum(1), rtol=1e-5, atol=1e-7) + np.testing.assert_allclose(b, G_sparse.sum(0), rtol=1e-5, atol=1e-7) + + +def test_emd2_sparse_vs_dense(): + + n_source = 100 + n_target = 100 + k = 10 + + rng = np.random.RandomState(42) + + x_source = rng.randn(n_source, 2) + x_target = rng.randn(n_target, 2) + 0.5 + + a = ot.utils.unif(n_source) + b = ot.utils.unif(n_target) + + C = ot.dist(x_source, x_target) + + rows = [] + cols = [] + data = [] + + for i in range(n_source): + distances = C[i, :] + nearest_k = np.argpartition(distances, k)[:k] + for j in nearest_k: + rows.append(i) + cols.append(j) + data.append(C[i, j]) + + C_knn = coo_matrix((data, (rows, cols)), shape=(n_source, n_target)) + + large_cost = 1e8 + C_dense_infty = np.full((n_source, n_target), large_cost) + C_knn_array = C_knn.toarray() + C_dense_infty[C_knn_array > 0] = C_knn_array[C_knn_array > 0] + + G_dense_initial = ot.emd(a, b, C_dense_infty) + + eps = 1e-9 + active_mask = G_dense_initial > eps + knn_mask = C_knn_array > 0 + extra_edges_mask = active_mask & ~knn_mask + + rows_aug = [] + cols_aug = [] + data_aug = [] + + knn_rows, knn_cols = np.where(knn_mask) + for i, j in zip(knn_rows, knn_cols): + rows_aug.append(i) + cols_aug.append(j) + data_aug.append(C[i, j]) + + extra_rows, extra_cols = np.where(extra_edges_mask) + for i, j in zip(extra_rows, extra_cols): + rows_aug.append(i) + cols_aug.append(j) + data_aug.append(C[i, j]) + + C_augmented = coo_matrix((data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target)) + + C_augmented_dense = np.full((n_source, n_target), large_cost) + C_augmented_array = C_augmented.toarray() + C_augmented_dense[C_augmented_array > 0] = C_augmented_array[C_augmented_array > 0] + + cost_dense = ot.emd2(a, b, C_augmented_dense) + cost_sparse = ot.emd2(a, b, C_augmented, sparse=True) + + np.testing.assert_allclose(cost_dense, cost_sparse, rtol=1e-5, atol=1e-7) + + def check_duality_gap(a, b, M, G, u, v, cost): cost_dual = np.vdot(a, u) + np.vdot(b, v) # Check that dual and primal cost are equal From 0eee6f1e14a4c78039f453158bce08a42f4f9098 Mon Sep 17 00:00:00 2001 From: nathanneike Date: Tue, 28 Oct 2025 16:22:31 +0100 Subject: [PATCH 2/9] [WIP] Add sparse EMD solver with unit tests This PR implements a sparse bipartite graph EMD solver for memory-efficient optimal transport when the cost matrix has many infinite or forbidden edges. Changes: - Implement sparse bipartite graph EMD solver in C++ - Add Python bindings for sparse solver (emd_wrap.pyx, _network_simplex.py) - Add unit tests to verify sparse and dense solvers produce identical results - Tests use augmented k-NN approach to ensure fair comparison Tests verify correctness: * test_emd_sparse_vs_dense() - verifies identical costs and marginal constraints * test_emd2_sparse_vs_dense() - verifies cost-only version Status: WIP - seeking feedback on implementation approach TODO: Add example script and documentation --- ot/lp/_network_simplex.py | 112 +++++++++++++++++++++++++++++--------- test/test_ot.py | 29 +++++++--- 2 files changed, 107 insertions(+), 34 deletions(-) diff --git a/ot/lp/_network_simplex.py b/ot/lp/_network_simplex.py index 3ce63a874..35b185746 100644 --- a/ot/lp/_network_simplex.py +++ b/ot/lp/_network_simplex.py @@ -294,9 +294,17 @@ def emd( else: M_coo = M - edge_sources = M_coo.row if M_coo.row.dtype == np.int64 else M_coo.row.astype(np.int64) - edge_targets = M_coo.col if M_coo.col.dtype == np.int64 else M_coo.col.astype(np.int64) - edge_costs = M_coo.data if M_coo.data.dtype == np.float64 else M_coo.data.astype(np.float64) + edge_sources = ( + M_coo.row if M_coo.row.dtype == np.int64 else M_coo.row.astype(np.int64) + ) + edge_targets = ( + M_coo.col if M_coo.col.dtype == np.int64 else M_coo.col.astype(np.int64) + ) + edge_costs = ( + M_coo.data + if M_coo.data.dtype == np.float64 + else M_coo.data.astype(np.float64) + ) n1, n2 = M_coo.shape elif isinstance(M, tuple) and len(M) == 3: edge_sources = np.asarray(M[0], dtype=np.int64) @@ -305,7 +313,9 @@ def emd( n1 = int(edge_sources.max() + 1) n2 = int(edge_targets.max() + 1) else: - raise ValueError("When sparse=True, M must be a scipy sparse matrix or a tuple (row, col, data)") + raise ValueError( + "When sparse=True, M must be a scipy sparse matrix or a tuple (row, col, data)" + ) a, b = list_to_array(a, b) else: @@ -321,9 +331,17 @@ def emd( type_as = a if len(a) == 0: - a = nx.ones((n1,), type_as=type_as) / n1 if n1 else nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] + a = ( + nx.ones((n1,), type_as=type_as) / n1 + if n1 + else nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] + ) if len(b) == 0: - b = nx.ones((n2,), type_as=type_as) / n2 if n2 else nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] + b = ( + nx.ones((n2,), type_as=type_as) / n2 + if n2 + else nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] + ) if sparse: a, b = nx.to_numpy(a, b) @@ -334,7 +352,6 @@ def emd( a = np.asarray(a, dtype=np.float64) b = np.asarray(b, dtype=np.float64) - if n1 is None: n1, n2 = M.shape @@ -409,7 +426,9 @@ def emd( if G is not None: return nx.from_numpy(G, type_as=type_as) else: - raise ValueError("Cannot return matrix when return_matrix=False and sparse=True without log=True") + raise ValueError( + "Cannot return matrix when return_matrix=False and sparse=True without log=True" + ) def emd2( @@ -419,12 +438,11 @@ def emd2( processes=1, numItermax=100000, log=False, - center_dual=True, numThreads=1, check_marginals=True, sparse=False, - return_matrix=False + return_matrix=False, ): r"""Solves the Earth Movers distance problem and returns the loss @@ -534,7 +552,7 @@ def emd2( edge_sources = None edge_targets = None edge_costs = None - n1, n2 = None, None + n1, n2 = None, None if sparse: if sp.issparse(M): @@ -545,11 +563,21 @@ def emd2( M_coo = M t1 = time.perf_counter() - edge_sources = M_coo.row if M_coo.row.dtype == np.int64 else M_coo.row.astype(np.int64) - edge_targets = M_coo.col if M_coo.col.dtype == np.int64 else M_coo.col.astype(np.int64) - edge_costs = M_coo.data if M_coo.data.dtype == np.float64 else M_coo.data.astype(np.float64) + edge_sources = ( + M_coo.row if M_coo.row.dtype == np.int64 else M_coo.row.astype(np.int64) + ) + edge_targets = ( + M_coo.col if M_coo.col.dtype == np.int64 else M_coo.col.astype(np.int64) + ) + edge_costs = ( + M_coo.data + if M_coo.data.dtype == np.float64 + else M_coo.data.astype(np.float64) + ) t2 = time.perf_counter() - print(f"[PY SPARSE] COO conversion: {(t1-t0)*1000:.3f} ms, array copies: {(t2-t1)*1000:.3f} ms") + print( + f"[PY SPARSE] COO conversion: {(t1-t0)*1000:.3f} ms, array copies: {(t2-t1)*1000:.3f} ms" + ) n1, n2 = M_coo.shape elif isinstance(M, tuple) and len(M) == 3: edge_sources = np.asarray(M[0], dtype=np.int64) @@ -577,12 +605,20 @@ def emd2( # if empty array given then use uniform distributions if len(a) == 0: - a = nx.ones((n1,), type_as=type_as) / n1 if n1 else nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] + a = ( + nx.ones((n1,), type_as=type_as) / n1 + if n1 + else nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] + ) if len(b) == 0: - b = nx.ones((n2,), type_as=type_as) / n2 if n2 else nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] + b = ( + nx.ones((n2,), type_as=type_as) / n2 + if n2 + else nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] + ) a0, b0 = a, b - M0 = None if sparse else M + M0 = None if sparse else M if sparse: edge_costs_original = nx.from_numpy(edge_costs, type_as=type_as) @@ -625,15 +661,24 @@ def f(b): bsel = b != 0 if edge_sources is not None: - flow_sources, flow_targets, flow_values, cost, u, v, result_code = emd_c_sparse( - a, b, edge_sources, edge_targets, edge_costs, numItermax + flow_sources, flow_targets, flow_values, cost, u, v, result_code = ( + emd_c_sparse( + a, b, edge_sources, edge_targets, edge_costs, numItermax + ) ) - edge_to_idx = {(edge_sources[k], edge_targets[k]): k for k in range(len(edge_sources))} + edge_to_idx = { + (edge_sources[k], edge_targets[k]): k + for k in range(len(edge_sources)) + } grad_edge_costs = np.zeros(len(edge_costs), dtype=np.float64) for idx in range(len(flow_sources)): - src, tgt, flow = flow_sources[idx], flow_targets[idx], flow_values[idx] + src, tgt, flow = ( + flow_sources[idx], + flow_targets[idx], + flow_values[idx], + ) edge_idx = edge_to_idx.get((src, tgt), -1) if edge_idx >= 0: grad_edge_costs[edge_idx] = flow @@ -679,7 +724,11 @@ def f(b): cost = nx.set_gradients( nx.from_numpy(cost, type_as=type_as), (a0, b0, edge_costs_original), - (log["u"] - nx.mean(log["u"]), log["v"] - nx.mean(log["v"]), nx.from_numpy(grad_edge_costs, type_as=type_as)), + ( + log["u"] - nx.mean(log["u"]), + log["v"] - nx.mean(log["v"]), + nx.from_numpy(grad_edge_costs, type_as=type_as), + ), ) else: cost = nx.set_gradients( @@ -694,14 +743,23 @@ def f(b): bsel = b != 0 if edge_sources is not None: - flow_sources, flow_targets, flow_values, cost, u, v, result_code = emd_c_sparse( - a, b, edge_sources, edge_targets, edge_costs, numItermax + flow_sources, flow_targets, flow_values, cost, u, v, result_code = ( + emd_c_sparse( + a, b, edge_sources, edge_targets, edge_costs, numItermax + ) ) - edge_to_idx = {(edge_sources[k], edge_targets[k]): k for k in range(len(edge_sources))} + edge_to_idx = { + (edge_sources[k], edge_targets[k]): k + for k in range(len(edge_sources)) + } grad_edge_costs = np.zeros(len(edge_costs), dtype=np.float64) for idx in range(len(flow_sources)): - src, tgt, flow = flow_sources[idx], flow_targets[idx], flow_values[idx] + src, tgt, flow = ( + flow_sources[idx], + flow_targets[idx], + flow_values[idx], + ) edge_idx = edge_to_idx.get((src, tgt), -1) if edge_idx >= 0: grad_edge_costs[edge_idx] = flow diff --git a/test/test_ot.py b/test/test_ot.py index e4c55f6f4..ec12f63c8 100644 --- a/test/test_ot.py +++ b/test/test_ot.py @@ -14,6 +14,7 @@ from ot.backend import torch, tf, get_backend from scipy.sparse import coo_matrix + def test_emd_dimension_and_mass_mismatch(): # test emd and emd2 for dimension mismatch n_samples = 100 @@ -915,10 +916,14 @@ def test_dual_variables(): def test_emd_sparse_vs_dense(): + """Test that sparse and dense EMD solvers produce identical results. + Uses augmented k-NN graph approach: first solves with dense solver to + identify needed edges, then compares both solvers on the same graph. + """ n_source = 100 n_target = 100 - k = 10 + k = 10 rng = np.random.RandomState(42) @@ -971,17 +976,21 @@ def test_emd_sparse_vs_dense(): cols_aug.append(j) data_aug.append(C[i, j]) - C_augmented = coo_matrix((data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target)) + C_augmented = coo_matrix( + (data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target) + ) C_augmented_dense = np.full((n_source, n_target), large_cost) C_augmented_array = C_augmented.toarray() C_augmented_dense[C_augmented_array > 0] = C_augmented_array[C_augmented_array > 0] G_dense, log_dense = ot.emd(a, b, C_augmented_dense, log=True) - G_sparse, log_sparse = ot.emd(a, b, C_augmented, log=True, sparse=True, return_matrix=True) + G_sparse, log_sparse = ot.emd( + a, b, C_augmented, log=True, sparse=True, return_matrix=True + ) - cost_dense = log_dense['cost'] - cost_sparse = log_sparse['cost'] + cost_dense = log_dense["cost"] + cost_sparse = log_sparse["cost"] np.testing.assert_allclose(cost_dense, cost_sparse, rtol=1e-5, atol=1e-7) @@ -992,10 +1001,14 @@ def test_emd_sparse_vs_dense(): def test_emd2_sparse_vs_dense(): + """Test that sparse and dense emd2 solvers produce identical results. + Uses augmented k-NN graph approach: first solves with dense solver to + identify needed edges, then compares both solvers on the same graph. + """ n_source = 100 n_target = 100 - k = 10 + k = 10 rng = np.random.RandomState(42) @@ -1049,7 +1062,9 @@ def test_emd2_sparse_vs_dense(): cols_aug.append(j) data_aug.append(C[i, j]) - C_augmented = coo_matrix((data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target)) + C_augmented = coo_matrix( + (data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target) + ) C_augmented_dense = np.full((n_source, n_target), large_cost) C_augmented_array = C_augmented.toarray() From 022720b295f42223101a87ff7c5a9db10b9c0267 Mon Sep 17 00:00:00 2001 From: nathanneike Date: Thu, 30 Oct 2025 11:08:06 +0100 Subject: [PATCH 3/9] Fix int64_t type compatibility for Linux, remove sparse and return matrix parameter from emd and fix linting issues --- ot/lp/_network_simplex.py | 85 +++++++++++++++++---------------------- ot/lp/emd_wrap.pyx | 16 ++++---- test/test_ot.py | 25 +++++++++--- 3 files changed, 65 insertions(+), 61 deletions(-) diff --git a/ot/lp/_network_simplex.py b/ot/lp/_network_simplex.py index 35b185746..7438bd131 100644 --- a/ot/lp/_network_simplex.py +++ b/ot/lp/_network_simplex.py @@ -12,7 +12,6 @@ import warnings import scipy.sparse as sp -import time from ..utils import list_to_array, check_number_threads from ..backend import get_backend from .emd_wrap import emd_c, emd_c_sparse, check_result @@ -174,8 +173,6 @@ def emd( center_dual=True, numThreads=1, check_marginals=True, - sparse=False, - return_matrix=False, ): r"""Solves the Earth Movers distance problem and returns the OT matrix @@ -236,22 +233,26 @@ def emd( check_marginals: bool, optional (default=True) If True, checks that the marginals mass are equal. If False, skips the check. - sparse: bool, optional (default=False) - If True, uses the sparse solver that only stores edges with finite costs. - When sparse=True, M should be a scipy.sparse matrix. - return_matrix: bool, optional (default=True) - If True, returns the transport matrix. If False and sparse=True, returns - sparse flow representation in log. + + .. note:: The solver automatically detects sparse format when M is provided as: + - A scipy.sparse matrix (coo, csr, csc, etc.) + - A tuple (row_indices, col_indices, costs) representing an edge list + + For sparse inputs, the solver uses a memory-efficient algorithm and returns + the flow in edge format (via log dict) instead of a full matrix. Returns ------- - gamma: array-like, shape (ns, nt) - Optimal transportation matrix for the given - parameters + gamma: array-like, shape (ns, nt), or None + Optimal transportation matrix for the given parameters. + For sparse inputs, returns None (use log=True to get flow in edge format). log: dict, optional - If input log is true, a dictionary containing the - cost and dual variables and exit status + If input log is True, a dictionary containing the cost, dual variables, + and exit status. For sparse inputs with log=True, also contains: + - 'flow_sources': source nodes of flow edges + - 'flow_targets': target nodes of flow edges + - 'flow_values': flow values on edges Examples @@ -287,7 +288,10 @@ def emd( edge_costs = None n1, n2 = None, None - if sparse: + # Auto-detect sparse format + is_sparse = sp.issparse(M) or (isinstance(M, tuple) and len(M) == 3) + + if is_sparse: if sp.issparse(M): if not isinstance(M, sp.coo_matrix): M_coo = sp.coo_matrix(M) @@ -312,10 +316,6 @@ def emd( edge_costs = np.asarray(M[2], dtype=np.float64) n1 = int(edge_sources.max() + 1) n2 = int(edge_targets.max() + 1) - else: - raise ValueError( - "When sparse=True, M must be a scipy sparse matrix or a tuple (row, col, data)" - ) a, b = list_to_array(a, b) else: @@ -343,7 +343,7 @@ def emd( else nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] ) - if sparse: + if is_sparse: a, b = nx.to_numpy(a, b) else: M, a, b = nx.to_numpy(M, a, b) @@ -375,14 +375,11 @@ def emd( numThreads = check_number_threads(numThreads) if edge_sources is not None: + # Sparse solver - never build full matrix flow_sources, flow_targets, flow_values, cost, u, v, result_code = emd_c_sparse( a, b, edge_sources, edge_targets, edge_costs, numItermax ) - if return_matrix: - G = np.zeros((len(a), len(b)), dtype=np.float64) - G[flow_sources, flow_targets] = flow_values - else: - G = None + G = None else: G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) @@ -413,7 +410,8 @@ def emd( log_dict["warning"] = result_code_string log_dict["result_code"] = result_code - if edge_sources is not None and not return_matrix: + if edge_sources is not None: + # For sparse, include flow in edge format log_dict["flow_sources"] = flow_sources log_dict["flow_targets"] = flow_targets log_dict["flow_values"] = flow_values @@ -427,7 +425,7 @@ def emd( return nx.from_numpy(G, type_as=type_as) else: raise ValueError( - "Cannot return matrix when return_matrix=False and sparse=True without log=True" + "For sparse inputs, log=True is required to get the flow in edge format" ) @@ -441,7 +439,6 @@ def emd2( center_dual=True, numThreads=1, check_marginals=True, - sparse=False, return_matrix=False, ): r"""Solves the Earth Movers distance problem and returns the loss @@ -503,11 +500,12 @@ def emd2( check_marginals: bool, optional (default=True) If True, checks that the marginals mass are equal. If False, skips the check. - sparse: bool, optional (default=False) - If True, uses the sparse solver that only stores edges with finite costs. - This is memory-efficient when M has many infinite or forbidden edges. - When sparse=True, M should be a scipy.sparse matrix (coo, csr, or csc format) - or a tuple (row_indices, col_indices, costs) representing the edge list. + + .. note:: The solver automatically detects sparse format when M is provided as: + - A scipy.sparse matrix (coo, csr, csc, etc.) + - A tuple (row_indices, col_indices, costs) representing an edge list + + For sparse inputs, the solver uses a memory-efficient algorithm. Edges not included are treated as having infinite cost (forbidden). @@ -554,14 +552,15 @@ def emd2( edge_costs = None n1, n2 = None, None - if sparse: + # Auto-detect sparse format + is_sparse = sp.issparse(M) or (isinstance(M, tuple) and len(M) == 3) + + if is_sparse: if sp.issparse(M): - t0 = time.perf_counter() if not isinstance(M, sp.coo_matrix): M_coo = sp.coo_matrix(M) else: M_coo = M - t1 = time.perf_counter() edge_sources = ( M_coo.row if M_coo.row.dtype == np.int64 else M_coo.row.astype(np.int64) @@ -574,10 +573,6 @@ def emd2( if M_coo.data.dtype == np.float64 else M_coo.data.astype(np.float64) ) - t2 = time.perf_counter() - print( - f"[PY SPARSE] COO conversion: {(t1-t0)*1000:.3f} ms, array copies: {(t2-t1)*1000:.3f} ms" - ) n1, n2 = M_coo.shape elif isinstance(M, tuple) and len(M) == 3: edge_sources = np.asarray(M[0], dtype=np.int64) @@ -585,10 +580,6 @@ def emd2( edge_costs = np.asarray(M[2], dtype=np.float64) n1 = int(edge_sources.max() + 1) n2 = int(edge_targets.max() + 1) - else: - raise ValueError( - "When sparse=True, M must be a scipy sparse matrix or a tuple (row, col, data)" - ) a, b = list_to_array(a, b) else: @@ -618,14 +609,14 @@ def emd2( ) a0, b0 = a, b - M0 = None if sparse else M + M0 = None if is_sparse else M - if sparse: + if is_sparse: edge_costs_original = nx.from_numpy(edge_costs, type_as=type_as) else: edge_costs_original = None - if sparse: + if is_sparse: a, b = nx.to_numpy(a, b) else: M, a, b = nx.to_numpy(M, a, b) diff --git a/ot/lp/emd_wrap.pyx b/ot/lp/emd_wrap.pyx index b4f603605..f95e47433 100644 --- a/ot/lp/emd_wrap.pyx +++ b/ot/lp/emd_wrap.pyx @@ -14,7 +14,7 @@ from ..utils import dist cimport cython cimport libc.math as math -from libc.stdint cimport uint64_t +from libc.stdint cimport uint64_t, int64_t import warnings @@ -22,7 +22,7 @@ import warnings cdef extern from "EMD.h": int EMD_wrap(int n1,int n2, double *X, double *Y,double *D, double *G, double* alpha, double* beta, double *cost, uint64_t maxIter) nogil int EMD_wrap_omp(int n1,int n2, double *X, double *Y,double *D, double *G, double* alpha, double* beta, double *cost, uint64_t maxIter, int numThreads) nogil - int EMD_wrap_sparse(int n1, int n2, double *X, double *Y, uint64_t n_edges, long long *edge_sources, long long *edge_targets, double *edge_costs, long long *flow_sources_out, long long *flow_targets_out, double *flow_values_out, uint64_t *n_flows_out, double *alpha, double *beta, double *cost, uint64_t maxIter) nogil + int EMD_wrap_sparse(int n1, int n2, double *X, double *Y, uint64_t n_edges, int64_t *edge_sources, int64_t *edge_targets, double *edge_costs, int64_t *flow_sources_out, int64_t *flow_targets_out, double *flow_values_out, uint64_t *n_flows_out, double *alpha, double *beta, double *cost, uint64_t maxIter) nogil cdef enum ProblemType: INFEASIBLE, OPTIMAL, UNBOUNDED, MAX_ITER_REACHED @@ -212,8 +212,8 @@ def emd_1d_sorted(np.ndarray[double, ndim=1, mode="c"] u_weights, @cython.wraparound(False) def emd_c_sparse(np.ndarray[double, ndim=1, mode="c"] a, np.ndarray[double, ndim=1, mode="c"] b, - np.ndarray[long long, ndim=1, mode="c"] edge_sources, - np.ndarray[long long, ndim=1, mode="c"] edge_targets, + np.ndarray[int64_t, ndim=1, mode="c"] edge_sources, + np.ndarray[int64_t, ndim=1, mode="c"] edge_targets, np.ndarray[double, ndim=1, mode="c"] edge_costs, uint64_t max_iter): """ @@ -259,8 +259,8 @@ def emd_c_sparse(np.ndarray[double, ndim=1, mode="c"] a, cdef double cost = 0 # Allocate output arrays (max size = n_edges) - cdef np.ndarray[long long, ndim=1, mode="c"] flow_sources = np.zeros(n_edges, dtype=np.int64) - cdef np.ndarray[long long, ndim=1, mode="c"] flow_targets = np.zeros(n_edges, dtype=np.int64) + cdef np.ndarray[int64_t, ndim=1, mode="c"] flow_sources = np.zeros(n_edges, dtype=np.int64) + cdef np.ndarray[int64_t, ndim=1, mode="c"] flow_targets = np.zeros(n_edges, dtype=np.int64) cdef np.ndarray[double, ndim=1, mode="c"] flow_values = np.zeros(n_edges, dtype=np.float64) cdef np.ndarray[double, ndim=1, mode="c"] alpha = np.zeros(n1) cdef np.ndarray[double, ndim=1, mode="c"] beta = np.zeros(n2) @@ -270,8 +270,8 @@ def emd_c_sparse(np.ndarray[double, ndim=1, mode="c"] a, n1, n2, a.data, b.data, n_edges, - edge_sources.data, edge_targets.data, edge_costs.data, - flow_sources.data, flow_targets.data, flow_values.data, + edge_sources.data, edge_targets.data, edge_costs.data, + flow_sources.data, flow_targets.data, flow_values.data, &n_flows_out, alpha.data, beta.data, &cost, max_iter ) diff --git a/test/test_ot.py b/test/test_ot.py index ec12f63c8..6b57c8602 100644 --- a/test/test_ot.py +++ b/test/test_ot.py @@ -985,19 +985,32 @@ def test_emd_sparse_vs_dense(): C_augmented_dense[C_augmented_array > 0] = C_augmented_array[C_augmented_array > 0] G_dense, log_dense = ot.emd(a, b, C_augmented_dense, log=True) - G_sparse, log_sparse = ot.emd( - a, b, C_augmented, log=True, sparse=True, return_matrix=True - ) + G_sparse, log_sparse = ot.emd(a, b, C_augmented, log=True) cost_dense = log_dense["cost"] cost_sparse = log_sparse["cost"] np.testing.assert_allclose(cost_dense, cost_sparse, rtol=1e-5, atol=1e-7) + # For dense, G_dense is returned; for sparse, reconstruct from flow edges np.testing.assert_allclose(a, G_dense.sum(1), rtol=1e-5, atol=1e-7) np.testing.assert_allclose(b, G_dense.sum(0), rtol=1e-5, atol=1e-7) - np.testing.assert_allclose(a, G_sparse.sum(1), rtol=1e-5, atol=1e-7) - np.testing.assert_allclose(b, G_sparse.sum(0), rtol=1e-5, atol=1e-7) + + # Reconstruct sparse matrix from flow for marginal checks + if G_sparse is None: + G_sparse_reconstructed = np.zeros((n_source, n_target)) + G_sparse_reconstructed[ + log_sparse["flow_sources"], log_sparse["flow_targets"] + ] = log_sparse["flow_values"] + np.testing.assert_allclose( + a, G_sparse_reconstructed.sum(1), rtol=1e-5, atol=1e-7 + ) + np.testing.assert_allclose( + b, G_sparse_reconstructed.sum(0), rtol=1e-5, atol=1e-7 + ) + else: + np.testing.assert_allclose(a, G_sparse.sum(1), rtol=1e-5, atol=1e-7) + np.testing.assert_allclose(b, G_sparse.sum(0), rtol=1e-5, atol=1e-7) def test_emd2_sparse_vs_dense(): @@ -1071,7 +1084,7 @@ def test_emd2_sparse_vs_dense(): C_augmented_dense[C_augmented_array > 0] = C_augmented_array[C_augmented_array > 0] cost_dense = ot.emd2(a, b, C_augmented_dense) - cost_sparse = ot.emd2(a, b, C_augmented, sparse=True) + cost_sparse = ot.emd2(a, b, C_augmented) np.testing.assert_allclose(cost_dense, cost_sparse, rtol=1e-5, atol=1e-7) From aa5f1c9431df0385ecb6da9dee58d3c56b4ad2e4 Mon Sep 17 00:00:00 2001 From: nathanneike Date: Mon, 3 Nov 2025 13:47:37 +0100 Subject: [PATCH 4/9] refactor: Clean up sparse EMD implementation - Remove tuple format support for sparse matrices (use scipy.sparse only) - Change index types from int64_t to uint64_t throughout (indices are never negative) - Refactor emd() and emd2() with clear sparse/dense code path separation - Add sparse_bipartitegraph.h to MANIFEST.in to fix build - Add test_emd_sparse_backends() to verify backend compatibility --- MANIFEST.in | 1 + ot/lp/EMD.h | 8 +- ot/lp/EMD_wrapper.cpp | 8 +- ot/lp/_network_simplex.py | 440 +++++++++++++++++--------------------- ot/lp/emd_wrap.pyx | 22 +- test/test_ot.py | 166 ++++++++++++++ 6 files changed, 387 insertions(+), 258 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 7c96ba026..d93298de4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,4 +10,5 @@ include ot/lp/full_bipartitegraph.h include ot/lp/full_bipartitegraph_omp.h include ot/lp/network_simplex_simple.h include ot/lp/network_simplex_simple_omp.h +include ot/lp/sparse_bipartitegraph.h include ot/partial/partial_cython.pyx diff --git a/ot/lp/EMD.h b/ot/lp/EMD.h index efa839bcf..e3564a2d2 100644 --- a/ot/lp/EMD.h +++ b/ot/lp/EMD.h @@ -38,11 +38,11 @@ int EMD_wrap_sparse( double *X, double *Y, uint64_t n_edges, // Number of edges in sparse graph - int64_t *edge_sources, // Source indices for each edge (n_edges) - int64_t *edge_targets, // Target indices for each edge (n_edges) + uint64_t *edge_sources, // Source indices for each edge (n_edges) + uint64_t *edge_targets, // Target indices for each edge (n_edges) double *edge_costs, // Cost for each edge (n_edges) - int64_t *flow_sources_out, // Output: source indices of non-zero flows - int64_t *flow_targets_out, // Output: target indices of non-zero flows + uint64_t *flow_sources_out, // Output: source indices of non-zero flows + uint64_t *flow_targets_out, // Output: target indices of non-zero flows double *flow_values_out, // Output: flow values uint64_t *n_flows_out, double *alpha, // Output: dual variables for sources (n1) diff --git a/ot/lp/EMD_wrapper.cpp b/ot/lp/EMD_wrapper.cpp index 7b4b9ed6e..bd3672535 100644 --- a/ot/lp/EMD_wrapper.cpp +++ b/ot/lp/EMD_wrapper.cpp @@ -228,11 +228,11 @@ int EMD_wrap_sparse( double *X, double *Y, uint64_t n_edges, - int64_t *edge_sources, - int64_t *edge_targets, + uint64_t *edge_sources, + uint64_t *edge_targets, double *edge_costs, - int64_t *flow_sources_out, - int64_t *flow_targets_out, + uint64_t *flow_sources_out, + uint64_t *flow_targets_out, double *flow_values_out, uint64_t *n_flows_out, double *alpha, diff --git a/ot/lp/_network_simplex.py b/ot/lp/_network_simplex.py index 7438bd131..8d033679c 100644 --- a/ot/lp/_network_simplex.py +++ b/ot/lp/_network_simplex.py @@ -234,9 +234,8 @@ def emd( If True, checks that the marginals mass are equal. If False, skips the check. - .. note:: The solver automatically detects sparse format when M is provided as: - - A scipy.sparse matrix (coo, csr, csc, etc.) - - A tuple (row_indices, col_indices, costs) representing an edge list + .. note:: The solver automatically detects sparse format when M is provided as + a scipy.sparse matrix (coo, csr, csc, etc.). For sparse inputs, the solver uses a memory-efficient algorithm and returns the flow in edge format (via log dict) instead of a full matrix. @@ -288,36 +287,35 @@ def emd( edge_costs = None n1, n2 = None, None - # Auto-detect sparse format - is_sparse = sp.issparse(M) or (isinstance(M, tuple) and len(M) == 3) + # Check for sparse format + is_sparse = sp.issparse(M) if is_sparse: - if sp.issparse(M): - if not isinstance(M, sp.coo_matrix): - M_coo = sp.coo_matrix(M) - else: - M_coo = M + # Convert to COO format for edge extraction + if not isinstance(M, sp.coo_matrix): + M_coo = sp.coo_matrix(M) + else: + M_coo = M - edge_sources = ( - M_coo.row if M_coo.row.dtype == np.int64 else M_coo.row.astype(np.int64) - ) - edge_targets = ( - M_coo.col if M_coo.col.dtype == np.int64 else M_coo.col.astype(np.int64) - ) - edge_costs = ( - M_coo.data - if M_coo.data.dtype == np.float64 - else M_coo.data.astype(np.float64) - ) - n1, n2 = M_coo.shape - elif isinstance(M, tuple) and len(M) == 3: - edge_sources = np.asarray(M[0], dtype=np.int64) - edge_targets = np.asarray(M[1], dtype=np.int64) - edge_costs = np.asarray(M[2], dtype=np.float64) - n1 = int(edge_sources.max() + 1) - n2 = int(edge_targets.max() + 1) + edge_sources = ( + M_coo.row if M_coo.row.dtype == np.uint64 else M_coo.row.astype(np.uint64) + ) + edge_targets = ( + M_coo.col if M_coo.col.dtype == np.uint64 else M_coo.col.astype(np.uint64) + ) + edge_costs = ( + M_coo.data + if M_coo.data.dtype == np.float64 + else M_coo.data.astype(np.float64) + ) + n1, n2 = M_coo.shape a, b = list_to_array(a, b) + elif isinstance(M, tuple): + raise ValueError( + "Tuple format for sparse cost matrix is not supported. " + "Please use scipy.sparse format (e.g., scipy.sparse.coo_matrix, csr_matrix, etc.)." + ) else: a, b, M = list_to_array(a, b, M) @@ -330,18 +328,14 @@ def emd( else: type_as = a + # Set n1, n2 if not already set (dense case) + if n1 is None: + n1, n2 = M.shape + if len(a) == 0: - a = ( - nx.ones((n1,), type_as=type_as) / n1 - if n1 - else nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] - ) + a = nx.ones((n1,), type_as=type_as) / n1 if len(b) == 0: - b = ( - nx.ones((n2,), type_as=type_as) / n2 - if n2 - else nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] - ) + b = nx.ones((n2,), type_as=type_as) / n2 if is_sparse: a, b = nx.to_numpy(a, b) @@ -352,9 +346,6 @@ def emd( a = np.asarray(a, dtype=np.float64) b = np.asarray(b, dtype=np.float64) - if n1 is None: - n1, n2 = M.shape - assert ( a.shape[0] == n1 and b.shape[0] == n2 ), "Dimension mismatch, check dimensions of M with a and b" @@ -374,59 +365,77 @@ def emd( numThreads = check_number_threads(numThreads) - if edge_sources is not None: + # ============================================================================ + # SPARSE SOLVER PATH + # ============================================================================ + if is_sparse: # Sparse solver - never build full matrix flow_sources, flow_targets, flow_values, cost, u, v, result_code = emd_c_sparse( a, b, edge_sources, edge_targets, edge_costs, numItermax ) - G = None - else: - G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) - - if center_dual: - u, v = center_ot_dual(u, v, a, b) - if np.any(~asel) or np.any(~bsel): - if edge_sources is not None: + # Center dual potentials + if center_dual: u, v = center_ot_dual(u, v, a, b) - else: - u, v = estimate_dual_null_weights(u, v, a, b, M) - result_code_string = check_result(result_code) - if not nx.is_floating_point(type_as): - warnings.warn( - "Input histogram consists of integer. The transport plan will be " - "casted accordingly, possibly resulting in a loss of precision. " - "If this behaviour is unwanted, please make sure your input " - "histogram consists of floating point elements.", - stacklevel=2, - ) + if np.any(~asel) or np.any(~bsel): + u, v = center_ot_dual(u, v, a, b) - if log: - log_dict = {} - log_dict["cost"] = cost - log_dict["u"] = nx.from_numpy(u, type_as=type_as) - log_dict["v"] = nx.from_numpy(v, type_as=type_as) - log_dict["warning"] = result_code_string - log_dict["result_code"] = result_code + result_code_string = check_result(result_code) - if edge_sources is not None: - # For sparse, include flow in edge format + if log: + log_dict = {} + log_dict["cost"] = cost + log_dict["u"] = nx.from_numpy(u, type_as=type_as) + log_dict["v"] = nx.from_numpy(v, type_as=type_as) + log_dict["warning"] = result_code_string + log_dict["result_code"] = result_code log_dict["flow_sources"] = flow_sources log_dict["flow_targets"] = flow_targets log_dict["flow_values"] = flow_values - if G is not None: - return nx.from_numpy(G, type_as=type_as), log_dict - else: return None, log_dict + else: + raise ValueError( + "For sparse inputs, log=True is required to get the flow in edge format" + ) - if G is not None: - return nx.from_numpy(G, type_as=type_as) + # ============================================================================ + # DENSE SOLVER PATH + # ============================================================================ else: - raise ValueError( - "For sparse inputs, log=True is required to get the flow in edge format" - ) + # Dense solver + G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) + + # Center dual potentials + if center_dual: + u, v = center_ot_dual(u, v, a, b) + + if np.any(~asel) or np.any(~bsel): + u, v = estimate_dual_null_weights(u, v, a, b, M) + + result_code_string = check_result(result_code) + + if not nx.is_floating_point(type_as): + warnings.warn( + "Input histogram consists of integer. The transport plan will be " + "casted accordingly, possibly resulting in a loss of precision. " + "If this behaviour is unwanted, please make sure your input " + "histogram consists of floating point elements.", + stacklevel=2, + ) + + if log: + log_dict = {} + log_dict["cost"] = cost + log_dict["u"] = nx.from_numpy(u, type_as=type_as) + log_dict["v"] = nx.from_numpy(v, type_as=type_as) + log_dict["warning"] = result_code_string + log_dict["result_code"] = result_code + + return nx.from_numpy(G, type_as=type_as), log_dict + else: + return nx.from_numpy(G, type_as=type_as) def emd2( @@ -501,9 +510,8 @@ def emd2( If True, checks that the marginals mass are equal. If False, skips the check. - .. note:: The solver automatically detects sparse format when M is provided as: - - A scipy.sparse matrix (coo, csr, csc, etc.) - - A tuple (row_indices, col_indices, costs) representing an edge list + .. note:: The solver automatically detects sparse format when M is provided as + a scipy.sparse matrix (coo, csr, csc, etc.). For sparse inputs, the solver uses a memory-efficient algorithm. Edges not included are treated as having infinite cost (forbidden). @@ -552,36 +560,35 @@ def emd2( edge_costs = None n1, n2 = None, None - # Auto-detect sparse format - is_sparse = sp.issparse(M) or (isinstance(M, tuple) and len(M) == 3) + # Check for sparse format + is_sparse = sp.issparse(M) if is_sparse: - if sp.issparse(M): - if not isinstance(M, sp.coo_matrix): - M_coo = sp.coo_matrix(M) - else: - M_coo = M + # Convert to COO format for edge extraction + if not isinstance(M, sp.coo_matrix): + M_coo = sp.coo_matrix(M) + else: + M_coo = M - edge_sources = ( - M_coo.row if M_coo.row.dtype == np.int64 else M_coo.row.astype(np.int64) - ) - edge_targets = ( - M_coo.col if M_coo.col.dtype == np.int64 else M_coo.col.astype(np.int64) - ) - edge_costs = ( - M_coo.data - if M_coo.data.dtype == np.float64 - else M_coo.data.astype(np.float64) - ) - n1, n2 = M_coo.shape - elif isinstance(M, tuple) and len(M) == 3: - edge_sources = np.asarray(M[0], dtype=np.int64) - edge_targets = np.asarray(M[1], dtype=np.int64) - edge_costs = np.asarray(M[2], dtype=np.float64) - n1 = int(edge_sources.max() + 1) - n2 = int(edge_targets.max() + 1) + edge_sources = ( + M_coo.row if M_coo.row.dtype == np.uint64 else M_coo.row.astype(np.uint64) + ) + edge_targets = ( + M_coo.col if M_coo.col.dtype == np.uint64 else M_coo.col.astype(np.uint64) + ) + edge_costs = ( + M_coo.data + if M_coo.data.dtype == np.float64 + else M_coo.data.astype(np.float64) + ) + n1, n2 = M_coo.shape a, b = list_to_array(a, b) + elif isinstance(M, tuple): + raise ValueError( + "Tuple format for sparse cost matrix is not supported. " + "Please use scipy.sparse format (e.g., scipy.sparse.coo_matrix, csr_matrix, etc.)." + ) else: a, b, M = list_to_array(a, b, M) @@ -594,19 +601,15 @@ def emd2( else: type_as = a # Can't use M for sparse case + # Set n1, n2 if not already set (dense case) + if n1 is None: + n1, n2 = M.shape + # if empty array given then use uniform distributions if len(a) == 0: - a = ( - nx.ones((n1,), type_as=type_as) / n1 - if n1 - else nx.ones((M.shape[0],), type_as=type_as) / M.shape[0] - ) + a = nx.ones((n1,), type_as=type_as) / n1 if len(b) == 0: - b = ( - nx.ones((n2,), type_as=type_as) / n2 - if n2 - else nx.ones((M.shape[1],), type_as=type_as) / M.shape[1] - ) + b = nx.ones((n2,), type_as=type_as) / n2 a0, b0 = a, b M0 = None if is_sparse else M @@ -625,9 +628,6 @@ def emd2( a = np.asarray(a, dtype=np.float64) b = np.asarray(b, dtype=np.float64) - if n1 is None: - n1, n2 = M.shape - assert ( a.shape[0] == n1 and b.shape[0] == n2 ), "Dimension mismatch, check dimensions of M with a and b" @@ -646,127 +646,88 @@ def emd2( numThreads = check_number_threads(numThreads) - if log or return_matrix: + # ============================================================================ + # SPARSE SOLVER PATH + # ============================================================================ + if is_sparse: def f(b): bsel = b != 0 - if edge_sources is not None: - flow_sources, flow_targets, flow_values, cost, u, v, result_code = ( - emd_c_sparse( - a, b, edge_sources, edge_targets, edge_costs, numItermax - ) - ) + # Solve sparse EMD + flow_sources, flow_targets, flow_values, cost, u, v, result_code = ( + emd_c_sparse(a, b, edge_sources, edge_targets, edge_costs, numItermax) + ) - edge_to_idx = { - (edge_sources[k], edge_targets[k]): k - for k in range(len(edge_sources)) - } - - grad_edge_costs = np.zeros(len(edge_costs), dtype=np.float64) - for idx in range(len(flow_sources)): - src, tgt, flow = ( - flow_sources[idx], - flow_targets[idx], - flow_values[idx], - ) - edge_idx = edge_to_idx.get((src, tgt), -1) - if edge_idx >= 0: - grad_edge_costs[edge_idx] = flow - - if return_matrix: - G = np.zeros((len(a), len(b)), dtype=np.float64) - G[flow_sources, flow_targets] = flow_values - else: - G = None - else: - G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) + # Build gradient mapping for edge costs + edge_to_idx = { + (edge_sources[k], edge_targets[k]): k for k in range(len(edge_sources)) + } + + grad_edge_costs = np.zeros(len(edge_costs), dtype=np.float64) + for idx in range(len(flow_sources)): + src, tgt, flow = ( + flow_sources[idx], + flow_targets[idx], + flow_values[idx], + ) + edge_idx = edge_to_idx.get((src, tgt), -1) + if edge_idx >= 0: + grad_edge_costs[edge_idx] = flow + # Center dual potentials if center_dual: u, v = center_ot_dual(u, v, a, b) if np.any(~asel) or np.any(~bsel): - if edge_sources is not None: - u, v = center_ot_dual(u, v, a, b) - else: - u, v = estimate_dual_null_weights(u, v, a, b, M) + u, v = center_ot_dual(u, v, a, b) - result_code_string = check_result(result_code) - log = {} - if not nx.is_floating_point(type_as): - warnings.warn( - "Input histogram consists of integer. The transport plan will be " - "casted accordingly, possibly resulting in a loss of precision. " - "If this behaviour is unwanted, please make sure your input " - "histogram consists of floating point elements.", - stacklevel=2, - ) + # Prepare cost with gradients + cost = nx.set_gradients( + nx.from_numpy(cost, type_as=type_as), + (a0, b0, edge_costs_original), + ( + nx.from_numpy(u - np.mean(u), type_as=type_as), + nx.from_numpy(v - np.mean(v), type_as=type_as), + nx.from_numpy(grad_edge_costs, type_as=type_as), + ), + ) + + check_result(result_code) + + if log or return_matrix: + log_dict = {} + log_dict["u"] = nx.from_numpy(u, type_as=type_as) + log_dict["v"] = nx.from_numpy(v, type_as=type_as) + log_dict["warning"] = check_result(result_code) + log_dict["result_code"] = result_code - if G is not None: - G = nx.from_numpy(G, type_as=type_as) if return_matrix: - log["G"] = G - log["u"] = nx.from_numpy(u, type_as=type_as) - log["v"] = nx.from_numpy(v, type_as=type_as) - log["warning"] = result_code_string - log["result_code"] = result_code - - if edge_sources is not None: - cost = nx.set_gradients( - nx.from_numpy(cost, type_as=type_as), - (a0, b0, edge_costs_original), - ( - log["u"] - nx.mean(log["u"]), - log["v"] - nx.mean(log["v"]), - nx.from_numpy(grad_edge_costs, type_as=type_as), - ), - ) + G = np.zeros((len(a), len(b)), dtype=np.float64) + G[flow_sources, flow_targets] = flow_values + log_dict["G"] = nx.from_numpy(G, type_as=type_as) + + return [cost, log_dict] else: - cost = nx.set_gradients( - nx.from_numpy(cost, type_as=type_as), - (a0, b0, M0), - (log["u"] - nx.mean(log["u"]), log["v"] - nx.mean(log["v"]), G), - ) - return [cost, log] + return cost + + # ============================================================================ + # DENSE SOLVER PATH + # ============================================================================ else: def f(b): bsel = b != 0 - if edge_sources is not None: - flow_sources, flow_targets, flow_values, cost, u, v, result_code = ( - emd_c_sparse( - a, b, edge_sources, edge_targets, edge_costs, numItermax - ) - ) - - edge_to_idx = { - (edge_sources[k], edge_targets[k]): k - for k in range(len(edge_sources)) - } - grad_edge_costs = np.zeros(len(edge_costs), dtype=np.float64) - for idx in range(len(flow_sources)): - src, tgt, flow = ( - flow_sources[idx], - flow_targets[idx], - flow_values[idx], - ) - edge_idx = edge_to_idx.get((src, tgt), -1) - if edge_idx >= 0: - grad_edge_costs[edge_idx] = flow - - G = None - else: - G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) + # Solve dense EMD + G, cost, u, v, result_code = emd_c(a, b, M, numItermax, numThreads) + # Center dual potentials if center_dual: u, v = center_ot_dual(u, v, a, b) if np.any(~asel) or np.any(~bsel): - if edge_sources is not None: - u, v = center_ot_dual(u, v, a, b) - else: - u, v = estimate_dual_null_weights(u, v, a, b, M) + u, v = estimate_dual_null_weights(u, v, a, b, M) if not nx.is_floating_point(type_as): warnings.warn( @@ -777,31 +738,32 @@ def f(b): stacklevel=2, ) - if edge_sources is not None: - # Sparse: gradient w.r.t. edge_costs (no need to convert G) - cost = nx.set_gradients( - nx.from_numpy(cost, type_as=type_as), - (a0, b0, edge_costs_original), - ( - nx.from_numpy(u - np.mean(u), type_as=type_as), - nx.from_numpy(v - np.mean(v), type_as=type_as), - nx.from_numpy(grad_edge_costs, type_as=type_as), - ), - ) - else: - G = nx.from_numpy(G, type_as=type_as) - cost = nx.set_gradients( - nx.from_numpy(cost, type_as=type_as), - (a0, b0, M0), - ( - nx.from_numpy(u - np.mean(u), type_as=type_as), - nx.from_numpy(v - np.mean(v), type_as=type_as), - G, - ), - ) + G = nx.from_numpy(G, type_as=type_as) + cost = nx.set_gradients( + nx.from_numpy(cost, type_as=type_as), + (a0, b0, M0), + ( + nx.from_numpy(u - np.mean(u), type_as=type_as), + nx.from_numpy(v - np.mean(v), type_as=type_as), + G, + ), + ) check_result(result_code) - return cost + + if log or return_matrix: + log_dict = {} + log_dict["u"] = nx.from_numpy(u, type_as=type_as) + log_dict["v"] = nx.from_numpy(v, type_as=type_as) + log_dict["warning"] = check_result(result_code) + log_dict["result_code"] = result_code + + if return_matrix: + log_dict["G"] = G + + return [cost, log_dict] + else: + return cost if len(b.shape) == 1: return f(b) diff --git a/ot/lp/emd_wrap.pyx b/ot/lp/emd_wrap.pyx index f95e47433..3b19d3fdd 100644 --- a/ot/lp/emd_wrap.pyx +++ b/ot/lp/emd_wrap.pyx @@ -22,7 +22,7 @@ import warnings cdef extern from "EMD.h": int EMD_wrap(int n1,int n2, double *X, double *Y,double *D, double *G, double* alpha, double* beta, double *cost, uint64_t maxIter) nogil int EMD_wrap_omp(int n1,int n2, double *X, double *Y,double *D, double *G, double* alpha, double* beta, double *cost, uint64_t maxIter, int numThreads) nogil - int EMD_wrap_sparse(int n1, int n2, double *X, double *Y, uint64_t n_edges, int64_t *edge_sources, int64_t *edge_targets, double *edge_costs, int64_t *flow_sources_out, int64_t *flow_targets_out, double *flow_values_out, uint64_t *n_flows_out, double *alpha, double *beta, double *cost, uint64_t maxIter) nogil + int EMD_wrap_sparse(int n1, int n2, double *X, double *Y, uint64_t n_edges, uint64_t *edge_sources, uint64_t *edge_targets, double *edge_costs, uint64_t *flow_sources_out, uint64_t *flow_targets_out, double *flow_values_out, uint64_t *n_flows_out, double *alpha, double *beta, double *cost, uint64_t maxIter) nogil cdef enum ProblemType: INFEASIBLE, OPTIMAL, UNBOUNDED, MAX_ITER_REACHED @@ -212,8 +212,8 @@ def emd_1d_sorted(np.ndarray[double, ndim=1, mode="c"] u_weights, @cython.wraparound(False) def emd_c_sparse(np.ndarray[double, ndim=1, mode="c"] a, np.ndarray[double, ndim=1, mode="c"] b, - np.ndarray[int64_t, ndim=1, mode="c"] edge_sources, - np.ndarray[int64_t, ndim=1, mode="c"] edge_targets, + np.ndarray[uint64_t, ndim=1, mode="c"] edge_sources, + np.ndarray[uint64_t, ndim=1, mode="c"] edge_targets, np.ndarray[double, ndim=1, mode="c"] edge_costs, uint64_t max_iter): """ @@ -225,9 +225,9 @@ def emd_c_sparse(np.ndarray[double, ndim=1, mode="c"] a, Source histogram b : (n2,) array Target histogram - edge_sources : (k,) array, int64 + edge_sources : (k,) array, uint64 Source indices for each edge - edge_targets : (k,) array, int64 + edge_targets : (k,) array, uint64 Target indices for each edge edge_costs : (k,) array, float64 Cost for each edge @@ -236,9 +236,9 @@ def emd_c_sparse(np.ndarray[double, ndim=1, mode="c"] a, Returns ------- - flow_sources : (n_flows,) array, int64 + flow_sources : (n_flows,) array, uint64 Source indices of non-zero flows - flow_targets : (n_flows,) array, int64 + flow_targets : (n_flows,) array, uint64 Target indices of non-zero flows flow_values : (n_flows,) array, float64 Flow values @@ -259,8 +259,8 @@ def emd_c_sparse(np.ndarray[double, ndim=1, mode="c"] a, cdef double cost = 0 # Allocate output arrays (max size = n_edges) - cdef np.ndarray[int64_t, ndim=1, mode="c"] flow_sources = np.zeros(n_edges, dtype=np.int64) - cdef np.ndarray[int64_t, ndim=1, mode="c"] flow_targets = np.zeros(n_edges, dtype=np.int64) + cdef np.ndarray[uint64_t, ndim=1, mode="c"] flow_sources = np.zeros(n_edges, dtype=np.uint64) + cdef np.ndarray[uint64_t, ndim=1, mode="c"] flow_targets = np.zeros(n_edges, dtype=np.uint64) cdef np.ndarray[double, ndim=1, mode="c"] flow_values = np.zeros(n_edges, dtype=np.float64) cdef np.ndarray[double, ndim=1, mode="c"] alpha = np.zeros(n1) cdef np.ndarray[double, ndim=1, mode="c"] beta = np.zeros(n2) @@ -270,8 +270,8 @@ def emd_c_sparse(np.ndarray[double, ndim=1, mode="c"] a, n1, n2, a.data, b.data, n_edges, - edge_sources.data, edge_targets.data, edge_costs.data, - flow_sources.data, flow_targets.data, flow_values.data, + edge_sources.data, edge_targets.data, edge_costs.data, + flow_sources.data, flow_targets.data, flow_values.data, &n_flows_out, alpha.data, beta.data, &cost, max_iter ) diff --git a/test/test_ot.py b/test/test_ot.py index 6b57c8602..27c9200d8 100644 --- a/test/test_ot.py +++ b/test/test_ot.py @@ -1089,6 +1089,172 @@ def test_emd2_sparse_vs_dense(): np.testing.assert_allclose(cost_dense, cost_sparse, rtol=1e-5, atol=1e-7) +def test_emd_sparse_backends(nx): + """Test that sparse EMD works with different backends for weights a and b. + + Uses augmented k-NN graph approach to ensure feasibility. + """ + n_source = 50 + n_target = 50 + k = 10 + + rng = np.random.RandomState(42) + + # Create distributions + a = ot.utils.unif(n_source) + b = ot.utils.unif(n_target) + + # Create cost matrix + x_source = rng.randn(n_source, 2) + x_target = rng.randn(n_target, 2) + 0.5 + C = ot.dist(x_source, x_target) + + # Create sparse k-NN graph + rows = [] + cols = [] + data = [] + + for i in range(n_source): + distances = C[i, :] + nearest_k = np.argpartition(distances, k)[:k] + for j in nearest_k: + rows.append(i) + cols.append(j) + data.append(C[i, j]) + + C_knn = coo_matrix((data, (rows, cols)), shape=(n_source, n_target)) + + # Augment with necessary edges (same approach as test_emd_sparse_vs_dense) + large_cost = 1e8 + C_dense_infty = np.full((n_source, n_target), large_cost) + C_knn_array = C_knn.toarray() + C_dense_infty[C_knn_array > 0] = C_knn_array[C_knn_array > 0] + + G_dense_initial = ot.emd(a, b, C_dense_infty) + eps = 1e-9 + active_mask = G_dense_initial > eps + knn_mask = C_knn_array > 0 + extra_edges_mask = active_mask & ~knn_mask + + rows_aug = [] + cols_aug = [] + data_aug = [] + + knn_rows, knn_cols = np.where(knn_mask) + for i, j in zip(knn_rows, knn_cols): + rows_aug.append(i) + cols_aug.append(j) + data_aug.append(C[i, j]) + + extra_rows, extra_cols = np.where(extra_edges_mask) + for i, j in zip(extra_rows, extra_cols): + rows_aug.append(i) + cols_aug.append(j) + data_aug.append(C[i, j]) + + C_augmented = coo_matrix( + (data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target) + ) + + # Test with numpy weights (baseline) + _, log_np = ot.emd(a, b, C_augmented, log=True) + + # Test with backend weights + ab, bb = nx.from_numpy(a, b) + _, log_backend = ot.emd(ab, bb, C_augmented, log=True) + + # Compare costs + cost_np = log_np["cost"] + cost_backend = nx.to_numpy(log_backend["cost"]) + + np.testing.assert_allclose(cost_np, cost_backend, rtol=1e-5, atol=1e-7) + + # Check flow values match + np.testing.assert_allclose( + log_np["flow_values"], log_backend["flow_values"], rtol=1e-5, atol=1e-7 + ) + + +def test_emd2_sparse_backends(nx): + """Test that sparse emd2 works with different backends for weights a and b. + + Uses augmented k-NN graph approach to ensure feasibility. + """ + n_source = 50 + n_target = 50 + k = 10 + + rng = np.random.RandomState(42) + + # Create distributions + a = ot.utils.unif(n_source) + b = ot.utils.unif(n_target) + + # Create cost matrix + x_source = rng.randn(n_source, 2) + x_target = rng.randn(n_target, 2) + 0.5 + C = ot.dist(x_source, x_target) + + # Create sparse k-NN graph + rows = [] + cols = [] + data = [] + + for i in range(n_source): + distances = C[i, :] + nearest_k = np.argpartition(distances, k)[:k] + for j in nearest_k: + rows.append(i) + cols.append(j) + data.append(C[i, j]) + + C_knn = coo_matrix((data, (rows, cols)), shape=(n_source, n_target)) + + # Augment with necessary edges (same approach as test_emd2_sparse_vs_dense) + large_cost = 1e8 + C_dense_infty = np.full((n_source, n_target), large_cost) + C_knn_array = C_knn.toarray() + C_dense_infty[C_knn_array > 0] = C_knn_array[C_knn_array > 0] + + G_dense_initial = ot.emd(a, b, C_dense_infty) + eps = 1e-9 + active_mask = G_dense_initial > eps + knn_mask = C_knn_array > 0 + extra_edges_mask = active_mask & ~knn_mask + + rows_aug = [] + cols_aug = [] + data_aug = [] + + knn_rows, knn_cols = np.where(knn_mask) + for i, j in zip(knn_rows, knn_cols): + rows_aug.append(i) + cols_aug.append(j) + data_aug.append(C[i, j]) + + extra_rows, extra_cols = np.where(extra_edges_mask) + for i, j in zip(extra_rows, extra_cols): + rows_aug.append(i) + cols_aug.append(j) + data_aug.append(C[i, j]) + + C_augmented = coo_matrix( + (data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target) + ) + + # Test with numpy weights (baseline) + cost_np = ot.emd2(a, b, C_augmented) + + # Test with backend weights + ab, bb = nx.from_numpy(a, b) + cost_backend = ot.emd2(ab, bb, C_augmented) + + # Compare costs + cost_backend_np = nx.to_numpy(cost_backend) + + np.testing.assert_allclose(cost_np, cost_backend_np, rtol=1e-5, atol=1e-7) + + def check_duality_gap(a, b, M, G, u, v, cost): cost_dual = np.vdot(a, u) + np.vdot(b, v) # Check that dual and primal cost are equal From fae9f026a8abd85511de0903fc0409bbfcf1acf7 Mon Sep 17 00:00:00 2001 From: nathanneike Date: Mon, 3 Nov 2025 13:48:49 +0100 Subject: [PATCH 5/9] fix : Quick test file fix --- test/test_ot.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/test/test_ot.py b/test/test_ot.py index 27c9200d8..691f77382 100644 --- a/test/test_ot.py +++ b/test/test_ot.py @@ -1100,16 +1100,13 @@ def test_emd_sparse_backends(nx): rng = np.random.RandomState(42) - # Create distributions a = ot.utils.unif(n_source) b = ot.utils.unif(n_target) - # Create cost matrix x_source = rng.randn(n_source, 2) x_target = rng.randn(n_target, 2) + 0.5 C = ot.dist(x_source, x_target) - # Create sparse k-NN graph rows = [] cols = [] data = [] @@ -1156,20 +1153,16 @@ def test_emd_sparse_backends(nx): (data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target) ) - # Test with numpy weights (baseline) _, log_np = ot.emd(a, b, C_augmented, log=True) - # Test with backend weights ab, bb = nx.from_numpy(a, b) _, log_backend = ot.emd(ab, bb, C_augmented, log=True) - # Compare costs cost_np = log_np["cost"] cost_backend = nx.to_numpy(log_backend["cost"]) np.testing.assert_allclose(cost_np, cost_backend, rtol=1e-5, atol=1e-7) - # Check flow values match np.testing.assert_allclose( log_np["flow_values"], log_backend["flow_values"], rtol=1e-5, atol=1e-7 ) @@ -1186,16 +1179,13 @@ def test_emd2_sparse_backends(nx): rng = np.random.RandomState(42) - # Create distributions a = ot.utils.unif(n_source) b = ot.utils.unif(n_target) - # Create cost matrix x_source = rng.randn(n_source, 2) x_target = rng.randn(n_target, 2) + 0.5 C = ot.dist(x_source, x_target) - # Create sparse k-NN graph rows = [] cols = [] data = [] @@ -1242,14 +1232,11 @@ def test_emd2_sparse_backends(nx): (data_aug, (rows_aug, cols_aug)), shape=(n_source, n_target) ) - # Test with numpy weights (baseline) cost_np = ot.emd2(a, b, C_augmented) - # Test with backend weights ab, bb = nx.from_numpy(a, b) cost_backend = ot.emd2(ab, bb, C_augmented) - # Compare costs cost_backend_np = nx.to_numpy(cost_backend) np.testing.assert_allclose(cost_np, cost_backend_np, rtol=1e-5, atol=1e-7) From b184cd4e95fa3a8aa969acd4f8ee006601e1fcca Mon Sep 17 00:00:00 2001 From: nathanneike Date: Tue, 18 Nov 2025 15:25:58 +0100 Subject: [PATCH 6/9] Added Example for documentation and modified back setup file to original file --- docs/source/_static/images/comparison.png | Bin 0 -> 287097 bytes examples/plot_sparse_emd.py | 306 ++++++++++++++++++++++ setup.py | 14 +- 3 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 docs/source/_static/images/comparison.png create mode 100644 examples/plot_sparse_emd.py diff --git a/docs/source/_static/images/comparison.png b/docs/source/_static/images/comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..587a4fb955c611fa8dac0cf3b32b243852597780 GIT binary patch literal 287097 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