brisc 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. brisc-0.2.2/.git_archival.txt +2 -0
  2. brisc-0.2.2/.gitattributes +1 -0
  3. brisc-0.2.2/.gitignore +21 -0
  4. {brisc-0.2.1 → brisc-0.2.2}/MANIFEST.in +2 -0
  5. {brisc-0.2.1/brisc.egg-info → brisc-0.2.2}/PKG-INFO +1 -1
  6. {brisc-0.2.1 → brisc-0.2.2}/brisc/hvg.pyx +101 -125
  7. {brisc-0.2.1 → brisc-0.2.2}/brisc/knn.pyx +93 -80
  8. {brisc-0.2.1 → brisc-0.2.2}/brisc/single_cell.py +14 -1
  9. {brisc-0.2.1 → brisc-0.2.2/brisc.egg-info}/PKG-INFO +1 -1
  10. {brisc-0.2.1 → brisc-0.2.2}/brisc.egg-info/SOURCES.txt +7 -0
  11. brisc-0.2.2/brisc.egg-info/scm_file_list.json +110 -0
  12. brisc-0.2.2/brisc.egg-info/scm_version.json +8 -0
  13. {brisc-0.2.1 → brisc-0.2.2}/pyproject.toml +4 -2
  14. brisc-0.2.2/tests/conftest.py +30 -0
  15. brisc-0.2.2/tests/make_test_data.py +105 -0
  16. {brisc-0.2.1 → brisc-0.2.2}/LICENSE +0 -0
  17. {brisc-0.2.1 → brisc-0.2.2}/README.md +0 -0
  18. {brisc-0.2.1 → brisc-0.2.2}/brisc/__init__.py +0 -0
  19. {brisc-0.2.1 → brisc-0.2.2}/brisc/colorspacious.py +0 -0
  20. {brisc-0.2.1 → brisc-0.2.2}/brisc/concatenate.py +0 -0
  21. {brisc-0.2.1 → brisc-0.2.2}/brisc/cysparse.pyx +0 -0
  22. {brisc-0.2.1 → brisc-0.2.2}/brisc/cyutils.pxd +0 -0
  23. {brisc-0.2.1 → brisc-0.2.2}/brisc/cyutils.pyx +0 -0
  24. {brisc-0.2.1 → brisc-0.2.2}/brisc/de.py +0 -0
  25. {brisc-0.2.1 → brisc-0.2.2}/brisc/doublets.pyx +0 -0
  26. {brisc-0.2.1 → brisc-0.2.2}/brisc/embed.pyx +0 -0
  27. {brisc-0.2.1 → brisc-0.2.2}/brisc/harmonize.pyx +0 -0
  28. {brisc-0.2.1 → brisc-0.2.2}/brisc/hdf5.pyx +0 -0
  29. {brisc-0.2.1 → brisc-0.2.2}/brisc/kmeans.pyx +0 -0
  30. {brisc-0.2.1 → brisc-0.2.2}/brisc/labels.pyx +0 -0
  31. {brisc-0.2.1 → brisc-0.2.2}/brisc/leiden.pyx +0 -0
  32. {brisc-0.2.1 → brisc-0.2.2}/brisc/loess.pyx +0 -0
  33. {brisc-0.2.1 → brisc-0.2.2}/brisc/norm_factors.pyx +0 -0
  34. {brisc-0.2.1 → brisc-0.2.2}/brisc/normalize.pyx +0 -0
  35. {brisc-0.2.1 → brisc-0.2.2}/brisc/pca.pyx +0 -0
  36. {brisc-0.2.1 → brisc-0.2.2}/brisc/pseudobulk.py +0 -0
  37. {brisc-0.2.1 → brisc-0.2.2}/brisc/pseudobulk_and_markers.pyx +0 -0
  38. {brisc-0.2.1 → brisc-0.2.2}/brisc/qc.pyx +0 -0
  39. {brisc-0.2.1 → brisc-0.2.2}/brisc/snn.pyx +0 -0
  40. {brisc-0.2.1 → brisc-0.2.2}/brisc/sparse.py +0 -0
  41. {brisc-0.2.1 → brisc-0.2.2}/brisc/threadpoolctl.py +0 -0
  42. {brisc-0.2.1 → brisc-0.2.2}/brisc/type_aliases.py +0 -0
  43. {brisc-0.2.1 → brisc-0.2.2}/brisc/utils.py +0 -0
  44. {brisc-0.2.1 → brisc-0.2.2}/brisc/validated_dict.py +0 -0
  45. {brisc-0.2.1 → brisc-0.2.2}/brisc.egg-info/dependency_links.txt +0 -0
  46. {brisc-0.2.1 → brisc-0.2.2}/brisc.egg-info/requires.txt +0 -0
  47. {brisc-0.2.1 → brisc-0.2.2}/brisc.egg-info/top_level.txt +0 -0
  48. {brisc-0.2.1 → brisc-0.2.2}/setup.cfg +0 -0
  49. {brisc-0.2.1 → brisc-0.2.2}/setup.py +0 -0
  50. {brisc-0.2.1 → brisc-0.2.2}/tests/test_cluster.py +0 -0
  51. {brisc-0.2.1 → brisc-0.2.2}/tests/test_de.py +0 -0
  52. {brisc-0.2.1 → brisc-0.2.2}/tests/test_embedding.py +0 -0
  53. {brisc-0.2.1 → brisc-0.2.2}/tests/test_hvg.py +0 -0
  54. {brisc-0.2.1 → brisc-0.2.2}/tests/test_integration.py +0 -0
  55. {brisc-0.2.1 → brisc-0.2.2}/tests/test_markers.py +0 -0
  56. {brisc-0.2.1 → brisc-0.2.2}/tests/test_neighbors.py +0 -0
  57. {brisc-0.2.1 → brisc-0.2.2}/tests/test_normalize.py +0 -0
  58. {brisc-0.2.1 → brisc-0.2.2}/tests/test_pca.py +0 -0
  59. {brisc-0.2.1 → brisc-0.2.2}/tests/test_pseudobulk.py +0 -0
  60. {brisc-0.2.1 → brisc-0.2.2}/tests/test_qc.py +0 -0
@@ -0,0 +1,2 @@
1
+ node: $Format:%H$
2
+ describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$
@@ -0,0 +1 @@
1
+ .git_archival.txt export-subst
brisc-0.2.2/.gitignore ADDED
@@ -0,0 +1,21 @@
1
+ # Python and build artifacts
2
+ brisc/__pycache__/
3
+ build/
4
+ dist/
5
+ *.egg-info/
6
+ wheelhouse/
7
+ conda-bld/
8
+ brisc/_v2/
9
+ brisc/_v3/
10
+ brisc/_v4/
11
+ brisc/_sve/
12
+
13
+ # Cython artifacts (generated C/C++ files and compiled libraries)
14
+ *.c
15
+ *.cpp
16
+ *.so
17
+ *.pyd
18
+
19
+ # Miscellaneous
20
+ *.txt
21
+ !.git_archival.txt
@@ -1,4 +1,6 @@
1
1
  recursive-include brisc *.pyx *.pxd
2
+ prune docs
3
+ prune .github
2
4
  prune brisc/x86_64_v2
3
5
  prune brisc/x86_64_v3
4
6
  prune brisc/x86_64_v4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brisc
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Fast single-cell toolkit
5
5
  Author-email: Michael Wainberg <m.wainberg@utoronto.ca>
6
6
  License-Expression: MIT
@@ -12,20 +12,24 @@ def gene_mean_and_variance_csr(const numeric[::1] data,
12
12
  const long long[::1] cell_indices,
13
13
  const unsigned long long num_cells,
14
14
  const unsigned num_dataset_genes,
15
+ unsigned long long[:, :, ::1] thread_sums,
16
+ unsigned long long[::1] counts_sum,
17
+ unsigned long long[::1] squared_counts_sum,
15
18
  float[::1] mean,
16
19
  float[::1] var,
17
20
  unsigned[::1] nonzero_count,
18
21
  unsigned num_threads):
22
+ # `thread_sums[thread_index, gene]` holds the sum, squared sum and number
23
+ # of non-zero counts of each gene across the elements processed by each
24
+ # thread, interleaved so that each element updates a single cache line.
25
+ # It is allocated once by the caller and reused across batches.
19
26
 
20
27
  cdef unsigned gene, cell, thread_index
21
28
  cdef unsigned long long num_elements, i, j, value, chunk_size, start, \
22
29
  end, total_sum, total_sum_of_squares, total_nonzero_count
23
30
  cdef float inv_num_cells = <float> 1 / num_cells
24
31
  cdef double inv_num_pairs_of_cells = 1.0 / (num_cells * (num_cells - 1))
25
- cdef vector[unsigned long long] sum_buffer, sum_of_squares_buffer
26
- cdef vector[vector[unsigned long long]] thread_sum, thread_sum_of_squares
27
- cdef vector[vector[unsigned]] thread_nonzero_counts
28
- cdef unsigned long long[::1] sum, sum_of_squares
32
+ cdef unsigned long long[:, ::1] sums
29
33
 
30
34
  if cell_indices.shape[0] == 0:
31
35
  num_elements = indices.shape[0]
@@ -34,21 +38,20 @@ def gene_mean_and_variance_csr(const numeric[::1] data,
34
38
  num_threads = min(num_threads, num_cells)
35
39
 
36
40
  if num_threads <= 1:
37
- sum_buffer.resize(num_dataset_genes)
38
- sum = <unsigned long long[:num_dataset_genes]> sum_buffer.data()
39
- sum_of_squares_buffer.resize(num_dataset_genes)
40
- sum_of_squares = <unsigned long long[:num_dataset_genes]> \
41
- sum_of_squares_buffer.data()
42
- nonzero_count[:] = 0
41
+ sums = thread_sums[0]
42
+ for gene in range(num_dataset_genes):
43
+ sums[gene, 0] = 0
44
+ sums[gene, 1] = 0
45
+ sums[gene, 2] = 0
43
46
  if cell_indices.shape[0] == 0:
44
47
  # Iterate over all elements of the count matrix, ignoring which
45
48
  # cell they're from
46
49
  for i in range(num_elements):
47
50
  gene = indices[i]
48
51
  value = <unsigned long long> data[i]
49
- sum[gene] += value
50
- sum_of_squares[gene] += value * value
51
- nonzero_count[gene] += 1
52
+ sums[gene, 0] += value
53
+ sums[gene, 1] += value * value
54
+ sums[gene, 2] += 1
52
55
  else:
53
56
  # Only iterate over the elements from cells in `cell_indices` (i.e.
54
57
  # cells in this batch, and/or passing QC)
@@ -58,20 +61,20 @@ def gene_mean_and_variance_csr(const numeric[::1] data,
58
61
  <unsigned long long> indptr[cell + 1]):
59
62
  gene = indices[i]
60
63
  value = <unsigned long long> data[i]
61
- sum[gene] += value
62
- sum_of_squares[gene] += value * value
63
- nonzero_count[gene] += 1
64
+ sums[gene, 0] += value
65
+ sums[gene, 1] += value * value
66
+ sums[gene, 2] += 1
64
67
 
65
68
  # Calculate means and variances from the sums and squared sums
66
69
  for gene in range(num_dataset_genes):
67
- mean[gene] = sum[gene] * inv_num_cells
70
+ counts_sum[gene] = sums[gene, 0]
71
+ squared_counts_sum[gene] = sums[gene, 1]
72
+ nonzero_count[gene] = <unsigned> sums[gene, 2]
73
+ mean[gene] = counts_sum[gene] * inv_num_cells
68
74
  var[gene] = <float> (inv_num_pairs_of_cells * (
69
- <double> num_cells * <double> sum_of_squares[gene] -
70
- <double> sum[gene] * <double> sum[gene]))
75
+ <double> num_cells * <double> squared_counts_sum[gene] -
76
+ <double> counts_sum[gene] * <double> counts_sum[gene]))
71
77
  else:
72
- thread_sum.resize(num_threads)
73
- thread_sum_of_squares.resize(num_threads)
74
- thread_nonzero_counts.resize(num_threads)
75
78
  with nogil:
76
79
  if cell_indices.shape[0] == 0:
77
80
  # Partition the work by elements, not cells, for better
@@ -80,31 +83,28 @@ def gene_mean_and_variance_csr(const numeric[::1] data,
80
83
  chunk_size = (num_elements + num_threads - 1) / num_threads
81
84
  with parallel(num_threads=num_threads):
82
85
  thread_index = threadid()
83
- thread_sum[thread_index].resize(num_dataset_genes)
84
- thread_sum_of_squares[thread_index].resize(
85
- num_dataset_genes)
86
- thread_nonzero_counts[thread_index].resize(
87
- num_dataset_genes)
86
+ for gene in range(num_dataset_genes):
87
+ thread_sums[thread_index, gene, 0] = 0
88
+ thread_sums[thread_index, gene, 1] = 0
89
+ thread_sums[thread_index, gene, 2] = 0
88
90
 
89
91
  start = thread_index * chunk_size
90
92
  end = min(start + chunk_size, num_elements)
91
93
  for i in range(start, end):
92
94
  gene = indices[i]
93
95
  value = <unsigned long long> data[i]
94
- thread_sum[thread_index][gene] += value
95
- thread_sum_of_squares[thread_index][gene] += \
96
- value * value
97
- thread_nonzero_counts[thread_index][gene] += 1
96
+ thread_sums[thread_index, gene, 0] += value
97
+ thread_sums[thread_index, gene, 1] += value * value
98
+ thread_sums[thread_index, gene, 2] += 1
98
99
  else:
99
100
  # Partition the work by cells
100
101
  chunk_size = (num_cells + num_threads - 1) / num_threads
101
102
  with parallel(num_threads=num_threads):
102
103
  thread_index = threadid()
103
- thread_sum[thread_index].resize(num_dataset_genes)
104
- thread_sum_of_squares[thread_index].resize(
105
- num_dataset_genes)
106
- thread_nonzero_counts[thread_index].resize(
107
- num_dataset_genes)
104
+ for gene in range(num_dataset_genes):
105
+ thread_sums[thread_index, gene, 0] = 0
106
+ thread_sums[thread_index, gene, 1] = 0
107
+ thread_sums[thread_index, gene, 2] = 0
108
108
 
109
109
  start = thread_index * chunk_size
110
110
  end = min(start + chunk_size, num_cells)
@@ -114,10 +114,9 @@ def gene_mean_and_variance_csr(const numeric[::1] data,
114
114
  <unsigned long long> indptr[cell + 1]):
115
115
  gene = indices[i]
116
116
  value = <unsigned long long> data[i]
117
- thread_sum[thread_index][gene] += value
118
- thread_sum_of_squares[thread_index][gene] += \
119
- value * value
120
- thread_nonzero_counts[thread_index][gene] += 1
117
+ thread_sums[thread_index, gene, 0] += value
118
+ thread_sums[thread_index, gene, 1] += value * value
119
+ thread_sums[thread_index, gene, 2] += 1
121
120
 
122
121
  # Calculate means and variances by aggregating the sums and squared
123
122
  # sums across threads
@@ -126,17 +125,18 @@ def gene_mean_and_variance_csr(const numeric[::1] data,
126
125
  total_sum_of_squares = 0
127
126
  total_nonzero_count = 0
128
127
  for thread_index in range(num_threads):
129
- total_sum = total_sum + \
130
- thread_sum[thread_index][gene]
128
+ total_sum = total_sum + thread_sums[thread_index, gene, 0]
131
129
  total_sum_of_squares = total_sum_of_squares + \
132
- thread_sum_of_squares[thread_index][gene]
130
+ thread_sums[thread_index, gene, 1]
133
131
  total_nonzero_count = total_nonzero_count + \
134
- thread_nonzero_counts[thread_index][gene]
132
+ thread_sums[thread_index, gene, 2]
133
+ counts_sum[gene] = total_sum
134
+ squared_counts_sum[gene] = total_sum_of_squares
135
+ nonzero_count[gene] = <unsigned> total_nonzero_count
135
136
  mean[gene] = total_sum * inv_num_cells
136
137
  var[gene] = <float> (inv_num_pairs_of_cells * (
137
138
  <double> num_cells * <double> total_sum_of_squares -
138
139
  <double> total_sum * <double> total_sum))
139
- nonzero_count[gene] = total_nonzero_count
140
140
 
141
141
 
142
142
  def gene_mean_and_variance_csc(const numeric[::1] data,
@@ -258,38 +258,33 @@ def clipped_sum_csr(const numeric[::1] data,
258
258
  const unsigned num_dataset_genes,
259
259
  const long long[::1] cell_indices,
260
260
  const float[::1] clip_val,
261
+ unsigned long long[:, :, ::1] thread_sums,
262
+ const unsigned long long[::1] counts_sum,
263
+ const unsigned long long[::1] squared_counts_sum,
261
264
  float[::1] batch_counts_sum,
262
265
  float[::1] squared_batch_counts_sum,
263
266
  unsigned num_threads):
264
- cdef unsigned cell, gene, thread_index, chunk_size, start, end
265
- cdef unsigned long long i, j, value, total_batch_counts_sum, \
266
- total_squared_batch_counts_sum, total_num_out_of_range
267
- cdef vector[unsigned long long] batch_counts_sum_int_buffer, \
268
- squared_batch_counts_sum_int_buffer, num_out_of_range_buffer
269
- cdef vector[vector[unsigned long long]] thread_batch_counts_sum_int, \
270
- thread_squared_batch_counts_sum_int, thread_num_out_of_range
267
+ cdef unsigned cell, gene, thread_index
268
+ cdef unsigned long long i, j, value, chunk_size, start, end, \
269
+ out_of_range_sum, squared_out_of_range_sum, num_out_of_range
271
270
  cdef pair[unsigned, unsigned] row_range
272
- cdef unsigned long long[::1] batch_counts_sum_int, \
273
- squared_batch_counts_sum_int, num_out_of_range
271
+ cdef unsigned long long[:, ::1] sums
274
272
 
275
273
  # Key insight: the things we're summing are integers except when
276
274
  # `value > clip_val[gene]`, in which case we are adding a (floating-point)
277
275
  # constant, so keep track of this case separately and add it at the end, to
278
- # minimize floating-point error. Also, use an integer version of `clip_val`
279
- # for these comparisons, to avoid any potential inconsistency in
280
- # floating-point roundoff between code paths.
276
+ # minimize floating-point error. This case is rare, so only update
277
+ # `thread_sums` for these elements, and subtract them from the unclipped
278
+ # sums calculated by `gene_mean_and_variance_csr()`. Also, use an integer
279
+ # version of `clip_val` for these comparisons, to avoid any potential
280
+ # inconsistency in floating-point roundoff between code paths.
281
281
  num_threads = min(num_threads, num_cells)
282
282
  if num_threads <= 1:
283
- batch_counts_sum_int_buffer.resize(num_dataset_genes)
284
- batch_counts_sum_int = <unsigned long long[:num_dataset_genes]> \
285
- batch_counts_sum_int_buffer.data()
286
- squared_batch_counts_sum_int_buffer.resize(num_dataset_genes)
287
- squared_batch_counts_sum_int = \
288
- <unsigned long long[:num_dataset_genes]> \
289
- squared_batch_counts_sum_int_buffer.data()
290
- num_out_of_range_buffer.resize(num_dataset_genes)
291
- num_out_of_range = <unsigned long long[:num_dataset_genes]> \
292
- num_out_of_range_buffer.data()
283
+ sums = thread_sums[0]
284
+ for gene in range(num_dataset_genes):
285
+ sums[gene, 0] = 0
286
+ sums[gene, 1] = 0
287
+ sums[gene, 2] = 0
293
288
  if cell_indices.shape[0] == 0:
294
289
  for cell in range(num_cells):
295
290
  for i in range(<unsigned long long> indptr[cell],
@@ -297,10 +292,9 @@ def clipped_sum_csr(const numeric[::1] data,
297
292
  gene = indices[i]
298
293
  value = <unsigned long long> data[i]
299
294
  if value > clip_val[gene]:
300
- num_out_of_range[gene] += 1
301
- else:
302
- batch_counts_sum_int[gene] += value
303
- squared_batch_counts_sum_int[gene] += value * value
295
+ sums[gene, 0] += value
296
+ sums[gene, 1] += value * value
297
+ sums[gene, 2] += 1
304
298
  else:
305
299
  for j in range(<unsigned long long> cell_indices.shape[0]):
306
300
  cell = cell_indices[j]
@@ -309,31 +303,25 @@ def clipped_sum_csr(const numeric[::1] data,
309
303
  gene = indices[i]
310
304
  value = <unsigned long long> data[i]
311
305
  if value > clip_val[gene]:
312
- num_out_of_range[gene] += 1
313
- else:
314
- batch_counts_sum_int[gene] += value
315
- squared_batch_counts_sum_int[gene] += value * value
306
+ sums[gene, 0] += value
307
+ sums[gene, 1] += value * value
308
+ sums[gene, 2] += 1
316
309
  for gene in range(num_dataset_genes):
317
310
  batch_counts_sum[gene] = \
318
- batch_counts_sum_int[gene] + \
319
- num_out_of_range[gene] * clip_val[gene]
311
+ (counts_sum[gene] - sums[gene, 0]) + \
312
+ sums[gene, 2] * clip_val[gene]
320
313
  squared_batch_counts_sum[gene] = \
321
- squared_batch_counts_sum_int[gene] + \
322
- num_out_of_range[gene] * clip_val[gene] * clip_val[gene]
314
+ (squared_counts_sum[gene] - sums[gene, 1]) + \
315
+ sums[gene, 2] * clip_val[gene] * clip_val[gene]
323
316
  else:
324
- thread_batch_counts_sum_int.resize(num_threads)
325
- thread_squared_batch_counts_sum_int.resize(num_threads)
326
- thread_num_out_of_range.resize(num_threads)
327
317
  with nogil:
328
318
  if cell_indices.shape[0] == 0:
329
319
  with parallel(num_threads=num_threads):
330
320
  thread_index = threadid()
331
- thread_batch_counts_sum_int[thread_index].resize(
332
- num_dataset_genes)
333
- thread_squared_batch_counts_sum_int[thread_index].resize(
334
- num_dataset_genes)
335
- thread_num_out_of_range[thread_index].resize(
336
- num_dataset_genes)
321
+ for gene in range(num_dataset_genes):
322
+ thread_sums[thread_index, gene, 0] = 0
323
+ thread_sums[thread_index, gene, 1] = 0
324
+ thread_sums[thread_index, gene, 2] = 0
337
325
 
338
326
  row_range = get_thread_offset(indptr, thread_index,
339
327
  num_threads)
@@ -343,23 +331,18 @@ def clipped_sum_csr(const numeric[::1] data,
343
331
  gene = indices[i]
344
332
  value = <unsigned long long> data[i]
345
333
  if value > clip_val[gene]:
346
- thread_num_out_of_range[
347
- thread_index][gene] += 1
348
- else:
349
- thread_batch_counts_sum_int[
350
- thread_index][gene] += value
351
- thread_squared_batch_counts_sum_int[
352
- thread_index][gene] += value * value
334
+ thread_sums[thread_index, gene, 0] += value
335
+ thread_sums[thread_index, gene, 1] += \
336
+ value * value
337
+ thread_sums[thread_index, gene, 2] += 1
353
338
  else:
354
339
  chunk_size = (num_cells + num_threads - 1) / num_threads
355
340
  with parallel(num_threads=num_threads):
356
341
  thread_index = threadid()
357
- thread_batch_counts_sum_int[thread_index].resize(
358
- num_dataset_genes)
359
- thread_squared_batch_counts_sum_int[thread_index].resize(
360
- num_dataset_genes)
361
- thread_num_out_of_range[thread_index].resize(
362
- num_dataset_genes)
342
+ for gene in range(num_dataset_genes):
343
+ thread_sums[thread_index, gene, 0] = 0
344
+ thread_sums[thread_index, gene, 1] = 0
345
+ thread_sums[thread_index, gene, 2] = 0
363
346
 
364
347
  start = thread_index * chunk_size
365
348
  end = min(start + chunk_size, num_cells)
@@ -370,36 +353,29 @@ def clipped_sum_csr(const numeric[::1] data,
370
353
  gene = indices[i]
371
354
  value = <unsigned long long> data[i]
372
355
  if value > clip_val[gene]:
373
- thread_num_out_of_range[
374
- thread_index][gene] += 1
375
- else:
376
- thread_batch_counts_sum_int[
377
- thread_index][gene] += value
378
- thread_squared_batch_counts_sum_int[
379
- thread_index][gene] += value * value
356
+ thread_sums[thread_index, gene, 0] += value
357
+ thread_sums[thread_index, gene, 1] += \
358
+ value * value
359
+ thread_sums[thread_index, gene, 2] += 1
380
360
 
381
361
  for gene in prange(num_dataset_genes,
382
362
  num_threads=num_threads):
383
- total_batch_counts_sum = 0
384
- total_squared_batch_counts_sum = 0
385
- total_num_out_of_range = 0
363
+ out_of_range_sum = 0
364
+ squared_out_of_range_sum = 0
365
+ num_out_of_range = 0
386
366
  for thread_index in range(num_threads):
387
- total_batch_counts_sum = \
388
- total_batch_counts_sum + \
389
- thread_batch_counts_sum_int[thread_index][gene]
390
- total_squared_batch_counts_sum = \
391
- total_squared_batch_counts_sum + \
392
- thread_squared_batch_counts_sum_int[thread_index][gene]
393
- total_num_out_of_range = \
394
- total_num_out_of_range + \
395
- thread_num_out_of_range[thread_index][gene]
367
+ out_of_range_sum = out_of_range_sum + \
368
+ thread_sums[thread_index, gene, 0]
369
+ squared_out_of_range_sum = squared_out_of_range_sum + \
370
+ thread_sums[thread_index, gene, 1]
371
+ num_out_of_range = num_out_of_range + \
372
+ thread_sums[thread_index, gene, 2]
396
373
  batch_counts_sum[gene] = \
397
- total_batch_counts_sum + \
398
- total_num_out_of_range * clip_val[gene]
374
+ (counts_sum[gene] - out_of_range_sum) + \
375
+ num_out_of_range * clip_val[gene]
399
376
  squared_batch_counts_sum[gene] = \
400
- total_squared_batch_counts_sum + \
401
- total_num_out_of_range * \
402
- clip_val[gene] * clip_val[gene]
377
+ (squared_counts_sum[gene] - squared_out_of_range_sum) + \
378
+ num_out_of_range * clip_val[gene] * clip_val[gene]
403
379
 
404
380
 
405
381
  def clipped_sum_csc(const numeric[::1] data,
@@ -37,12 +37,14 @@ def knn_self(const float[:, ::1] X,
37
37
  num_cells_in_block, query_index_in_block, query_sorted, num_pairs, \
38
38
  pair_index, next_pair_index, query_in_cluster_index, \
39
39
  num_queries_in_cluster, max_cluster_size, cluster_start, \
40
- cluster_size, neighbor_offset, num_dimensions = X.shape[1], \
40
+ cluster_size, neighbor_offset, block_offset, block_end, \
41
+ num_dimensions = X.shape[1], \
41
42
  num_clusters = centroids.shape[0], \
42
43
  num_chunks = (num_X + chunk_size_kmeans - 1) / chunk_size_kmeans, \
43
44
  num_blocks = (num_X + chunk_size_search - 1) / chunk_size_search
44
45
  cdef float norm, worst_distance, distance, query_norm, true_distance, \
45
- partial_distance
46
+ block_min_distance
47
+ cdef const float* row
46
48
  cdef uninitialized_vector[unsigned] cluster_offsets_buffer
47
49
  cdef uninitialized_vector[float] centroid_norms_buffer, \
48
50
  temp_distances_buffer, query_X_buffer, block_distances_buffer
@@ -242,34 +244,25 @@ def knn_self(const float[:, ::1] X,
242
244
  query_sorted = block_start + query_index_in_block
243
245
  query_norm = cell_norms[query_sorted]
244
246
  worst_distance = distances[query_sorted, 0]
245
- if not cluster_start <= query_sorted < \
246
- cluster_start + cluster_size:
247
- # Different cluster from the one the query cell is in;
248
- # leave out the self-neighbors check
249
- for neighbor_offset in range(cluster_size):
250
- neighbor = cluster_start + neighbor_offset
251
- partial_distance = block_distances[
252
- query_in_cluster_index * cluster_size +
253
- neighbor_offset]
254
- true_distance = query_norm + partial_distance
255
- if true_distance < worst_distance:
256
- max_heap_replace_top(
257
- &neighbors[query_sorted, 0],
258
- &distances[query_sorted, 0],
259
- sorted_order[neighbor], true_distance,
260
- num_neighbors)
261
- worst_distance = distances[query_sorted, 0]
262
- else:
263
- # Same cluster the query cell is in; check for
264
- # self-neighbors
265
- for neighbor_offset in range(cluster_size):
247
+ row = &block_distances[
248
+ query_in_cluster_index * cluster_size]
249
+ # Skip blocks of 64 cells where even the nearest one
250
+ # does not beat the current worst neighbor
251
+ for block_offset in range(0, cluster_size, 64):
252
+ block_end = min(block_offset + 64, cluster_size)
253
+ block_min_distance = row[block_offset]
254
+ for neighbor_offset in range(block_offset + 1,
255
+ block_end):
256
+ block_min_distance = min(block_min_distance,
257
+ row[neighbor_offset])
258
+ if query_norm + block_min_distance >= worst_distance:
259
+ continue
260
+ for neighbor_offset in range(block_offset,
261
+ block_end):
266
262
  neighbor = cluster_start + neighbor_offset
267
263
  if query_sorted == neighbor:
268
264
  continue # skip self-neighbors
269
- partial_distance = block_distances[
270
- query_in_cluster_index * cluster_size +
271
- neighbor_offset]
272
- true_distance = query_norm + partial_distance
265
+ true_distance = query_norm + row[neighbor_offset]
273
266
  if true_distance < worst_distance:
274
267
  max_heap_replace_top(
275
268
  &neighbors[query_sorted, 0],
@@ -458,37 +451,28 @@ def knn_self(const float[:, ::1] X,
458
451
  query_sorted = block_start + query_index_in_block
459
452
  query_norm = cell_norms[query_sorted]
460
453
  worst_distance = distances[query_sorted, 0]
461
- if not cluster_start <= query_sorted < \
462
- cluster_start + cluster_size:
463
- # Different cluster from the one the query cell
464
- # is in; leave out the self-neighbors check
465
- for neighbor_offset in range(cluster_size):
466
- neighbor = cluster_start + neighbor_offset
467
- partial_distance = thread_block_distances[
468
- thread_index][query_in_cluster_index *
469
- cluster_size + neighbor_offset]
470
- true_distance = \
471
- query_norm + partial_distance
472
- if true_distance < worst_distance:
473
- max_heap_replace_top(
474
- &neighbors[query_sorted, 0],
475
- &distances[query_sorted, 0],
476
- sorted_order[neighbor],
477
- true_distance, num_neighbors)
478
- worst_distance = \
479
- distances[query_sorted, 0]
480
- else:
481
- # Same cluster the query cell is in; check for
482
- # self-neighbors
483
- for neighbor_offset in range(cluster_size):
454
+ row = \
455
+ thread_block_distances[thread_index].data() + \
456
+ query_in_cluster_index * cluster_size
457
+ for block_offset in range(0, cluster_size, 64):
458
+ block_end = \
459
+ min(block_offset + 64, cluster_size)
460
+ block_min_distance = row[block_offset]
461
+ for neighbor_offset in range(
462
+ block_offset + 1, block_end):
463
+ block_min_distance = min(
464
+ block_min_distance,
465
+ row[neighbor_offset])
466
+ if query_norm + block_min_distance >= \
467
+ worst_distance:
468
+ continue
469
+ for neighbor_offset in range(block_offset,
470
+ block_end):
484
471
  neighbor = cluster_start + neighbor_offset
485
472
  if query_sorted == neighbor:
486
473
  continue # skip self-neighbors
487
- partial_distance = thread_block_distances[
488
- thread_index][query_in_cluster_index *
489
- cluster_size + neighbor_offset]
490
474
  true_distance = \
491
- query_norm + partial_distance
475
+ query_norm + row[neighbor_offset]
492
476
  if true_distance < worst_distance:
493
477
  max_heap_replace_top(
494
478
  &neighbors[query_sorted, 0],
@@ -544,14 +528,16 @@ def knn_cross(const float[:, ::1] Y,
544
528
  num_cells_in_block, query_index_in_block, query_sorted, num_pairs, \
545
529
  pair_index, next_pair_index, query_in_cluster_index, \
546
530
  num_queries_in_cluster, max_cluster_size, cluster_start, \
547
- cluster_size, neighbor_offset, num_dimensions = X.shape[1], \
531
+ cluster_size, neighbor_offset, block_offset, block_end, \
532
+ num_dimensions = X.shape[1], \
548
533
  num_clusters = centroids.shape[0], \
549
534
  num_chunks = (num_Y + chunk_size_kmeans - 1) / \
550
535
  chunk_size_kmeans, \
551
536
  num_blocks = (num_Y + chunk_size_search - 1) / \
552
537
  chunk_size_search
553
538
  cdef float norm, worst_distance, distance, query_norm, true_distance, \
554
- partial_distance
539
+ block_min_distance
540
+ cdef const float* row
555
541
  cdef uninitialized_vector[unsigned] cluster_offsets_buffer
556
542
  cdef uninitialized_vector[float] centroid_norms_buffer, \
557
543
  temp_distances_buffer, query_Y_buffer, block_distances_buffer
@@ -743,18 +729,29 @@ def knn_cross(const float[:, ::1] Y,
743
729
  query_sorted = block_start + query_index_in_block
744
730
  query_norm = query_norms[query_sorted]
745
731
  worst_distance = distances[query_sorted, 0]
746
- for neighbor_offset in range(cluster_size):
747
- neighbor = cluster_start + neighbor_offset
748
- partial_distance = block_distances[
749
- query_in_cluster_index * cluster_size +
750
- neighbor_offset]
751
- true_distance = query_norm + partial_distance
752
- if true_distance < worst_distance:
753
- max_heap_replace_top(&neighbors[query_sorted, 0],
754
- &distances[query_sorted, 0],
755
- neighbor, true_distance,
756
- num_neighbors)
757
- worst_distance = distances[query_sorted, 0]
732
+ row = &block_distances[
733
+ query_in_cluster_index * cluster_size]
734
+ # Skip blocks of 64 cells where even the nearest one
735
+ # does not beat the current worst neighbor
736
+ for block_offset in range(0, cluster_size, 64):
737
+ block_end = min(block_offset + 64, cluster_size)
738
+ block_min_distance = row[block_offset]
739
+ for neighbor_offset in range(block_offset + 1,
740
+ block_end):
741
+ block_min_distance = min(block_min_distance,
742
+ row[neighbor_offset])
743
+ if query_norm + block_min_distance >= worst_distance:
744
+ continue
745
+ for neighbor_offset in range(block_offset,
746
+ block_end):
747
+ neighbor = cluster_start + neighbor_offset
748
+ true_distance = query_norm + row[neighbor_offset]
749
+ if true_distance < worst_distance:
750
+ max_heap_replace_top(
751
+ &neighbors[query_sorted, 0],
752
+ &distances[query_sorted, 0], neighbor,
753
+ true_distance, num_neighbors)
754
+ worst_distance = distances[query_sorted, 0]
758
755
 
759
756
  pair_index = next_pair_index
760
757
 
@@ -944,18 +941,34 @@ def knn_cross(const float[:, ::1] Y,
944
941
  query_sorted = block_start + query_index_in_block
945
942
  query_norm = query_norms[query_sorted]
946
943
  worst_distance = distances[query_sorted, 0]
947
- for neighbor_offset in range(cluster_size):
948
- neighbor = cluster_start + neighbor_offset
949
- partial_distance = thread_block_distances[
950
- thread_index][query_in_cluster_index *
951
- cluster_size + neighbor_offset]
952
- true_distance = query_norm + partial_distance
953
- if true_distance < worst_distance:
954
- max_heap_replace_top(
955
- &neighbors[query_sorted, 0],
956
- &distances[query_sorted, 0],
957
- neighbor, true_distance, num_neighbors)
958
- worst_distance = distances[query_sorted, 0]
944
+ row = \
945
+ thread_block_distances[thread_index].data() + \
946
+ query_in_cluster_index * cluster_size
947
+ for block_offset in range(0, cluster_size, 64):
948
+ block_end = \
949
+ min(block_offset + 64, cluster_size)
950
+ block_min_distance = row[block_offset]
951
+ for neighbor_offset in range(
952
+ block_offset + 1, block_end):
953
+ block_min_distance = min(
954
+ block_min_distance,
955
+ row[neighbor_offset])
956
+ if query_norm + block_min_distance >= \
957
+ worst_distance:
958
+ continue
959
+ for neighbor_offset in range(block_offset,
960
+ block_end):
961
+ neighbor = cluster_start + neighbor_offset
962
+ true_distance = \
963
+ query_norm + row[neighbor_offset]
964
+ if true_distance < worst_distance:
965
+ max_heap_replace_top(
966
+ &neighbors[query_sorted, 0],
967
+ &distances[query_sorted, 0],
968
+ neighbor, true_distance,
969
+ num_neighbors)
970
+ worst_distance = \
971
+ distances[query_sorted, 0]
959
972
 
960
973
  pair_index = next_pair_index
961
974
 
@@ -11385,6 +11385,12 @@ class SingleCell:
11385
11385
  norm_gene_vars = []
11386
11386
  total_nonzero_count = np.zeros(len(genes_in_all_datasets),
11387
11387
  dtype=np.uint64)
11388
+ # For CSR, allocate the per-thread sums once and reuse them across
11389
+ # batches, rather than reallocating them for every batch
11390
+ if any(isinstance(dataset._X, csr_array) for dataset in datasets):
11391
+ thread_sums = np.empty((num_threads, max(
11392
+ dataset._X.shape[1] for dataset in datasets), 3),
11393
+ dtype=np.uint64)
11388
11394
  for X, cell_mask, gene_indices in batches:
11389
11395
  num_dataset_genes = X.shape[1]
11390
11396
  mean = np.empty(num_dataset_genes, dtype=np.float32)
@@ -11400,10 +11406,15 @@ class SingleCell:
11400
11406
  else:
11401
11407
  cell_indices = cell_mask
11402
11408
  num_cells = len(cell_indices)
11409
+ counts_sum = np.empty(num_dataset_genes, dtype=np.uint64)
11410
+ squared_counts_sum = np.empty(num_dataset_genes,
11411
+ dtype=np.uint64)
11403
11412
  gene_mean_and_variance_csr(
11404
11413
  data=X.data, indices=X.indices, indptr=X.indptr,
11405
11414
  cell_indices=cell_indices, num_cells=num_cells,
11406
- num_dataset_genes=num_dataset_genes, mean=mean, var=var,
11415
+ num_dataset_genes=num_dataset_genes,
11416
+ thread_sums=thread_sums, counts_sum=counts_sum,
11417
+ squared_counts_sum=squared_counts_sum, mean=mean, var=var,
11407
11418
  nonzero_count=nonzero_count, num_threads=num_threads)
11408
11419
  else:
11409
11420
  if cell_mask is None:
@@ -11452,6 +11463,8 @@ class SingleCell:
11452
11463
  data=X.data, indices=X.indices, indptr=X.indptr,
11453
11464
  num_cells=num_cells, num_dataset_genes=num_dataset_genes,
11454
11465
  cell_indices=cell_indices, clip_val=clip_val,
11466
+ thread_sums=thread_sums, counts_sum=counts_sum,
11467
+ squared_counts_sum=squared_counts_sum,
11455
11468
  batch_counts_sum=batch_counts_sum,
11456
11469
  squared_batch_counts_sum=squared_batch_counts_sum,
11457
11470
  num_threads=num_threads)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brisc
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Fast single-cell toolkit
5
5
  Author-email: Michael Wainberg <m.wainberg@utoronto.ca>
6
6
  License-Expression: MIT
@@ -1,3 +1,6 @@
1
+ .git_archival.txt
2
+ .gitattributes
3
+ .gitignore
1
4
  LICENSE
2
5
  MANIFEST.in
3
6
  README.md
@@ -37,7 +40,11 @@ brisc.egg-info/PKG-INFO
37
40
  brisc.egg-info/SOURCES.txt
38
41
  brisc.egg-info/dependency_links.txt
39
42
  brisc.egg-info/requires.txt
43
+ brisc.egg-info/scm_file_list.json
44
+ brisc.egg-info/scm_version.json
40
45
  brisc.egg-info/top_level.txt
46
+ tests/conftest.py
47
+ tests/make_test_data.py
41
48
  tests/test_cluster.py
42
49
  tests/test_de.py
43
50
  tests/test_embedding.py
@@ -0,0 +1,110 @@
1
+ {
2
+ "files": [
3
+ ".git_archival.txt",
4
+ ".gitattributes",
5
+ ".github/workflows/build-and-publish.yml",
6
+ ".github/workflows/docs.yml",
7
+ ".gitignore",
8
+ "LICENSE",
9
+ "MANIFEST.in",
10
+ "README.md",
11
+ "brisc/__init__.py",
12
+ "brisc/colorspacious.py",
13
+ "brisc/concatenate.py",
14
+ "brisc/cysparse.pyx",
15
+ "brisc/cyutils.pxd",
16
+ "brisc/cyutils.pyx",
17
+ "brisc/de.py",
18
+ "brisc/doublets.pyx",
19
+ "brisc/embed.pyx",
20
+ "brisc/harmonize.pyx",
21
+ "brisc/hdf5.pyx",
22
+ "brisc/hvg.pyx",
23
+ "brisc/kmeans.pyx",
24
+ "brisc/knn.pyx",
25
+ "brisc/labels.pyx",
26
+ "brisc/leiden.pyx",
27
+ "brisc/loess.pyx",
28
+ "brisc/norm_factors.pyx",
29
+ "brisc/normalize.pyx",
30
+ "brisc/pca.pyx",
31
+ "brisc/pseudobulk.py",
32
+ "brisc/pseudobulk_and_markers.pyx",
33
+ "brisc/qc.pyx",
34
+ "brisc/single_cell.py",
35
+ "brisc/snn.pyx",
36
+ "brisc/sparse.py",
37
+ "brisc/threadpoolctl.py",
38
+ "brisc/type_aliases.py",
39
+ "brisc/utils.py",
40
+ "brisc/validated_dict.py",
41
+ "docs/.gitignore",
42
+ "docs/Makefile",
43
+ "docs/deploy.sh",
44
+ "docs/deploy/_worker.js",
45
+ "docs/deploy/robots.txt",
46
+ "docs/make_tutorial_data.py",
47
+ "docs/source/_pygments_ristretto.py",
48
+ "docs/source/_static/css/custom.css",
49
+ "docs/source/_static/fonts/DejaVuSansMono-subset.woff2",
50
+ "docs/source/_static/images/favicon.ico",
51
+ "docs/source/_static/images/runner_logo_dark.svg",
52
+ "docs/source/_static/images/runner_logo_light.svg",
53
+ "docs/source/_static/images/runner_title_wide_dark.svg",
54
+ "docs/source/_static/images/runner_title_wide_light.svg",
55
+ "docs/source/_static/js/api-scrollspy.js",
56
+ "docs/source/_static/js/benchmark-data.js",
57
+ "docs/source/_static/js/benchmark.js",
58
+ "docs/source/_static/js/details-anchor.js",
59
+ "docs/source/_static/js/header-reveal.js",
60
+ "docs/source/_static/js/theme-fix.js",
61
+ "docs/source/_templates/guide-nav.html",
62
+ "docs/source/api/index.md",
63
+ "docs/source/conf.py",
64
+ "docs/source/de/analysis.rst",
65
+ "docs/source/de/index.rst",
66
+ "docs/source/de/io.rst",
67
+ "docs/source/de/properties.rst",
68
+ "docs/source/index.md",
69
+ "docs/source/installation.md",
70
+ "docs/source/pseudobulk/analysis.rst",
71
+ "docs/source/pseudobulk/data_access.rst",
72
+ "docs/source/pseudobulk/dictionary_interface.rst",
73
+ "docs/source/pseudobulk/index.rst",
74
+ "docs/source/pseudobulk/io.rst",
75
+ "docs/source/pseudobulk/manipulation.rst",
76
+ "docs/source/pseudobulk/properties.rst",
77
+ "docs/source/singlecell/analysis.rst",
78
+ "docs/source/singlecell/data_access.rst",
79
+ "docs/source/singlecell/index.rst",
80
+ "docs/source/singlecell/io.rst",
81
+ "docs/source/singlecell/manipulation.rst",
82
+ "docs/source/singlecell/properties.rst",
83
+ "docs/source/singlecell/typedefs.rst",
84
+ "docs/source/tutorials/basic_workflow.md",
85
+ "docs/source/tutorials/data_manipulation.md",
86
+ "docs/source/tutorials/differential_expression.md",
87
+ "docs/source/tutorials/images/label_transfer.png",
88
+ "docs/source/tutorials/images/markers.png",
89
+ "docs/source/tutorials/images/umap.png",
90
+ "docs/source/tutorials/images/volcano.png",
91
+ "docs/source/tutorials/index.md",
92
+ "docs/source/tutorials/integration_and_label_transfer.md",
93
+ "docs/source/tutorials/interoperability.md",
94
+ "pyproject.toml",
95
+ "setup.py",
96
+ "tests/conftest.py",
97
+ "tests/make_test_data.py",
98
+ "tests/test_cluster.py",
99
+ "tests/test_de.py",
100
+ "tests/test_embedding.py",
101
+ "tests/test_hvg.py",
102
+ "tests/test_integration.py",
103
+ "tests/test_markers.py",
104
+ "tests/test_neighbors.py",
105
+ "tests/test_normalize.py",
106
+ "tests/test_pca.py",
107
+ "tests/test_pseudobulk.py",
108
+ "tests/test_qc.py"
109
+ ]
110
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.2.2",
3
+ "distance": 0,
4
+ "node": "g0a16f206c3772da54539139bb13d522c030a32ff",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-23"
8
+ }
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools>=61", "wheel", "Cython>=3.0", "numpy>=1.17.0", "scipy>=0.16.0"]
2
+ requires = ["setuptools>=64", "setuptools-scm>=8", "wheel", "Cython>=3.0", "numpy>=1.17.0", "scipy>=0.16.0"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "brisc"
7
- version = "0.2.1"
7
+ dynamic = ["version"]
8
8
  description = "Fast single-cell toolkit"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -39,6 +39,8 @@ include = ["brisc", "brisc.*"]
39
39
  [tool.setuptools.exclude-package-data]
40
40
  "*" = ["*.cpp", "*.c"]
41
41
 
42
+ [tool.setuptools_scm]
43
+
42
44
  [tool.cibuildwheel]
43
45
  build = "cp39-* cp310-* cp311-* cp312-* cp313-* cp314-* cp315-*"
44
46
  skip = "*musllinux* *i686* *ppc64le* *s390x* cp314t-* cp315t-*"
@@ -0,0 +1,30 @@
1
+ """Shared fixtures for the brisc test suite."""
2
+ import shutil
3
+ import pytest
4
+
5
+ _FIXTURE = "Parse_10K_PBMC_cytokines.h5ad"
6
+ _HF_REPO = "dashingcell/Parse_10K_PBMC_cytokines"
7
+
8
+
9
+ @pytest.fixture(scope="session")
10
+ def data_path():
11
+ # Pull the ~0.15 GB fixture from the public HF dataset (cached under
12
+ # ~/.cache/huggingface).
13
+ from huggingface_hub import hf_hub_download
14
+ return hf_hub_download(_HF_REPO, _FIXTURE, repo_type="dataset")
15
+
16
+
17
+ @pytest.fixture(scope="session")
18
+ def sc_orig(data_path):
19
+ from brisc import SingleCell
20
+ return SingleCell(data_path)
21
+
22
+
23
+ @pytest.fixture(scope="session")
24
+ def require_r():
25
+ # R is installed in CI, so a broken ryp/limma should fail loudly, not skip.
26
+ if shutil.which("R") is None and shutil.which("Rscript") is None:
27
+ pytest.skip("R not installed")
28
+ import ryp
29
+ ryp.r("suppressMessages(library(limma))")
30
+ return True
@@ -0,0 +1,105 @@
1
+ """
2
+ Build the brisc test fixture and publish it to the HF dataset CI pulls from.
3
+ Kept for provenance -- CI never runs this; conftest fetches the finished file.
4
+
5
+ PBS vs IFN-gamma, balanced 50/50, subsampled to 10,000 cells x full genes:
6
+ 24 pseudobulk samples (2 x 12 donors) instead of 1,092, so DE by
7
+ (sample, cell_type) stays well-populated.
8
+
9
+ Run where the 10M source (~227 GB) lives; skips the download if it's present.
10
+ Needs huggingface_hub and a write token (HF_TOKEN or `huggingface-cli login`).
11
+ """
12
+ import subprocess
13
+ from pathlib import Path
14
+ import polars as pl
15
+ from brisc import SingleCell
16
+ from huggingface_hub import HfApi
17
+
18
+ REPO_ID = 'dashingcell/Parse_10K_PBMC_cytokines'
19
+ N_CELLS = 10_000
20
+ CONDITIONS = ['PBS', 'IFN-gamma']
21
+ OBS_COLUMNS = ['sample', 'donor', 'cell_type', 'treatment', 'cytokine']
22
+
23
+ data_dir = Path.home() / 'single-cell' / 'Parse'
24
+ src = data_dir / 'Parse_10M_PBMC_cytokines.h5ad'
25
+ out = data_dir / f'Parse_{N_CELLS // 1000}K_PBMC_cytokines.h5ad'
26
+
27
+ CARD = """\
28
+ ---
29
+ license: other
30
+ license_name: parse-biosciences-data-terms
31
+ license_link: https://www.parsebiosciences.com/datasets/
32
+ pretty_name: Parse 10K PBMC Cytokines (IFN-γ vs PBS)
33
+ tags:
34
+ - single-cell
35
+ - scRNA-seq
36
+ - PBMC
37
+ - immunology
38
+ - cytokines
39
+ - brisc
40
+ size_categories:
41
+ - 1K<n<10K
42
+ ---
43
+
44
+ # Parse 10K PBMC Cytokines (IFN-γ vs PBS)
45
+
46
+ Balanced 10,000-cell subsample of the ~10 million cell cytokine stimulation
47
+ dataset from Parse Biosciences.
48
+
49
+ In the original experiment, peripheral blood mononuclear cells (PBMCs) from
50
+ twelve healthy donors were treated with either one of 90 different cytokines or
51
+ a phosphate-buffered saline (PBS) control for 24 hours, yielding
52
+ (90 + 1) × 12 = 1,092 experimental conditions.
53
+
54
+ For the brisc test suite, the data is restricted to the interferon-gamma
55
+ (IFN-γ) versus PBS contrast and subsampled to 5,000 cells per condition,
56
+ stratified by cell type. The result is 10,000 cells × 40,352 genes of raw
57
+ counts across 24 samples (12 donors × 2 conditions) and 18 annotated cell
58
+ types, with `obs` columns `sample`, `donor`, `cell_type`, `treatment`, and
59
+ `cytokine`.
60
+
61
+ ## Source
62
+
63
+ - https://www.parsebiosciences.com/datasets/10-million-human-pbmcs-in-a-single-experiment/
64
+ - https://www.biorxiv.org/content/10.64898/2025.12.12.693897v1
65
+
66
+ All credit to Parse Biosciences.
67
+ """
68
+
69
+ def remove_unused_categories(s: pl.Series) -> pl.Expr:
70
+ """polars-Enum version of pandas' remove_unused_categories: rebuild each
71
+ Enum from the values present."""
72
+ present = set(s.unique().to_list())
73
+ kept = [c for c in s.dtype.categories.to_list() if c in present]
74
+ return pl.col(s.name).cast(pl.Enum(kept))
75
+
76
+ # Fetch the 10M source once
77
+ data_dir.mkdir(parents=True, exist_ok=True)
78
+ if not src.exists():
79
+ url = 'https://parse-wget.s3.us-west-2.amazonaws.com/10m/' + src.name
80
+ subprocess.run(['wget', '-O', str(src), url], check=True)
81
+
82
+ # Build the balanced 10K fixture
83
+ sc = SingleCell(src, obs_columns=OBS_COLUMNS)\
84
+ .filter_obs(pl.col('cytokine').is_in(CONDITIONS))
85
+ half = N_CELLS // 2
86
+ pbs = sc.filter_obs(pl.col('cytokine') == 'PBS')\
87
+ .subsample_obs(n=half, by_column='cell_type', QC_column=None, seed=0)
88
+ ifn = sc.filter_obs(pl.col('cytokine') == 'IFN-gamma')\
89
+ .subsample_obs(n=half, by_column='cell_type', QC_column=None, seed=0)
90
+ fixture = pbs.concat_obs(ifn)
91
+
92
+ # Drop unused Enum categories (sample 1,092 -> 24, cytokine 91 -> 2)
93
+ enum_cols = [name for name, dt in fixture.obs.schema.items() if dt == pl.Enum]
94
+ fixture = fixture.with_columns_obs(
95
+ [remove_unused_categories(fixture.obs[c]) for c in enum_cols])
96
+ fixture.save(out, overwrite=True)
97
+
98
+ # Publish data + card to the HF dataset repo (no-op if unchanged)
99
+ api = HfApi()
100
+ api.create_repo(REPO_ID, repo_type='dataset', exist_ok=True)
101
+ api.upload_file(path_or_fileobj=str(out), path_in_repo=out.name,
102
+ repo_id=REPO_ID, repo_type='dataset')
103
+ api.upload_file(path_or_fileobj=CARD.encode(), path_in_repo='README.md',
104
+ repo_id=REPO_ID, repo_type='dataset')
105
+ print(f'Published to https://huggingface.co/datasets/{REPO_ID}')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes