Skip to content

Commit 7faef57

Browse files
committed
bootstrapping
1 parent 2150b0a commit 7faef57

5 files changed

Lines changed: 332 additions & 35 deletions

File tree

src/rapids_singlecell/pertpy_gpu/_distances.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ def _bootstrap_mode_precomputed(
523523
distance = self.metric_fct.from_precomputed(
524524
bootstrap_sub_pwd, bootstrap_sub_idx, **kwargs
525525
)
526-
distances.append(distance.get())
526+
distances.append(distance)
527527

528528
mean = np.mean(distances)
529529
variance = np.var(distances)

src/rapids_singlecell/pertpy_gpu/_distances_standalone.py

Lines changed: 276 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def _load_edistance_kernels():
3535
compute_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+
125286
def 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

tmp_scripts/compute_edistance.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import time
5+
from pathlib import Path
56

67
import anndata as ad
78
import cupy as cp
@@ -24,9 +25,10 @@
2425

2526
# homedir/data/adamson_2016_upr_epistasis
2627
save_dir = os.path.join(
27-
os.path.expanduser("~"), "data", "adamson_2016_upr_epistasis_pca.h5ad"
28+
os.path.expanduser("~"),
29+
"data",
2830
)
29-
adata = ad.read_h5ad(save_dir)
31+
adata = ad.read_h5ad(Path(save_dir) / "adamson_2016_upr_epistasis_pca.h5ad")
3032
rsc.get.anndata_to_GPU(adata, convert_all=True)
3133
dist = Distance(obsm_key="X_pca", metric="edistance")
3234
start_time = time.time()

tmp_scripts/compute_edistance_cpu.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,37 @@
22

33
import os
44
import time
5+
from argparse import ArgumentParser
6+
from pathlib import Path
57

68
import anndata as ad
79
from pertpy.tools import Distance
810

911
if __name__ == "__main__":
12+
parser = ArgumentParser()
13+
parser.add_argument("--bootstrap", action="store_true")
14+
args = parser.parse_args()
1015
obs_key = "perturbation"
16+
bootstrap = args.bootstrap
1117

1218
# homedir/data/adamson_2016_upr_epistasis
1319
save_dir = os.path.join(
14-
os.path.expanduser("~"), "data", "adamson_2016_upr_epistasis_pca.h5ad"
20+
os.path.expanduser("~"),
21+
"data",
1522
)
16-
adata = ad.read_h5ad(save_dir)
23+
adata = ad.read_h5ad(Path(save_dir) / "adamson_2016_upr_epistasis_pca.h5ad")
1724
dist = Distance(obsm_key="X_pca", metric="edistance")
1825
start_time = time.time()
19-
df = dist.pairwise(adata, groupby=obs_key)
26+
if bootstrap:
27+
df, df_var = dist.pairwise(
28+
adata, groupby=obs_key, bootstrap=True, n_bootstrap=100
29+
)
30+
else:
31+
df = dist.pairwise(adata, groupby=obs_key)
2032
end_time = time.time()
2133
print(f"Time taken: {end_time - start_time} seconds")
34+
if bootstrap:
35+
df_var.to_csv(Path(save_dir) / "df_cpu_bootstrap_var.csv")
36+
df.to_csv(Path(save_dir) / "df_cpu_bootstrap.csv")
37+
else:
38+
df.to_csv(Path(save_dir) / "df_cpu.csv")

0 commit comments

Comments
 (0)