diff --git a/RELEASES.md b/RELEASES.md index 5874aff79..fdef6c517 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -20,6 +20,7 @@ - Fix mean centering in `ot.dr.fda` and `ot.dr.wda`: `np.mean(X)` returned a scalar instead of the per-feature mean, so `proj` did not center the data as documented. In `ot.dr.fda` the same pattern in the class means made the between-class scatter matrix independent of which features separate the classes, and FDA returned a non-discriminant direction (PR #840) - `ot.dr.fda` and `ot.dr.wda` no longer modify the input array `X` in place (PR #840) - Fix `UnbalancedSinkhornTransport` `transform` failing with `AttributeError: 'NoneType' object has no attribute 'array_equal'` when `fit` was called with missing parameters (PR #837, Issue #650) +- Fix quantized (F)GW solvers that ordered OT based on clusters and not initial node ordering (PR #857, Issue #786) ## 0.9.7.post1 diff --git a/ot/gromov/_quantized.py b/ot/gromov/_quantized.py index 861cbfd46..d855bbcbb 100644 --- a/ot/gromov/_quantized.py +++ b/ot/gromov/_quantized.py @@ -40,6 +40,8 @@ def quantized_fused_gromov_wasserstein_partitioned( list_R2, list_p1, list_p2, + part1=None, + part2=None, MR=None, alpha=1.0, build_OT=False, @@ -113,6 +115,12 @@ def quantized_fused_gromov_wasserstein_partitioned( List of node distributions within each partition of the source space. list_p : list of npart2 arrays, List of node distributions within each partition of the target space. + part1 : list of npart1 arrays, optional. Default is None. + List of arrays containing the indices of nodes in each partition of the source space. + Required as input if `build_OT=True`. + part2 : list of npart2 arrays, optional. Default is None. + List of arrays containing the indices of nodes in each partition of the target space. + Required as input if `build_OT=True`. MR : array-like, shape (npart1, npart2), optional. (Default is None) Metric cost matrix between features of representants across spaces. alpha: float, optional. Default is None. @@ -156,19 +164,33 @@ def quantized_fused_gromov_wasserstein_partitioned( """ if nx is None: - arr = [CR1, CR2, *list_R1, *list_R2, *list_p1, *list_p2] - - if MR is not None: - arr.append(MR) + arr = [CR1, CR2, *list_R1, *list_R2, *list_p1, *list_p2, MR] + if build_OT: + arr += [*part1, *part2] nx = get_backend(*arr) npart1 = len(list_R1) npart2 = len(list_R2) + if (npart1 != len(list_p1)) or (npart2 != len(list_p2)): + raise ValueError( + f""" + Inconsistent number of partitions between list_R1 ({npart1}), list_p1 ({len(list_p1)}), + and list_R2 ({npart2}), list_p2 ({len(list_p2)}). + """ + ) + if build_OT and ((npart1 != len(part1)) or (npart2 != len(part2))): + raise ValueError( + f""" + Inconsistent number of partitions in part1 ({len(part1)}) + and part2 ({len(part2)}). + """ + ) + # compute marginals for global alignment - pR1 = nx.from_numpy(list_to_array([nx.sum(p) for p in list_p1])) - pR2 = nx.from_numpy(list_to_array([nx.sum(q) for q in list_p2])) + pR1 = nx.from_numpy(list_to_array([nx.sum(p) for p in list_p1]), type_as=CR1) + pR2 = nx.from_numpy(list_to_array([nx.sum(q) for q in list_p2]), type_as=CR2) # compute global alignment if alpha == 1.0: @@ -247,22 +269,25 @@ def quantized_fused_gromov_wasserstein_partitioned( Ts_local[(i, j)] = res_1d if build_OT: - T_rows = [] - for i in range(npart1): - list_Ti = [] - for j in range(npart2): - if T_global[i, j] == 0.0: - T_local = nx.zeros( - (list_R1[i].shape[0], list_R2[j].shape[0]), type_as=T_global - ) - else: - T_local = T_global[i, j] * Ts_local[(i, j)] - list_Ti.append(T_local) - - Ti = nx.concatenate(list_Ti, axis=1) - T_rows.append(Ti) - T = nx.concatenate(T_rows, axis=0) - + # Do memory-efficient alternatives by backend for mutable vs non-mutable tensors + if nx.__name__ in ("numpy", "torch"): + T = _build_full_transport_by_assignment( + T_global, + Ts_local, + part1, + part2, + nx, + ) + else: + T = _build_full_transport_by_concatenation( + T_global, + Ts_local, + list_R1, + list_R2, + part1, + part2, + nx, + ) else: T = None @@ -273,6 +298,124 @@ def quantized_fused_gromov_wasserstein_partitioned( return T_global, Ts_local, T +def _build_full_transport_by_assignment( + T_global, + Ts_local, + part1, + part2, + nx, +): + """ + Build the full transport matrix by assigning local transport blocks. + + The local couplings are written directly into the rows and columns + identified by each source and target partition. This preserves the + original sample ordering without materializing a partition-ordered + intermediate matrix. + + Parameters + ---------- + T_global : array-like, shape (npart1, npart2) + Global transport between source and target partition representants. + Ts_local : dict + Dictionary of local transport matrices keyed by ``(i, j)``. + part1 : list of array-like + Source partition indices in the original sample ordering. + part2 : list of array-like + Target partition indices in the original sample ordering. + nx : backend + POT backend used to create and assign the transport matrix. + + Returns + ------- + T : array-like, shape (ns, nt) + Full transport matrix between the original source and target samples. + """ + ns = sum(indices.shape[0] for indices in part1) + nt = sum(indices.shape[0] for indices in part2) + + T = nx.zeros((ns, nt), type_as=T_global) + + for i, rows in enumerate(part1): + for j, cols in enumerate(part2): + if T_global[i, j] != 0.0: + T_block = T_global[i, j] * Ts_local[(i, j)] + T[rows[:, None], cols[None, :]] = T_block + + return T + + +def _build_full_transport_by_concatenation( + T_global, + Ts_local, + list_R1, + list_R2, + part1, + part2, + nx, +): + """ + Build the full transport matrix by concatenating partition blocks. + + Local couplings are first concatenated in source and target partition + order, then rows and columns are reordered according to the partition + indices so that the result uses the original sample ordering. + + Parameters + ---------- + T_global : array-like, shape (npart1, npart2) + Global transport between source and target partition representants. + Ts_local : dict + Dictionary of local transport matrices keyed by ``(i, j)``. + list_R1 : list of array-like + Source representative-to-sample relations, used to determine block + dimensions. + list_R2 : list of array-like + Target representative-to-sample relations, used to determine block + dimensions. + part1 : list of array-like + Source partition indices in the original sample ordering. + part2 : list of array-like + Target partition indices in the original sample ordering. + nx : backend + POT backend used to concatenate and reorder the transport matrix. + + Returns + ------- + T : array-like, shape (ns, nt) + Full transport matrix between the original source and target samples. + """ + T_rows = [] + + for i in range(len(list_R1)): + T_blocks = [] + + for j in range(len(list_R2)): + if T_global[i, j] == 0.0: + T_block = nx.zeros( + (list_R1[i].shape[0], list_R2[j].shape[0]), + type_as=T_global, + ) + else: + T_block = T_global[i, j] * Ts_local[(i, j)] + + T_blocks.append(T_block) + + T_rows.append(nx.concatenate(T_blocks, axis=1)) + + T = nx.concatenate(T_rows, axis=0) + + perm1 = nx.concatenate(part1, axis=0) + perm2 = nx.concatenate(part2, axis=0) + + T = T[nx.argsort(perm1)] + T = nx.transpose(T) + T = T[nx.argsort(perm2)] + T = nx.transpose(T) + + return T + + def get_graph_partition( C, npart, part_method="random", F=None, alpha=1.0, random_state=0, nx=None ): @@ -308,8 +451,8 @@ def get_graph_partition( Returns ------- - part : array-like, shape (npart,) - Array of partition assignment for each node. + part : list of array-like, length npart + List of arrays containing the indices of nodes in each partition. References ---------- @@ -333,40 +476,38 @@ def get_graph_partition( stacklevel=2, ) - part = np.arange(n) + part = list(nx.arange(n)[:, None]) elif npart == 1: - part = np.zeros(n) + part = [nx.arange(n)] elif part_method == "random": # randomly partition the space random.seed(random_state) - part = list_to_array(random.choices(np.arange(npart), k=C.shape[0])) - - elif part_method == "louvain": - C = nx.to_numpy(C0) - graph = from_numpy_array(C) - part_sets = louvain_communities(graph, seed=random_state) - part = np.zeros(n) - for iset_, set_ in enumerate(part_sets): - set_ = list(set_) - part[set_] = iset_ + part_assignments = random.choices(np.arange(npart), k=n) + part = [ + nx.from_numpy(np.where(np.array(part_assignments) == i)[0]) + for i in range(npart) + ] - elif part_method == "fluid": + elif part_method in ["louvain", "fluid"]: C = nx.to_numpy(C0) graph = from_numpy_array(C) - part_sets = asyn_fluidc(graph, npart, seed=random_state) - part = np.zeros(n) - for iset_, set_ in enumerate(part_sets): - set_ = list(set_) - part[set_] = iset_ + if part_method == "louvain": + part_sets = louvain_communities(graph, seed=random_state) + else: + part_sets = asyn_fluidc(graph, npart, seed=random_state) + part = [ + nx.from_numpy(np.array(list(nodes)).astype(np.int64)) for nodes in part_sets + ] elif part_method == "spectral": C = nx.to_numpy(C0) sc = SpectralClustering( n_clusters=npart, random_state=random_state, affinity="precomputed" ).fit(C) - part = sc.labels_ + labels = sc.labels_ + part = [nx.from_numpy(np.where(labels == i)[0]) for i in range(npart)] elif part_method in ["GW", "FGW"]: raise ValueError(f"`part_method == {part_method}` not implemented yet.") @@ -378,7 +519,8 @@ def get_graph_partition( {"random", "louvain", "fluid", "spectral", "GW", "FGW"}. """ ) - return nx.from_numpy(part, type_as=C0) + + return part def get_graph_representants(C, part, rep_method="pagerank", random_state=0, nx=None): @@ -391,8 +533,8 @@ def get_graph_representants(C, part, rep_method="pagerank", random_state=0, nx=N ---------- C : array-like, shape (n, n) structure matrix. - part : array-like, shape (n,) - Array of partition assignment for each node. + part : list of array-like, length npart + List of arrays containing the indices of nodes in each partition. rep_method : str, optional. Default is 'pagerank'. Selection method for representant in each partition. Can be either 'random' i.e random sampling within each partition, or 'pagerank' to select a @@ -404,9 +546,9 @@ def get_graph_representants(C, part, rep_method="pagerank", random_state=0, nx=N Returns ------- - rep_indices : list, shape (npart,) - indices for representative node of each partition sorted - according to partition identifiers. + rep_indices : array-like, shape (npart,) + Array of indices for representative node of each partition sorted + according to partition order in `part` with same type as `C`. References ---------- @@ -415,34 +557,31 @@ def get_graph_representants(C, part, rep_method="pagerank", random_state=0, nx=N """ if nx is None: - nx = get_backend(C, part) + nx = get_backend(C, *part) rep_indices = [] - part_ids = nx.unique(part) - n_part_ids = part_ids.shape[0] - if n_part_ids == C.shape[0]: - rep_indices = nx.arange(n_part_ids) + n = C.shape[0] + n_part = len(part) + if n_part == n: + rep_indices = [indices[0] for indices in part] elif rep_method == "random": random.seed(random_state) - for id_, part_id in enumerate(part_ids): - indices = nx.where(part == part_id)[0] + for indices in part: rep_indices.append(random.choice(indices)) elif rep_method == "pagerank": C0, part0 = C, part C = nx.to_numpy(C0) - part = nx.to_numpy(part0) - part_ids = np.unique(part) + part = [nx.to_numpy(indices) for indices in part] - for id_ in part_ids: - indices = np.where(part == id_)[0] + for indices in part: C_id = C[indices, :][:, indices] graph = from_numpy_array(C_id) pagerank_values = list(pagerank(graph).values()) rep_idx = np.argmax(pagerank_values) rep_indices.append(indices[rep_idx]) - + C, part = C0, part0 else: raise ValueError( f""" @@ -450,7 +589,7 @@ def get_graph_representants(C, part, rep_method="pagerank", random_state=0, nx=N {"random", "pagerank"}. """ ) - + rep_indices = nx.from_numpy(list_to_array(rep_indices).astype(np.int64)) return rep_indices @@ -471,11 +610,11 @@ def format_partitioned_graph( Structure matrix. p : array-like, shape (n,), Node distribution. - part : array-like, shape (n,) - Array of partition assignment for each node. - rep_indices : list of array-like of ints, shape (npart,) - indices for representative node of each partition sorted according to - partition identifiers. + part : list of array-like, length npart + List of arrays containing the indices of nodes in each partition. + rep_indices : array-like, shape (npart,) + Array of indices for representative node of each partition sorted + according to partition order in `part` with same type as `C`. F : array-like, shape (n, d), optional. (Default is None) Optional feature matrix aligned with the graph structure. M : array-like, shape (n, n), optional. (Default is None) @@ -492,7 +631,7 @@ def format_partitioned_graph( CR : array-like, shape (npart, npart) Structure matrix between partition representants. list_R : list of npart arrays, - List of relations between a representant and nodes in its partition, + List of relations between a representant and the nodes in its partition, for each partition. list_p : list of npart arrays, List of node distributions within each partition. @@ -506,13 +645,7 @@ def format_partitioned_graph( """ if nx is None: - arr = [C, p, part] - if F is not None: - arr.append(F) - if M is not None: - arr.append(M) - - nx = get_backend(*arr) + nx = get_backend(C, p, *part, rep_indices, F, M) if alpha != 1.0: if (M is None) or (F is None): @@ -531,11 +664,8 @@ def format_partitioned_graph( list_R, list_p = [], [] - part_ids = nx.unique(part) - - for id_, part_id in enumerate(part_ids): - indices = nx.where(part == part_id)[0] - list_R.append(C_new[rep_indices[id_], indices]) + for cluster_id, indices in enumerate(part): + list_R.append(C_new[rep_indices[cluster_id], indices]) list_p.append(p[indices]) if F is None: @@ -727,14 +857,10 @@ def quantized_fused_gromov_wasserstein( """ ) - arr = [C1, C2] - if C1_aux is not None: - arr.append(C1_aux) - else: + arr = [C1, C2, C1_aux, C2_aux, F1, F2] + if C1_aux is None: C1_aux = C1 - if C2_aux is not None: - arr.append(C2_aux) - else: + if C2_aux is None: C2_aux = C2 if p is not None: arr.append(list_to_array(p)) @@ -744,10 +870,6 @@ def quantized_fused_gromov_wasserstein( arr.append(list_to_array(q)) else: q = unif(C2.shape[0], type_as=C1) - if F1 is not None: - arr.append(F1) - if F2 is not None: - arr.append(F1) nx = get_backend(*arr) @@ -832,6 +954,8 @@ def quantized_fused_gromov_wasserstein( list_R2, list_p1, list_p2, + part1, + part2, MR, alpha, build_OT=True, @@ -892,8 +1016,8 @@ def get_partition_and_representants_samples( Returns ------- - part : array-like, shape (npart,) - Array of partition assignment for each node. + part : list of array-like, length npart + List of arrays containing the indices of nodes in each partition. rep_indices : list, shape (npart,) indices for representative node of each partition sorted @@ -918,36 +1042,42 @@ def get_partition_and_representants_samples( stacklevel=2, ) - part = nx.arange(n) - rep_indices = nx.arange(n) + part = list(nx.arange(n)[:, None]) + rep_indices = [i for i in range(n)] elif npart == 1: random.seed(random_state) - part = nx.zeros(n) - rep_indices = [random.choice(nx.arange(n))] + part = [nx.arange(n)] + rep_indices = [random.choice(np.arange(n))] elif method == "random": # randomly partition the space random.seed(random_state) - part = list_to_array(random.choices(np.arange(npart), k=X.shape[0])) - part = nx.from_numpy(part, type_as=X0) + part_assignments = random.choices(np.arange(npart), k=X.shape[0]) + part = [ + nx.from_numpy(np.where(np.array(part_assignments) == i)[0]) + for i in range(npart) + ] # randomly select representant in each partition rep_indices = [] - part_ids = nx.unique(part) - for id_, part_id in enumerate(part_ids): - indices = nx.where(part == part_id)[0] + for indices_array in part: + indices = nx.to_numpy(indices_array) rep_indices.append(random.choice(indices)) elif method == "kmeans": X = nx.to_numpy(X0) km = KMeans(n_clusters=npart, random_state=random_state).fit(X) - part = nx.from_numpy(km.labels_, type_as=X0) + labels = km.labels_ + part = [ + nx.from_numpy(np.where(labels == i)[0].astype(np.int64)) + for i in range(npart) + ] rep_indices = [] - for part_id in range(npart): - indices = nx.where(part == part_id)[0] - dists = dist(X[indices], km.cluster_centers_[part_id][None, :]) + for i in range(npart): + indices = np.where(labels == i)[0] + dists = dist(X[indices], km.cluster_centers_[i][None, :]) best_idx = indices[dists.argmin()] rep_indices.append(best_idx) @@ -958,6 +1088,9 @@ def get_partition_and_representants_samples( """ ) + rep_indices = nx.from_numpy(list_to_array(rep_indices), type_as=part[0]) + # print('part:', type(part), type(part[0]), part[0].dtype) + # print('rep_indices:', type(rep_indices), rep_indices.dtype) return part, rep_indices @@ -976,11 +1109,11 @@ def format_partitioned_samples(X, p, part, rep_indices, F=None, alpha=1.0, nx=No Structure matrix. p : array-like, shape (n,), Node distribution. - part : array-like, shape (n,) - Array of partition assignment for each node. - rep_indices : list of array-like of ints, shape (npart,) - indices for representative node of each partition sorted according to - partition identifiers. + part : list of array-like, length npart + List of arrays containing the indices of nodes in each partition. + rep_indices : array-like, shape (npart,) + Array of indices for representative node of each partition sorted + according to partition identifiers. F : array-like, shape (n, p), optional. (Default is None) Optional feature matrix aligned with the samples. alpha: float, optional. Default is 1. @@ -1009,7 +1142,7 @@ def format_partitioned_samples(X, p, part, rep_indices, F=None, alpha=1.0, nx=No """ if nx is None: - arr = [X, p, part] + arr = [X, p, *part, rep_indices] if F is not None: arr.append(F) @@ -1028,14 +1161,11 @@ def format_partitioned_samples(X, p, part, rep_indices, F=None, alpha=1.0, nx=No list_R, list_p = [], [] - part_ids = nx.unique(part) - - for id_, part_id in enumerate(part_ids): - indices = nx.where(part == part_id)[0] - structure_R = dist(X[indices], X[rep_indices[id_]][None, :]) + for cluster_id, indices in enumerate(part): + structure_R = dist(X[indices], X[rep_indices[cluster_id]][None, :]) if alpha != 1: - features_R = dist(F[indices], F[rep_indices[id_]][None, :]) + features_R = dist(F[indices], F[rep_indices[cluster_id]][None, :]) else: features_R = 0.0 @@ -1195,7 +1325,7 @@ def quantized_fused_gromov_wasserstein_samples( """ ) - arr = [X1, X2] + arr = [X1, X2, F1, F2] if p is not None: arr.append(list_to_array(p)) else: @@ -1204,10 +1334,6 @@ def quantized_fused_gromov_wasserstein_samples( arr.append(list_to_array(q)) else: q = unif(X2.shape[0], type_as=X1) - if F1 is not None: - arr.append(F1) - if F2 is not None: - arr.append(F1) nx = get_backend(*arr) @@ -1255,6 +1381,8 @@ def quantized_fused_gromov_wasserstein_samples( list_R2, list_p1, list_p2, + part1, + part2, MR, alpha, build_OT=True, diff --git a/test/gromov/test_quantized.py b/test/gromov/test_quantized.py index c3b80bb7d..5a14e9450 100644 --- a/test/gromov/test_quantized.py +++ b/test/gromov/test_quantized.py @@ -22,8 +22,10 @@ def test_quantized_gw(nx): C2 = rng.uniform(low=10.0, high=20.0, size=(n_samples, n_samples)) C2 = (C2 + C2.T) / 2.0 - p = ot.unif(n_samples) - q = ot.unif(n_samples) + p = np.arange(n_samples).astype(float) + p /= p.sum() + q = np.arange(n_samples).astype(float) + q /= q.sum() npart2 = 3 @@ -50,7 +52,7 @@ def test_quantized_gw(nx): npart1, npart2, p, - None, + q, C1, None, part_method=part_method, @@ -63,7 +65,7 @@ def test_quantized_gw(nx): C2b, npart1, npart2, - None, + pb, qb, None, C2b, @@ -80,6 +82,12 @@ def test_quantized_gw(nx): T_globalb, Ts_localb, Tb = resb Tb = nx.to_numpy(Tb) + print("T.sum(0):", T.sum(0)) + print("Tb.sum(0):", Tb.sum(0)) + print("T.sum(1):", T.sum(1)) + print("Tb.sum(1):", Tb.sum(1)) + print("p:", p) + print("q:", q) # check constraints np.testing.assert_allclose(T, Tb, atol=1e-06) np.testing.assert_allclose( @@ -114,7 +122,6 @@ def test_quantized_fgw(nx): p = ot.unif(n_samples) q = ot.unif(n_samples) - npart1 = 2 npart2 = 3 @@ -226,7 +233,17 @@ def test_quantized_fgw(nx): MRb = ot.dist(FR1b, FR2b) T_globalb, Ts_localb, _ = ot.gromov.quantized_fused_gromov_wasserstein_partitioned( - CR1b, CR2b, list_R1b, list_R2b, list_p1b, list_p2b, MRb, alpha, build_OT=False + CR1b, + CR2b, + list_R1b, + list_R2b, + list_p1b, + list_p2b, + None, + None, # part useless when build_OT=False + MRb, + alpha, + build_OT=False, ) T_globalb = nx.to_numpy(T_globalb) @@ -264,7 +281,17 @@ def test_quantized_fgw(nx): # for non admissible values of alpha with pytest.raises(ValueError): ot.gromov.quantized_fused_gromov_wasserstein_partitioned( - CR1b, CR2b, list_R1b, list_R2b, list_p1b, list_p2b, MRb, 0, build_OT=False + CR1b, + CR2b, + list_R1b, + list_R2b, + list_p1b, + list_p2b, + None, + None, # part useless when build_OT=False + MRb, + 0, + build_OT=False, ) # for non-consistent feature information provided @@ -364,8 +391,8 @@ def test_quantized_fgw_samples(nx): F1 = rng.uniform(low=0.0, high=10, size=(n_samples_1, 3)) F2 = rng.uniform(low=0.0, high=10, size=(n_samples_2, 3)) - p = ot.unif(n_samples_1) - q = ot.unif(n_samples_2) + p = np.random.dirichlet(np.ones(n_samples_1)) + q = np.random.dirichlet(np.ones(n_samples_2)) npart1 = 2 npart2 = 3 @@ -382,17 +409,18 @@ def test_quantized_fgw_samples(nx): for npart1 in [1, n_samples_1 + 1, 2]: log_tests = [True, False, True] count_mode = 0 - + print("--- npart:", npart1, "---") for method in methods: + print("method:", method, " nx:", nx.__name__) log_ = log_tests[count_mode] count_mode += 1 res = ot.gromov.quantized_fused_gromov_wasserstein_samples( - X1, X2, npart1, npart2, p, None, F1, F2, alpha, method, log_ + X1, X2, npart1, npart2, p, q, F1, F2, alpha, method, log_ ) resb = ot.gromov.quantized_fused_gromov_wasserstein_samples( - X1b, X2b, npart1, npart2, None, qb, F1b, F2b, alpha, method, log_ + X1b, X2b, npart1, npart2, pb, qb, F1b, F2b, alpha, method, log_ ) if log_: @@ -437,8 +465,25 @@ def test_quantized_fgw_samples(nx): MRb = ot.dist(FR1b, FR2b) + print("CR1b:", type(CR1b), CR1b.dtype) + print("CR2b", type(CR2b), CR2b.dtype) + print("list_R1b:", type(list_R1b), type(list_R1b[0]), list_R1b[0].dtype) + print("list_R2b:", type(list_R2b), type(list_R2b[0]), list_R2b[0].dtype) + print("list_p1b:", type(list_p1b), type(list_p1b[0]), list_p1b[0].dtype) + print("list_p2b:", type(list_p2b), type(list_p2b[0]), list_p2b[0].dtype) + print("MRb:", type(MRb), MRb.dtype) T_globalb, Ts_localb, _ = ot.gromov.quantized_fused_gromov_wasserstein_partitioned( - CR1b, CR2b, list_R1b, list_R2b, list_p1b, list_p2b, MRb, alpha, build_OT=False + CR1b, + CR2b, + list_R1b, + list_R2b, + list_p1b, + list_p2b, + None, + None, # part useless when build_OT=False + MRb, + alpha, + build_OT=False, ) T_globalb = nx.to_numpy(T_globalb)