annotuneagent 0.1.0__py3-none-any.whl

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 (38) hide show
  1. annotuneagent/__init__.py +68 -0
  2. annotuneagent/__main__.py +6 -0
  3. annotuneagent/_version.py +19 -0
  4. annotuneagent/annotate.py +93 -0
  5. annotuneagent/arbitrate.py +141 -0
  6. annotuneagent/cli.py +366 -0
  7. annotuneagent/decide.py +725 -0
  8. annotuneagent/evaluate.py +121 -0
  9. annotuneagent/evidence.py +606 -0
  10. annotuneagent/granularity/__init__.py +28 -0
  11. annotuneagent/granularity/cl_dag.py +179 -0
  12. annotuneagent/granularity/ontology_map.py +108 -0
  13. annotuneagent/granularity/relations.py +96 -0
  14. annotuneagent/hypotheses.py +38 -0
  15. annotuneagent/local_lora.py +706 -0
  16. annotuneagent/marker_plot.py +172 -0
  17. annotuneagent/markers.py +206 -0
  18. annotuneagent/markers.tsv +378 -0
  19. annotuneagent/mltools.py +128 -0
  20. annotuneagent/ontology/cl_isa.json +1 -0
  21. annotuneagent/ontology/granularity_policy.yaml +8 -0
  22. annotuneagent/ontology/ontology_map.jsonl +198 -0
  23. annotuneagent/prepare.py +1385 -0
  24. annotuneagent/py.typed +0 -0
  25. annotuneagent/read.py +76 -0
  26. annotuneagent/tokenizer/LICENSE +202 -0
  27. annotuneagent/tokenizer/merges.txt +247587 -0
  28. annotuneagent/tokenizer/tokenizer.json +495920 -0
  29. annotuneagent/tokenizer/tokenizer_config.json +305 -0
  30. annotuneagent/tokenizer/vocab.json +248046 -0
  31. annotuneagent/votes.py +83 -0
  32. annotuneagent/workflow.py +2037 -0
  33. annotuneagent-0.1.0.dist-info/METADATA +208 -0
  34. annotuneagent-0.1.0.dist-info/RECORD +38 -0
  35. annotuneagent-0.1.0.dist-info/WHEEL +5 -0
  36. annotuneagent-0.1.0.dist-info/entry_points.txt +2 -0
  37. annotuneagent-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. annotuneagent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,606 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Gene-level evidence for one cluster against the other cells."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import os
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from dataclasses import dataclass
12
+
13
+ import numba
14
+ import numpy as np
15
+ import pandas as pd
16
+ from anndata import AnnData
17
+ from scipy import sparse, stats
18
+
19
+ # A marker is relatively high inside the cluster. Other cell types may also
20
+ # express it. A negative marker counts against a label only when a substantial
21
+ # fraction of the cluster expresses it and its level is higher inside
22
+ # (log fold-change). Equal inside and outside levels are ambient and do not.
23
+ NEGATIVE_PRESENT_PCT = 0.25
24
+ NEGATIVE_MIN_LOGFC = 1.0
25
+
26
+
27
+ _LOG1P_MAX = 30.0
28
+ # Marker columns are copied once. The cap keeps that copy near 256 MB.
29
+ _MARKER_CHUNK_BYTES = 256 * 1024 * 1024
30
+ # Per-thread cluster sums stay under this. Above it, one thread owns the sum.
31
+ _PARTIAL_SUM_BYTES = 1024 * 1024 * 1024
32
+
33
+
34
+ def _cpus() -> int:
35
+ return max(1, os.cpu_count() or 1)
36
+
37
+
38
+ def _expression_values(adata: AnnData) -> np.ndarray:
39
+ if sparse.issparse(adata.X):
40
+ return np.asarray(adata.X.data)
41
+ return np.asarray(adata.X).ravel()
42
+
43
+
44
+ def _looks_like_log1p(values: np.ndarray) -> bool:
45
+ """True when values are already natural-log1p and not raw counts.
46
+
47
+ Raw counts are whole numbers. A missing ``uns['log1p']`` flag does not
48
+ by itself mean the matrix is counts.
49
+ """
50
+ if values.size == 0:
51
+ return True
52
+ peak = float(np.max(values))
53
+ if peak > _LOG1P_MAX:
54
+ return False
55
+ nonzero = values[values > 0]
56
+ if nonzero.size == 0 or peak < 1:
57
+ return True
58
+ whole = np.abs(nonzero - np.rint(nonzero)) < 1e-6
59
+ return float(whole.mean()) <= 0.5
60
+
61
+
62
+ def validate_expression(adata: AnnData) -> None:
63
+ """Require natural-log1p, library-size-normalized expression in ``X``.
64
+
65
+ A matrix that passes and has no ``uns['log1p']`` flag gets
66
+ ``uns['log1p'] = {'base': None}``.
67
+ """
68
+ flagged = adata.uns.get("log1p")
69
+ if isinstance(flagged, dict) and flagged.get("base") is not None:
70
+ raise ValueError("adata.X must use natural log1p expression")
71
+ values = _expression_values(adata)
72
+ if values.size and (not np.isfinite(values).all() or (values < 0).any()):
73
+ raise ValueError("adata.X must contain finite, nonnegative log1p expression")
74
+ if flagged is None:
75
+ if not _looks_like_log1p(values):
76
+ raise ValueError("adata.X must be normalized with scanpy.pp.normalize_total and log1p")
77
+ adata.uns["log1p"] = {"base": None}
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class GeneEvidence:
82
+ gene: str
83
+ direction: str
84
+ pct_in: float
85
+ pct_out: float
86
+ mean_in: float
87
+ mean_out: float
88
+ logfc: float
89
+ pvalue: float | None
90
+ expressed: bool
91
+
92
+ def as_dict(self) -> dict[str, object]:
93
+ return {
94
+ "gene": self.gene,
95
+ "direction": self.direction,
96
+ "pct_in": self.pct_in,
97
+ "pct_out": self.pct_out,
98
+ "mean_in": self.mean_in,
99
+ "mean_out": self.mean_out,
100
+ "logfc": self.logfc,
101
+ "pvalue": self.pvalue,
102
+ "expressed": self.expressed,
103
+ }
104
+
105
+
106
+ def _cluster_labels(adata: AnnData, cluster_key: str) -> np.ndarray:
107
+ """Cluster label of every cell as text. A cell with no cluster is an error."""
108
+ if cluster_key not in adata.obs.columns:
109
+ raise KeyError(f"obs has no column {cluster_key!r}")
110
+ column = adata.obs[cluster_key]
111
+ missing = int(column.isna().sum())
112
+ if missing:
113
+ raise ValueError(
114
+ f"obs[{cluster_key!r}] has {missing} cells with no cluster; assign or remove them first"
115
+ )
116
+ return column.astype(str).to_numpy()
117
+
118
+
119
+ def cluster_ids(adata: AnnData, cluster_key: str) -> list[str]:
120
+ return list(dict.fromkeys(_cluster_labels(adata, cluster_key).tolist()))
121
+
122
+
123
+ def cluster_mask(adata: AnnData, cluster_key: str, cluster: str) -> np.ndarray:
124
+ mask = _cluster_labels(adata, cluster_key) == str(cluster)
125
+ if not mask.any():
126
+ raise ValueError(f"cluster {cluster!r} has no cells")
127
+ if mask.all():
128
+ raise ValueError(f"cluster {cluster!r} contains every cell; there is no contrast")
129
+ return mask
130
+
131
+
132
+ def require_unique_var_names(adata: AnnData) -> None:
133
+ """Stop before marker scoring when two columns share a gene symbol."""
134
+ names = pd.Index(adata.var_names.astype(str))
135
+ if names.is_unique:
136
+ return
137
+ raise ValueError("var_names are not unique; call adata.var_names_make_unique() first")
138
+
139
+
140
+ def evidence_from_stats(
141
+ gene: str,
142
+ direction: str,
143
+ stats_row: tuple[float, float, float, float, float, float],
144
+ ) -> GeneEvidence:
145
+ pct_in, pct_out, mean_in, mean_out, logfc, pvalue = stats_row
146
+ expressed = (
147
+ negative_marker_conflicts(pct_in, pct_out, logfc) if direction == "negative" else logfc > 0
148
+ )
149
+ return GeneEvidence(
150
+ gene=gene,
151
+ direction=direction,
152
+ pct_in=pct_in,
153
+ pct_out=pct_out,
154
+ mean_in=mean_in,
155
+ mean_out=mean_out,
156
+ logfc=logfc,
157
+ pvalue=pvalue,
158
+ expressed=expressed,
159
+ )
160
+
161
+
162
+ def measure_gene(
163
+ adata: AnnData,
164
+ mask: np.ndarray,
165
+ gene: str,
166
+ *,
167
+ direction: str,
168
+ ) -> GeneEvidence:
169
+ """One marker against the cells outside ``mask``.
170
+
171
+ The number is the same value ``marker_statistics`` reports. Mann-Whitney
172
+ p-values are recorded and do not change the score.
173
+ """
174
+ row = marker_statistics(adata, [np.asarray(mask, dtype=bool)], [gene])[0][gene]
175
+ return evidence_from_stats(gene, direction, row)
176
+
177
+
178
+ def marker_statistics(
179
+ adata: AnnData,
180
+ masks: list[np.ndarray],
181
+ genes: list[str],
182
+ ) -> list[dict[str, tuple[float, float, float, float, float, float]]]:
183
+ """Percent, linear mean, log fold-change, and Mann-Whitney p for every mask.
184
+
185
+ Each value is ``(pct_in, pct_out, mean_in, mean_out, logfc, pvalue)``.
186
+ Threads share ``adata.X``. A process pool would copy the matrix per worker.
187
+ """
188
+ n_obs = adata.n_obs
189
+ n_clusters = len(masks)
190
+ n_genes = len(genes)
191
+ if n_clusters == 0:
192
+ return []
193
+ codes = np.full(n_obs, -1, dtype=np.int32)
194
+ n_in = np.zeros(n_clusters, dtype=np.float64)
195
+ for cluster, mask in enumerate(masks):
196
+ mask = np.asarray(mask, dtype=bool)
197
+ if mask.shape != (n_obs,):
198
+ raise ValueError("cluster mask length does not match n_obs")
199
+ count = int(mask.sum())
200
+ if count == 0 or count == n_obs:
201
+ raise ValueError("`x` and `y` must be of nonzero size.")
202
+ codes[mask] = np.int32(cluster)
203
+ n_in[cluster] = count
204
+ empty = [{} for _ in range(n_clusters)]
205
+ if n_genes == 0:
206
+ return empty
207
+ require_unique_var_names(adata)
208
+ found = pd.Index(adata.var_names.astype(str)).get_indexer(genes)
209
+ for gene, position in zip(genes, found, strict=True):
210
+ if position < 0:
211
+ raise KeyError(f"gene {gene!r} is not in var_names")
212
+ indexes = [int(position) for position in found]
213
+ pct_in = np.zeros((n_clusters, n_genes), dtype=np.float64)
214
+ pct_out = np.zeros((n_clusters, n_genes), dtype=np.float64)
215
+ mean_in = np.zeros((n_clusters, n_genes), dtype=np.float64)
216
+ mean_out = np.zeros((n_clusters, n_genes), dtype=np.float64)
217
+ logfc = np.zeros((n_clusters, n_genes), dtype=np.float64)
218
+ pvalues = np.ones((n_clusters, n_genes), dtype=np.float64)
219
+ tie_term = np.zeros(n_genes, dtype=np.float64)
220
+ chunk = max(1, _MARKER_CHUNK_BYTES // (8 * max(n_obs, 1)))
221
+ for offset in range(0, n_genes, chunk):
222
+ stop = min(n_genes, offset + chunk)
223
+ block = _gene_block(adata.X, indexes[offset:stop])
224
+ _score_block(
225
+ block,
226
+ offset,
227
+ codes,
228
+ n_in,
229
+ pct_in,
230
+ pct_out,
231
+ mean_in,
232
+ mean_out,
233
+ logfc,
234
+ pvalues,
235
+ tie_term,
236
+ )
237
+ _exact_small_clusters(block, codes, n_in, pvalues, tie_term, offset)
238
+ rows: list[dict[str, tuple[float, float, float, float, float, float]]] = []
239
+ for cluster in range(n_clusters):
240
+ rows.append(
241
+ {
242
+ gene: (
243
+ float(pct_in[cluster, index]),
244
+ float(pct_out[cluster, index]),
245
+ float(mean_in[cluster, index]),
246
+ float(mean_out[cluster, index]),
247
+ float(logfc[cluster, index]),
248
+ float(pvalues[cluster, index]),
249
+ )
250
+ for index, gene in enumerate(genes)
251
+ }
252
+ )
253
+ return rows
254
+
255
+
256
+ def _gene_block(matrix, indexes: list[int]) -> np.ndarray:
257
+ chosen = np.asarray(indexes, dtype=int)
258
+ if sparse.issparse(matrix):
259
+ dense = np.asarray(matrix[:, chosen].toarray(), dtype=np.float64)
260
+ else:
261
+ dense = np.asarray(matrix[:, chosen], dtype=np.float64)
262
+ if dense.ndim == 1:
263
+ dense = dense.reshape(-1, 1)
264
+ return np.ascontiguousarray(dense.T)
265
+
266
+
267
+ def _score_block(
268
+ block: np.ndarray,
269
+ offset: int,
270
+ codes: np.ndarray,
271
+ n_in: np.ndarray,
272
+ pct_in: np.ndarray,
273
+ pct_out: np.ndarray,
274
+ mean_in: np.ndarray,
275
+ mean_out: np.ndarray,
276
+ logfc: np.ndarray,
277
+ pvalues: np.ndarray,
278
+ tie_term: np.ndarray,
279
+ ) -> None:
280
+ n_chunk = block.shape[0]
281
+
282
+ def call(start: int, stop: int) -> None:
283
+ _marker_chunk(
284
+ block,
285
+ codes,
286
+ n_in,
287
+ pct_in,
288
+ pct_out,
289
+ mean_in,
290
+ mean_out,
291
+ logfc,
292
+ pvalues,
293
+ tie_term,
294
+ offset,
295
+ start,
296
+ stop,
297
+ )
298
+
299
+ _run_chunks(call, n_chunk)
300
+
301
+
302
+ def _run_chunks(call, n_items: int) -> None:
303
+ if n_items <= 0:
304
+ return
305
+ workers = min(_cpus(), n_items)
306
+ edges = [int(i * n_items / workers) for i in range(workers + 1)]
307
+ # Compile on this thread, then give every core a slice.
308
+ call(0, 0)
309
+ if workers == 1:
310
+ call(0, n_items)
311
+ return
312
+ with ThreadPoolExecutor(max_workers=workers) as pool:
313
+ futures = [pool.submit(call, edges[i], edges[i + 1]) for i in range(workers)]
314
+ for future in futures:
315
+ future.result()
316
+
317
+
318
+ @numba.njit(cache=True, nogil=True)
319
+ def _marker_chunk(
320
+ block,
321
+ codes,
322
+ n_in,
323
+ pct_in,
324
+ pct_out,
325
+ mean_in,
326
+ mean_out,
327
+ logfc,
328
+ pvalues,
329
+ tie_term,
330
+ offset,
331
+ start,
332
+ stop,
333
+ ):
334
+ n_obs = block.shape[1]
335
+ n_clusters = n_in.shape[0]
336
+ for local in range(start, stop):
337
+ column = block[local]
338
+ gene = offset + local
339
+ sum_in = np.zeros(n_clusters, dtype=np.float64)
340
+ count_in = np.zeros(n_clusters, dtype=np.float64)
341
+ total_sum = 0.0
342
+ total_count = 0.0
343
+ first = column[0]
344
+ constant = True
345
+ for index in range(n_obs):
346
+ value = column[index]
347
+ if value != first:
348
+ constant = False
349
+ linear = math.expm1(value)
350
+ total_sum += linear
351
+ expressed = 1.0 if value > 0.0 else 0.0
352
+ total_count += expressed
353
+ cluster = codes[index]
354
+ if cluster >= 0:
355
+ sum_in[cluster] += linear
356
+ count_in[cluster] += expressed
357
+ for cluster in range(n_clusters):
358
+ n1 = n_in[cluster]
359
+ n2 = n_obs - n1
360
+ mean_in[cluster, gene] = sum_in[cluster] / n1
361
+ mean_out[cluster, gene] = (total_sum - sum_in[cluster]) / n2
362
+ pct_in[cluster, gene] = count_in[cluster] / n1
363
+ pct_out[cluster, gene] = (total_count - count_in[cluster]) / n2
364
+ logfc[cluster, gene] = math.log2(
365
+ (mean_in[cluster, gene] + 1.0) / (mean_out[cluster, gene] + 1.0)
366
+ )
367
+ pvalues[cluster, gene] = 1.0
368
+ if constant:
369
+ count = float(n_obs)
370
+ tie_term[gene] = count * count * count - count
371
+ continue
372
+ order = np.argsort(column)
373
+ ranks = np.empty(n_obs, dtype=np.float64)
374
+ term = 0.0
375
+ group = 0
376
+ while group < n_obs:
377
+ end = group + 1
378
+ value = column[order[group]]
379
+ while end < n_obs and column[order[end]] == value:
380
+ end += 1
381
+ count = end - group
382
+ average = (group + 1) + (count - 1) / 2.0
383
+ for position in range(group, end):
384
+ ranks[order[position]] = average
385
+ count_f = float(count)
386
+ term += count_f * count_f * count_f - count_f
387
+ group = end
388
+ tie_term[gene] = term
389
+ rank_sum = np.zeros(n_clusters, dtype=np.float64)
390
+ for index in range(n_obs):
391
+ cluster = codes[index]
392
+ if cluster >= 0:
393
+ rank_sum[cluster] += ranks[index]
394
+ n = float(n_obs)
395
+ for cluster in range(n_clusters):
396
+ n1 = n_in[cluster]
397
+ n2 = n - n1
398
+ pvalues[cluster, gene] = _asymptotic_p(rank_sum[cluster], n1, n2, term)
399
+
400
+
401
+ @numba.njit(cache=True)
402
+ def _asymptotic_p(rank_sum, n1, n2, tie_term):
403
+ # Same normal approximation as scipy.stats.mannwhitneyu, two-sided, continuity on.
404
+ u1 = rank_sum - n1 * (n1 + 1.0) / 2.0
405
+ u2 = n1 * n2 - u1
406
+ u = u1 if u1 >= u2 else u2
407
+ mu = n1 * n2 / 2.0
408
+ n = n1 + n2
409
+ scale = (n + 1.0) - tie_term / (n * (n - 1.0))
410
+ variance = n1 * n2 / 12.0 * scale
411
+ if variance <= 0.0:
412
+ return 1.0
413
+ z = (u - mu - 0.5) / math.sqrt(variance)
414
+ p = math.erfc(z / math.sqrt(2.0))
415
+ if p > 1.0:
416
+ return 1.0
417
+ if p < 0.0:
418
+ return 0.0
419
+ return p
420
+
421
+
422
+ def _exact_small_clusters(block, codes, n_in, pvalues, tie_term, offset) -> None:
423
+ """Clusters of eight or fewer cells use scipy's exact test when nothing is tied.
424
+
425
+ Larger contrasts, and every tied contrast, keep the normal approximation.
426
+ A constant gene stays at p = 1, matching ``measure_gene``.
427
+ """
428
+ n_obs = codes.shape[0]
429
+ n_out = n_obs - n_in
430
+ small = np.flatnonzero((n_in <= 8.0) | (n_out <= 8.0))
431
+ if small.size == 0:
432
+ return
433
+ local = np.flatnonzero(tie_term[offset : offset + block.shape[0]] == 0.0)
434
+ if local.size == 0:
435
+ return
436
+ inside_idx = []
437
+ outside_idx = []
438
+ for cluster in range(n_in.shape[0]):
439
+ inside_idx.append(np.flatnonzero(codes == cluster))
440
+ outside_idx.append(np.flatnonzero(codes != cluster))
441
+ for cluster in small:
442
+ inside_at = inside_idx[cluster]
443
+ outside_at = outside_idx[cluster]
444
+ for local_gene in local:
445
+ column = block[int(local_gene)]
446
+ inside = column[inside_at]
447
+ outside = column[outside_at]
448
+ gene = offset + int(local_gene)
449
+ if (
450
+ inside.size
451
+ and outside.size
452
+ and inside.min() == inside.max()
453
+ and outside.min() == outside.max()
454
+ and inside[0] == outside[0]
455
+ ):
456
+ pvalues[cluster, gene] = 1.0
457
+ continue
458
+ result = stats.mannwhitneyu(inside, outside, alternative="two-sided")
459
+ pvalues[cluster, gene] = float(result.pvalue)
460
+
461
+
462
+ def positive_contribution(gene: GeneEvidence) -> float:
463
+ """A marker counts when its level is higher inside, even if other cells also express it."""
464
+ if gene.logfc <= 0:
465
+ return 0.0
466
+ return gene.logfc
467
+
468
+
469
+ def undeclared_enriched(
470
+ adata: AnnData,
471
+ mask: np.ndarray,
472
+ declared: set[str],
473
+ *,
474
+ limit: int = 10,
475
+ min_logfc: float = 1.0,
476
+ ) -> list[str]:
477
+ """Genes enriched in the cluster that no hypothesis declared.
478
+
479
+ Ranked by log fold change. These names are for inspection only.
480
+ """
481
+ return undeclared_groups(
482
+ adata,
483
+ [np.asarray(mask, dtype=bool)],
484
+ declared,
485
+ limit=limit,
486
+ min_logfc=min_logfc,
487
+ )[0]
488
+
489
+
490
+ def undeclared_groups(
491
+ adata: AnnData,
492
+ masks: list[np.ndarray],
493
+ declared: set[str],
494
+ *,
495
+ limit: int = 10,
496
+ min_logfc: float = 1.0,
497
+ ) -> list[list[str]]:
498
+ """Top undeclared genes for every mask from one expm1 pass."""
499
+ if not masks:
500
+ return []
501
+ n_obs = adata.n_obs
502
+ codes = np.full(n_obs, -1, dtype=np.int32)
503
+ for cluster, mask in enumerate(masks):
504
+ mask = np.asarray(mask, dtype=bool)
505
+ count = int(mask.sum())
506
+ if count == 0 or count == n_obs:
507
+ raise ValueError(f"cluster mask {cluster} has no contrast")
508
+ codes[mask] = np.int32(cluster)
509
+ sums, totals = _group_linear_sums(adata.X, codes, len(masks))
510
+ n_in = np.bincount(codes[codes >= 0], minlength=len(masks)).astype(np.float64)
511
+ n_out = n_obs - n_in
512
+ mean_in = sums / n_in[:, None]
513
+ mean_out = (totals - sums) / n_out[:, None]
514
+ changes = np.log2((mean_in + 1.0) / (mean_out + 1.0))
515
+ names = np.asarray(adata.var_names.astype(str))
516
+ blocked = np.fromiter((name in declared for name in names), dtype=bool, count=names.size)
517
+ chosen: list[list[str]] = []
518
+ for cluster in range(len(masks)):
519
+ keep = changes[cluster] >= min_logfc
520
+ keep[blocked] = False
521
+ order = np.argsort(-changes[cluster])
522
+ genes: list[str] = []
523
+ for index in order:
524
+ if not keep[index]:
525
+ continue
526
+ genes.append(str(names[index]))
527
+ if len(genes) >= limit:
528
+ break
529
+ chosen.append(genes)
530
+ return chosen
531
+
532
+
533
+ def _group_linear_sums(matrix, codes: np.ndarray, n_clusters: int) -> tuple[np.ndarray, np.ndarray]:
534
+ n_vars = int(matrix.shape[1])
535
+ if sparse.issparse(matrix):
536
+ stored = matrix.tocsr()
537
+ data = np.expm1(np.asarray(stored.data, dtype=np.float64))
538
+ totals = np.bincount(stored.indices, weights=data, minlength=n_vars).astype(np.float64)
539
+ sums = _sparse_cluster_sums(stored.indptr, stored.indices, data, codes, n_clusters, n_vars)
540
+ return sums, totals
541
+ # Column blocks keep the float64 copy near _MARKER_CHUNK_BYTES.
542
+ n_obs = int(matrix.shape[0])
543
+ step = max(1, _MARKER_CHUNK_BYTES // (8 * max(n_obs, 1)))
544
+ rows = [np.flatnonzero(codes == cluster) for cluster in range(n_clusters)]
545
+ sums = np.zeros((n_clusters, n_vars), dtype=np.float64)
546
+ totals = np.zeros(n_vars, dtype=np.float64)
547
+ for start in range(0, n_vars, step):
548
+ stop = min(n_vars, start + step)
549
+ linear = np.expm1(np.asarray(matrix[:, start:stop], dtype=np.float64))
550
+ totals[start:stop] = linear.sum(axis=0)
551
+ for cluster, index in enumerate(rows):
552
+ sums[cluster, start:stop] = linear[index].sum(axis=0)
553
+ return sums, totals
554
+
555
+
556
+ def _sparse_cluster_sums(indptr, indices, data, codes, n_clusters: int, n_vars: int) -> np.ndarray:
557
+ n_obs = int(codes.shape[0])
558
+ workers = min(_cpus(), max(n_obs, 1))
559
+ partial_bytes = workers * n_clusters * n_vars * 8
560
+ if workers == 1 or n_obs < 2 or partial_bytes > _PARTIAL_SUM_BYTES:
561
+ return _sum_row_range(indptr, indices, data, codes, n_clusters, n_vars, 0, n_obs)
562
+ _sum_row_range(indptr, indices, data, codes, n_clusters, n_vars, 0, 0)
563
+ edges = [int(i * n_obs / workers) for i in range(workers + 1)]
564
+ with ThreadPoolExecutor(max_workers=workers) as pool:
565
+ parts = list(
566
+ pool.map(
567
+ lambda start, stop: _sum_row_range(
568
+ indptr, indices, data, codes, n_clusters, n_vars, start, stop
569
+ ),
570
+ edges[:-1],
571
+ edges[1:],
572
+ )
573
+ )
574
+ sums = parts[0]
575
+ for part in parts[1:]:
576
+ sums += part
577
+ return sums
578
+
579
+
580
+ @numba.njit(cache=True, nogil=True)
581
+ def _sum_row_range(indptr, indices, data, codes, n_clusters, n_vars, start, stop):
582
+ sums = np.zeros((n_clusters, n_vars), dtype=np.float64)
583
+ for row in range(start, stop):
584
+ cluster = codes[row]
585
+ if cluster < 0:
586
+ continue
587
+ for pointer in range(indptr[row], indptr[row + 1]):
588
+ sums[cluster, indices[pointer]] += data[pointer]
589
+ return sums
590
+
591
+
592
+ def negative_marker_conflicts(pct_in: float, pct_out: float, logfc: float) -> bool:
593
+ """True when a negative marker is relatively high inside the cluster.
594
+
595
+ Expression in other cell types does not cancel that. ``pct_out`` stays in
596
+ the signature so callers can pass the measured fraction.
597
+ """
598
+ del pct_out
599
+ return pct_in >= NEGATIVE_PRESENT_PCT and logfc >= NEGATIVE_MIN_LOGFC
600
+
601
+
602
+ def negative_penalty(gene: GeneEvidence) -> float:
603
+ """Enriched negative markers penalize. Absence and ambient RNA do not."""
604
+ if not negative_marker_conflicts(gene.pct_in, gene.pct_out, gene.logfc):
605
+ return 0.0
606
+ return gene.logfc
@@ -0,0 +1,28 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Cell Ontology lineage used when a written name is chosen.
5
+
6
+ The snapshot is packaged data. popV is not imported and is not a dependency.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from .cl_dag import CellOntology, OntologyCycleError, load_ontology
12
+ from .ontology_map import OntologyMap, load_map
13
+ from .relations import relation
14
+
15
+
16
+ def ontology_dir() -> Path:
17
+ return Path(__file__).resolve().parents[1] / "ontology"
18
+
19
+
20
+ __all__ = [
21
+ "CellOntology",
22
+ "OntologyCycleError",
23
+ "OntologyMap",
24
+ "load_map",
25
+ "load_ontology",
26
+ "ontology_dir",
27
+ "relation",
28
+ ]