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.
- annotuneagent/__init__.py +68 -0
- annotuneagent/__main__.py +6 -0
- annotuneagent/_version.py +19 -0
- annotuneagent/annotate.py +93 -0
- annotuneagent/arbitrate.py +141 -0
- annotuneagent/cli.py +366 -0
- annotuneagent/decide.py +725 -0
- annotuneagent/evaluate.py +121 -0
- annotuneagent/evidence.py +606 -0
- annotuneagent/granularity/__init__.py +28 -0
- annotuneagent/granularity/cl_dag.py +179 -0
- annotuneagent/granularity/ontology_map.py +108 -0
- annotuneagent/granularity/relations.py +96 -0
- annotuneagent/hypotheses.py +38 -0
- annotuneagent/local_lora.py +706 -0
- annotuneagent/marker_plot.py +172 -0
- annotuneagent/markers.py +206 -0
- annotuneagent/markers.tsv +378 -0
- annotuneagent/mltools.py +128 -0
- annotuneagent/ontology/cl_isa.json +1 -0
- annotuneagent/ontology/granularity_policy.yaml +8 -0
- annotuneagent/ontology/ontology_map.jsonl +198 -0
- annotuneagent/prepare.py +1385 -0
- annotuneagent/py.typed +0 -0
- annotuneagent/read.py +76 -0
- annotuneagent/tokenizer/LICENSE +202 -0
- annotuneagent/tokenizer/merges.txt +247587 -0
- annotuneagent/tokenizer/tokenizer.json +495920 -0
- annotuneagent/tokenizer/tokenizer_config.json +305 -0
- annotuneagent/tokenizer/vocab.json +248046 -0
- annotuneagent/votes.py +83 -0
- annotuneagent/workflow.py +2037 -0
- annotuneagent-0.1.0.dist-info/METADATA +208 -0
- annotuneagent-0.1.0.dist-info/RECORD +38 -0
- annotuneagent-0.1.0.dist-info/WHEEL +5 -0
- annotuneagent-0.1.0.dist-info/entry_points.txt +2 -0
- annotuneagent-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
]
|