@@ -35,7 +35,7 @@ def _load_edistance_kernels():
3535compute_group_distances_kernel = _load_edistance_kernels ()
3636
3737
38- def compute_d_other_gpu (
38+ def compute_pairwise_means_gpu (
3939 embedding : cp .ndarray , cat_offsets : cp .ndarray , cell_indices : cp .ndarray , k : int
4040) -> cp .ndarray :
4141 """
@@ -122,12 +122,177 @@ def compute_d_other_gpu(
122122 return pairwise_means
123123
124124
125+ def generate_bootstrap_indices (
126+ cat_offsets : cp .ndarray ,
127+ k : int ,
128+ n_bootstrap : int = 100 ,
129+ random_state : int = 0 ,
130+ ) -> list [list [cp .ndarray ]]:
131+ """
132+ Generate bootstrap indices for all groups and all bootstrap iterations.
133+ This matches the CPU implementation's random sampling logic for reproducibility.
134+
135+ Parameters
136+ ----------
137+ cat_offsets : cp.ndarray
138+ Group start/end indices
139+ k : int
140+ Number of groups
141+ n_bootstrap : int
142+ Number of bootstrap samples
143+ random_state : int
144+ Random seed for reproducibility
145+
146+ Returns
147+ -------
148+ bootstrap_indices : list[list[cp.ndarray]]
149+ For each bootstrap iteration, list of indices arrays for each group
150+ Shape: [n_bootstrap][k] where each element is cp.ndarray of group_size
151+ """
152+ import numpy as np
153+
154+ # Use same RNG logic as CPU code
155+ rng = np .random .default_rng (random_state )
156+
157+ # Convert to numpy for CPU-based random generation
158+ cat_offsets_np = cat_offsets .get ()
159+
160+ bootstrap_indices = []
161+
162+ for bootstrap_iter in range (n_bootstrap ):
163+ group_indices = []
164+
165+ for group_idx in range (k ):
166+ start_idx = cat_offsets_np [group_idx ]
167+ end_idx = cat_offsets_np [group_idx + 1 ]
168+ group_size = end_idx - start_idx
169+
170+ if group_size > 0 :
171+ # Generate bootstrap indices using same logic as CPU code
172+ # rng.choice(a=X.shape[0], size=X.shape[0], replace=True)
173+ bootstrap_group_indices = rng .choice (
174+ group_size , size = group_size , replace = True
175+ )
176+ # Convert to CuPy array
177+ group_indices .append (cp .array (bootstrap_group_indices , dtype = cp .int32 ))
178+ else :
179+ # Empty group
180+ group_indices .append (cp .array ([], dtype = cp .int32 ))
181+
182+ bootstrap_indices .append (group_indices )
183+
184+ return bootstrap_indices
185+
186+
187+ def _bootstrap_sample_cells_from_indices (
188+ * ,
189+ cat_offsets : cp .ndarray ,
190+ cell_indices : cp .ndarray ,
191+ k : int ,
192+ bootstrap_group_indices : list [cp .ndarray ],
193+ ) -> tuple [cp .ndarray , cp .ndarray ]:
194+ """
195+ Bootstrap sample cells using pre-generated indices.
196+
197+ Parameters
198+ ----------
199+ cat_offsets : cp.ndarray
200+ Group start/end indices
201+ cell_indices : cp.ndarray
202+ Sorted cell indices by group
203+ k : int
204+ Number of groups
205+ bootstrap_group_indices : list[cp.ndarray]
206+ Pre-generated bootstrap indices for each group
207+
208+ Returns
209+ -------
210+ new_cat_offsets, new_cell_indices : tuple[cp.ndarray, cp.ndarray]
211+ New category structure with bootstrapped cells
212+ """
213+ new_cell_indices = []
214+ new_cat_offsets = cp .zeros (k + 1 , dtype = cp .int32 )
215+
216+ for group_idx in range (k ):
217+ start_idx = cat_offsets [group_idx ]
218+ end_idx = cat_offsets [group_idx + 1 ]
219+ group_size = end_idx - start_idx
220+
221+ if group_size > 0 :
222+ # Get original cell indices for this group
223+ group_cells = cell_indices [start_idx :end_idx ]
224+
225+ # Use pre-generated bootstrap indices
226+ bootstrap_indices = bootstrap_group_indices [group_idx ]
227+ bootstrap_cells = group_cells [bootstrap_indices ]
228+
229+ new_cell_indices .extend (bootstrap_cells .get ().tolist ())
230+
231+ new_cat_offsets [group_idx + 1 ] = len (new_cell_indices )
232+
233+ return new_cat_offsets , cp .array (new_cell_indices , dtype = cp .int32 )
234+
235+
236+ def compute_pairwise_means_gpu_bootstrap (
237+ embedding : cp .ndarray ,
238+ * ,
239+ cat_offsets : cp .ndarray ,
240+ cell_indices : cp .ndarray ,
241+ k : int ,
242+ n_bootstrap : int = 100 ,
243+ random_state : int = 0 ,
244+ ) -> tuple [cp .ndarray , cp .ndarray ]:
245+ """
246+ Compute bootstrap statistics for between-group distances.
247+ Uses CPU-compatible random generation for reproducibility.
248+
249+ Returns:
250+ means: [k, k] matrix of bootstrap means
251+ variances: [k, k] matrix of bootstrap variances
252+ """
253+ # Generate all bootstrap indices upfront using CPU-compatible logic
254+ bootstrap_indices = generate_bootstrap_indices (
255+ cat_offsets , k , n_bootstrap , random_state
256+ )
257+
258+ bootstrap_results = []
259+
260+ for bootstrap_iter in range (n_bootstrap ):
261+ # Use pre-generated indices for this bootstrap iteration
262+ boot_cat_offsets , boot_cell_indices = _bootstrap_sample_cells_from_indices (
263+ cat_offsets = cat_offsets ,
264+ cell_indices = cell_indices ,
265+ k = k ,
266+ bootstrap_group_indices = bootstrap_indices [bootstrap_iter ],
267+ )
268+
269+ # Compute distances with bootstrapped samples
270+ pairwise_means = compute_pairwise_means_gpu (
271+ embedding = embedding ,
272+ cat_offsets = boot_cat_offsets ,
273+ cell_indices = boot_cell_indices ,
274+ k = k ,
275+ )
276+ bootstrap_results .append (pairwise_means .get ())
277+
278+ # Compute statistics across bootstrap samples
279+ bootstrap_stack = cp .array (bootstrap_results ) # [n_bootstrap, k, k]
280+ means = cp .mean (bootstrap_stack , axis = 0 )
281+ variances = cp .var (bootstrap_stack , axis = 0 )
282+
283+ return means , variances
284+
285+
125286def pairwise_edistance_gpu (
126287 adata : AnnData ,
127288 groupby : str ,
128289 * ,
129290 obsm_key : str = "X_pca" ,
130291 groups : list [str ] | None = None ,
292+ inplace : bool = False ,
293+ bootstrap : bool = False ,
294+ n_bootstrap : int = 100 ,
295+ random_state : int = 0 ,
131296) -> pd .DataFrame :
132297 """
133298 GPU-accelerated pairwise edistance computation with decomposed components.
@@ -169,9 +334,117 @@ def pairwise_edistance_gpu(
169334 k = len (group_map )
170335 cat_offsets , cell_indices = _create_category_index_mapping (group_labels , k )
171336
337+ groups_list = (
338+ list (original_groups .cat .categories .values ) if groups is None else groups
339+ )
340+ if not bootstrap :
341+ df = compute_pairwise_means_gpu_edistance (
342+ embedding = embedding ,
343+ cat_offsets = cat_offsets ,
344+ cell_indices = cell_indices ,
345+ k = k ,
346+ groups_list = groups_list ,
347+ groupby = groupby ,
348+ )
349+ if inplace :
350+ adata .uns [f"{ groupby } _pairwise_edistance" ] = {
351+ "distances" : df ,
352+ }
353+ return df
354+
355+ else :
356+ df , df_var = compute_pairwise_means_gpu_edistance_bootstrap (
357+ embedding = embedding ,
358+ cat_offsets = cat_offsets ,
359+ cell_indices = cell_indices ,
360+ k = k ,
361+ groups_list = groups_list ,
362+ groupby = groupby ,
363+ n_bootstrap = n_bootstrap ,
364+ random_state = random_state ,
365+ )
366+
367+ if inplace :
368+ adata .uns [f"{ groupby } _pairwise_edistance" ] = {
369+ "distances" : df ,
370+ "distances_var" : df_var ,
371+ }
372+ return df , df_var
373+
374+
375+ def compute_pairwise_means_gpu_edistance_bootstrap (
376+ embedding : cp .ndarray ,
377+ * ,
378+ cat_offsets : cp .ndarray ,
379+ cell_indices : cp .ndarray ,
380+ k : int ,
381+ groups_list : list [str ],
382+ groupby : str ,
383+ n_bootstrap : int = 100 ,
384+ random_state : int = 0 ,
385+ ) -> tuple [pd .DataFrame , pd .DataFrame ]:
386+ # Bootstrap computation
387+ pairwise_means_boot , pairwise_vars_boot = compute_pairwise_means_gpu_bootstrap (
388+ embedding = embedding ,
389+ cat_offsets = cat_offsets ,
390+ cell_indices = cell_indices ,
391+ k = k ,
392+ n_bootstrap = n_bootstrap ,
393+ random_state = random_state ,
394+ )
395+
396+ # 4. Compute final edistance for means and variances
397+ edistance_means = cp .zeros ((k , k ), dtype = np .float32 )
398+ edistance_vars = cp .zeros ((k , k ), dtype = np .float32 )
399+
400+ for a in range (k ):
401+ for b in range (a + 1 , k ):
402+ # Bootstrap mean edistance
403+ edistance_means [a , b ] = (
404+ 2 * pairwise_means_boot [a , b ]
405+ - pairwise_means_boot [a , a ]
406+ - pairwise_means_boot [b , b ]
407+ )
408+ edistance_means [b , a ] = edistance_means [a , b ]
409+
410+ # Bootstrap variance edistance (using delta method approximation)
411+ # Var(2*X - Y - Z) = 4*Var(X) + Var(Y) + Var(Z) (assuming independence)
412+ edistance_vars [a , b ] = (
413+ 4 * pairwise_vars_boot [a , b ]
414+ + pairwise_vars_boot [a , a ]
415+ + pairwise_vars_boot [b , b ]
416+ )
417+ edistance_vars [b , a ] = edistance_vars [a , b ]
418+
419+ # 5. Create output DataFrames
420+
421+ df_mean = pd .DataFrame (
422+ edistance_means .get (), index = groups_list , columns = groups_list
423+ )
424+ df_mean .index .name = groupby
425+ df_mean .columns .name = groupby
426+ df_mean .name = "pairwise edistance"
427+
428+ df_var = pd .DataFrame (edistance_vars .get (), index = groups_list , columns = groups_list )
429+ df_var .index .name = groupby
430+ df_var .columns .name = groupby
431+ df_var .name = "pairwise edistance variance"
432+
433+ return df_mean , df_var
434+
435+
436+ def compute_pairwise_means_gpu_edistance (
437+ embedding : cp .ndarray ,
438+ * ,
439+ cat_offsets : cp .ndarray ,
440+ cell_indices : cp .ndarray ,
441+ k : int ,
442+ groups_list : list [str ],
443+ groupby : str ,
444+ ) -> pd .DataFrame :
172445 # 3. Compute decomposed components
173446 # d_itself = compute_d_itself_gpu(embedding, cat_offsets, cell_indices, k)
174- pairwise_means = compute_d_other_gpu (embedding , cat_offsets , cell_indices , k )
447+ pairwise_means = compute_pairwise_means_gpu (embedding , cat_offsets , cell_indices , k )
175448
176449 # 4. Compute final edistance: df[a,b] = 2*d_other[a,b] - d_itself[a] - d_itself[b]
177450 edistance_matrix = cp .zeros ((k , k ), dtype = np .float32 )
@@ -183,17 +456,10 @@ def pairwise_edistance_gpu(
183456 edistance_matrix [b , a ] = edistance_matrix [a , b ]
184457
185458 # 5. Create output DataFrame
186- groups_list = (
187- list (original_groups .cat .categories .values ) if groups is None else groups
188- )
459+
189460 df = pd .DataFrame (edistance_matrix .get (), index = groups_list , columns = groups_list )
190461 df .index .name = groupby
191462 df .columns .name = groupby
192463 df .name = "pairwise edistance"
193464
194- # Store in adata
195- adata .uns [f"{ groupby } _pairwise_edistance" ] = {
196- "distances" : df ,
197- }
198-
199465 return df
0 commit comments