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
annotuneagent/decide.py
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Name and ML results for one cluster.
|
|
5
|
+
|
|
6
|
+
The name model copies a cell-type name from the marker dictionary. The ML
|
|
7
|
+
model reads measured differential expression and candidate marker panels, and
|
|
8
|
+
copies a name from those candidates. Both return a result when a model chain
|
|
9
|
+
is supplied. The workflow judgment reads those results together with the
|
|
10
|
+
marker score and writes the title.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import random
|
|
18
|
+
import urllib.request
|
|
19
|
+
import weakref
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from scipy import sparse
|
|
24
|
+
|
|
25
|
+
from .evidence import NEGATIVE_PRESENT_PCT
|
|
26
|
+
from .hypotheses import Hypothesis
|
|
27
|
+
|
|
28
|
+
# Each task has its own prompt.
|
|
29
|
+
SYSTEM_NAME = (
|
|
30
|
+
"You name one cell cluster. dictionary_hits is not in rank order. "
|
|
31
|
+
"negative lists genes that type should not express when they are high in this cluster. "
|
|
32
|
+
"Reply with JSON only. "
|
|
33
|
+
'{"action": "retest", "names": ["dictionary name"]} '
|
|
34
|
+
"Name a type only when at least two of its markers are near the top of the gene list "
|
|
35
|
+
"and its negative list is empty. If two types list the same genes and only one has an empty "
|
|
36
|
+
"negative list, name that type only when it is the one supported near the top. "
|
|
37
|
+
"Copy the name from dictionary_hits."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
SYSTEM_DECIDE = SYSTEM_NAME
|
|
41
|
+
|
|
42
|
+
SYSTEM_ML = (
|
|
43
|
+
"You review one cell cluster using measured differential expression and candidate marker panels. "
|
|
44
|
+
"genes is the full gene list retained by the cluster cache. marker_measurements contains "
|
|
45
|
+
"every positive and negative marker for the listed candidates that was measured; null means "
|
|
46
|
+
"the cache has no measurement, not zero expression. The columns are gene, logfc, pct_in, pct_out. "
|
|
47
|
+
"Each candidate lists all its positive and negative marker names and statistics in the order "
|
|
48
|
+
"specified by candidate_stat_columns. Use expression direction, detection rates, competing "
|
|
49
|
+
'candidates, species and tissue. Reply with JSON only: {"names":["candidate name"]} '
|
|
50
|
+
'or {"action":"escalate"}. Copy a name from candidates; do not invent one.'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
ML_MEASUREMENT_COLUMNS = ["gene", "logfc", "pct_in", "pct_out"]
|
|
54
|
+
ML_STAT_COLUMNS = [
|
|
55
|
+
"n_positive",
|
|
56
|
+
"n_positive_supported",
|
|
57
|
+
"n_positive_downregulated",
|
|
58
|
+
"n_positive_detected",
|
|
59
|
+
"n_negative",
|
|
60
|
+
"n_negative_high",
|
|
61
|
+
"n_negative_unmeasured",
|
|
62
|
+
"mean_positive_logfc",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
_ACTIONS = frozenset({"adopt_subcluster", "retest", "refuse", "escalate"})
|
|
66
|
+
|
|
67
|
+
# A marker counts as support only above this log2 fold change.
|
|
68
|
+
SUPPORT_LOGFC = 0.5
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Decision:
|
|
73
|
+
action: str
|
|
74
|
+
names: tuple[str, ...] = ()
|
|
75
|
+
markers: tuple[Hypothesis, ...] = ()
|
|
76
|
+
|
|
77
|
+
def as_dict(self) -> dict[str, object]:
|
|
78
|
+
return {
|
|
79
|
+
"action": self.action,
|
|
80
|
+
"names": list(self.names),
|
|
81
|
+
"markers": [
|
|
82
|
+
{
|
|
83
|
+
"name": item.name,
|
|
84
|
+
"positive": list(item.positive),
|
|
85
|
+
"negative": list(item.negative),
|
|
86
|
+
}
|
|
87
|
+
for item in self.markers
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _gene_list(value: object) -> tuple[str, ...]:
|
|
93
|
+
if value is None:
|
|
94
|
+
return ()
|
|
95
|
+
if not isinstance(value, list):
|
|
96
|
+
raise ValueError("marker genes must be a list")
|
|
97
|
+
return tuple(str(gene) for gene in value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_decision(text: str, allowed: set[str] | None = None) -> Decision:
|
|
101
|
+
"""Parse a model reply. Names outside ``allowed`` are dropped.
|
|
102
|
+
|
|
103
|
+
An empty ``allowed`` set drops every name. ``None`` keeps them all.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
payload = json.loads(text)
|
|
107
|
+
except json.JSONDecodeError as exc:
|
|
108
|
+
raise ValueError("decision is not JSON") from exc
|
|
109
|
+
if not isinstance(payload, dict):
|
|
110
|
+
raise ValueError("decision must be an object")
|
|
111
|
+
action = payload.get("action")
|
|
112
|
+
if action not in _ACTIONS:
|
|
113
|
+
raise ValueError(f"unknown action {action!r}")
|
|
114
|
+
raw_names = payload.get("names") or []
|
|
115
|
+
raw_markers = payload.get("markers") or []
|
|
116
|
+
if not isinstance(raw_names, list) or not isinstance(raw_markers, list):
|
|
117
|
+
raise ValueError("names and markers must be lists")
|
|
118
|
+
names = tuple(str(name) for name in raw_names if allowed is None or str(name) in allowed)
|
|
119
|
+
markers: list[Hypothesis] = []
|
|
120
|
+
for item in raw_markers:
|
|
121
|
+
if not isinstance(item, dict):
|
|
122
|
+
raise ValueError("marker entry must be an object")
|
|
123
|
+
positive = _gene_list(item.get("positive"))
|
|
124
|
+
negative = _gene_list(item.get("negative"))
|
|
125
|
+
if not positive:
|
|
126
|
+
continue
|
|
127
|
+
name = item.get("name")
|
|
128
|
+
if not isinstance(name, str) or not name:
|
|
129
|
+
raise ValueError("marker entry needs a name")
|
|
130
|
+
markers.append(Hypothesis(name, positive=positive, negative=negative))
|
|
131
|
+
if action == "retest" and not names and not markers:
|
|
132
|
+
# A reply that only names types outside the hit list abstains.
|
|
133
|
+
if allowed is not None and raw_names:
|
|
134
|
+
return Decision("escalate")
|
|
135
|
+
raise ValueError("retest needs a dictionary name or markers")
|
|
136
|
+
if action != "retest":
|
|
137
|
+
names = ()
|
|
138
|
+
markers = ()
|
|
139
|
+
return Decision(action, names, tuple(markers))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class MlResult:
|
|
144
|
+
"""ML adapter result. ``named`` copied a candidate. ``escalate`` abstains. ``failed`` is not a prediction."""
|
|
145
|
+
|
|
146
|
+
status: str
|
|
147
|
+
names: tuple[str, ...] = ()
|
|
148
|
+
error: str | None = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_ml(text: str, allowed: set[str] | None = None) -> MlResult:
|
|
152
|
+
"""Parse an ML reply. A ``names`` list is success. ``action=escalate`` abstains.
|
|
153
|
+
|
|
154
|
+
A reply that is not a names list or an escalate action is rejected.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
payload = json.loads(text)
|
|
158
|
+
except json.JSONDecodeError as exc:
|
|
159
|
+
raise ValueError("ml reply is not JSON") from exc
|
|
160
|
+
if not isinstance(payload, dict):
|
|
161
|
+
raise ValueError("ml reply must be an object")
|
|
162
|
+
if payload.get("action") == "escalate":
|
|
163
|
+
return MlResult("escalate")
|
|
164
|
+
raw_names = payload.get("names")
|
|
165
|
+
if not isinstance(raw_names, list) or not raw_names:
|
|
166
|
+
raise ValueError("ml success needs a names list")
|
|
167
|
+
names = tuple(str(name) for name in raw_names if allowed is None or str(name) in allowed)
|
|
168
|
+
if not names:
|
|
169
|
+
if allowed is not None:
|
|
170
|
+
return MlResult("escalate")
|
|
171
|
+
raise ValueError("ml names are not in the candidate list")
|
|
172
|
+
return MlResult("named", names)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def propose_hypotheses(
|
|
176
|
+
catalog: list[Hypothesis],
|
|
177
|
+
enriched: list[str],
|
|
178
|
+
used: set[str],
|
|
179
|
+
*,
|
|
180
|
+
limit: int = 5,
|
|
181
|
+
) -> list[Hypothesis]:
|
|
182
|
+
"""Dictionary types whose positive markers overlap undeclared enriched genes."""
|
|
183
|
+
genes = set(enriched)
|
|
184
|
+
ranked: list[tuple[int, str, Hypothesis]] = []
|
|
185
|
+
for item in catalog:
|
|
186
|
+
if item.name in used:
|
|
187
|
+
continue
|
|
188
|
+
overlap = len(set(item.positive) & genes)
|
|
189
|
+
if overlap:
|
|
190
|
+
ranked.append((overlap, item.name, item))
|
|
191
|
+
ranked.sort(key=lambda row: (-row[0], row[1]))
|
|
192
|
+
return [item for _, _, item in ranked[:limit]]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# The weak reference prevents a later AnnData object from reusing a dead one's id.
|
|
196
|
+
_WILCOXON: dict[int, tuple[weakref.ReferenceType, tuple, dict, dict]] = {}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _gene_name(value: object) -> str:
|
|
200
|
+
if isinstance(value, bytes):
|
|
201
|
+
return value.decode()
|
|
202
|
+
return str(value)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _buffer_digest(values) -> bytes:
|
|
206
|
+
arr = np.asarray(values)
|
|
207
|
+
digest = hashlib.blake2b(digest_size=8)
|
|
208
|
+
digest.update(arr.dtype.str.encode())
|
|
209
|
+
digest.update(np.asarray(arr.shape, dtype=np.int64).tobytes())
|
|
210
|
+
if arr.size == 0 or arr.dtype.kind == "O":
|
|
211
|
+
return digest.digest()
|
|
212
|
+
flat = arr.reshape(-1)
|
|
213
|
+
if not flat.flags.c_contiguous:
|
|
214
|
+
flat = np.ascontiguousarray(flat)
|
|
215
|
+
digest.update(memoryview(flat).cast("B"))
|
|
216
|
+
return digest.digest()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _matrix_token(matrix) -> tuple:
|
|
220
|
+
"""Content token so an in-place edit of ``X`` does not reuse an old table."""
|
|
221
|
+
if sparse.issparse(matrix):
|
|
222
|
+
return (
|
|
223
|
+
"sparse",
|
|
224
|
+
tuple(matrix.shape),
|
|
225
|
+
str(matrix.format),
|
|
226
|
+
int(matrix.nnz),
|
|
227
|
+
_buffer_digest(matrix.data),
|
|
228
|
+
_buffer_digest(matrix.indices),
|
|
229
|
+
_buffer_digest(matrix.indptr),
|
|
230
|
+
)
|
|
231
|
+
values = np.asarray(matrix)
|
|
232
|
+
return ("dense", tuple(values.shape), str(values.dtype), _buffer_digest(values))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def wilcoxon_order(adata, cluster_key: str) -> dict[str, list[tuple[str, float]]]:
|
|
236
|
+
"""Positive genes in ``rank_genes_groups`` score order.
|
|
237
|
+
|
|
238
|
+
Writes ``uns['rank_genes_groups']``. Repeated calls reuse the table when
|
|
239
|
+
the cluster labels and the values in ``X`` are unchanged.
|
|
240
|
+
"""
|
|
241
|
+
import scanpy as sc
|
|
242
|
+
|
|
243
|
+
labels = adata.obs[cluster_key].astype(str)
|
|
244
|
+
counts = labels.value_counts()
|
|
245
|
+
matrix = adata.X
|
|
246
|
+
signature = (cluster_key, tuple(labels), id(matrix), _matrix_token(matrix))
|
|
247
|
+
cache_key = id(adata)
|
|
248
|
+
cached = _WILCOXON.get(cache_key)
|
|
249
|
+
if cached is not None and cached[0]() is adata and cached[1] == signature:
|
|
250
|
+
adata.uns["rank_genes_groups"] = cached[3]
|
|
251
|
+
return cached[2]
|
|
252
|
+
if adata.obs[cluster_key].nunique() < 2:
|
|
253
|
+
raise ValueError("wilcoxon needs at least two clusters")
|
|
254
|
+
import warnings
|
|
255
|
+
|
|
256
|
+
import pandas as pd
|
|
257
|
+
|
|
258
|
+
# Scanpy inserts one column per cluster into a single frame.
|
|
259
|
+
with warnings.catch_warnings():
|
|
260
|
+
warnings.simplefilter("ignore", pd.errors.PerformanceWarning)
|
|
261
|
+
sc.tl.rank_genes_groups(
|
|
262
|
+
adata,
|
|
263
|
+
groupby=cluster_key,
|
|
264
|
+
reference="rest",
|
|
265
|
+
method="wilcoxon",
|
|
266
|
+
use_raw=False,
|
|
267
|
+
pts=True,
|
|
268
|
+
)
|
|
269
|
+
result = adata.uns["rank_genes_groups"]
|
|
270
|
+
tables: dict[str, list[tuple[str, float]]] = {}
|
|
271
|
+
for cluster in counts.index:
|
|
272
|
+
key = str(cluster)
|
|
273
|
+
try:
|
|
274
|
+
order = [_gene_name(gene) for gene in result["names"][key]]
|
|
275
|
+
changes = [float(value) for value in result["logfoldchanges"][key]]
|
|
276
|
+
except (KeyError, IndexError, TypeError, ValueError):
|
|
277
|
+
continue
|
|
278
|
+
rows: list[tuple[str, float]] = []
|
|
279
|
+
seen: set[str] = set()
|
|
280
|
+
for gene, change in zip(order, changes, strict=True):
|
|
281
|
+
if gene in seen or change <= 0:
|
|
282
|
+
continue
|
|
283
|
+
seen.add(gene)
|
|
284
|
+
rows.append((gene, change))
|
|
285
|
+
tables[key] = rows
|
|
286
|
+
_WILCOXON[cache_key] = (
|
|
287
|
+
weakref.ref(adata, lambda _ref: _WILCOXON.pop(cache_key, None)),
|
|
288
|
+
signature,
|
|
289
|
+
tables,
|
|
290
|
+
result,
|
|
291
|
+
)
|
|
292
|
+
return tables
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _detection(adata, cluster: str) -> dict[str, float]:
|
|
296
|
+
result = adata.uns.get("rank_genes_groups")
|
|
297
|
+
if not result or "pts" not in result:
|
|
298
|
+
return {}
|
|
299
|
+
frame = result["pts"]
|
|
300
|
+
if cluster not in frame.columns:
|
|
301
|
+
return {}
|
|
302
|
+
return {_gene_name(gene): float(frame.loc[gene, cluster]) for gene in frame.index}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def ranked_payload(
|
|
306
|
+
order: list[tuple[str, float]],
|
|
307
|
+
pct: dict[str, float],
|
|
308
|
+
hypotheses: list[Hypothesis],
|
|
309
|
+
*,
|
|
310
|
+
species: str = "",
|
|
311
|
+
tissue: str = "",
|
|
312
|
+
stage: str = "",
|
|
313
|
+
previous: dict[str, object] | None = None,
|
|
314
|
+
source: str | None = None,
|
|
315
|
+
donor: str | None = None,
|
|
316
|
+
cluster: str | None = None,
|
|
317
|
+
) -> dict[str, object]:
|
|
318
|
+
"""Action and name user object from a Wilcoxon-ordered gene list."""
|
|
319
|
+
by_gene = dict(order)
|
|
320
|
+
genes: list[dict[str, object]] = [
|
|
321
|
+
{"gene": gene, "logfc": round(change, 1)} for gene, change in order[:25]
|
|
322
|
+
]
|
|
323
|
+
scored: list[tuple[float, str, list[str]]] = []
|
|
324
|
+
for item in hypotheses:
|
|
325
|
+
chosen: list[str] = []
|
|
326
|
+
total = 0.0
|
|
327
|
+
for gene in item.positive:
|
|
328
|
+
value = by_gene.get(gene)
|
|
329
|
+
# Wilcoxon logFC explodes when a handful of cells carry the gene.
|
|
330
|
+
# The same detection cut as a negative marker keeps those genes
|
|
331
|
+
# out of the names the model is allowed to copy.
|
|
332
|
+
if value is None or value <= SUPPORT_LOGFC:
|
|
333
|
+
continue
|
|
334
|
+
if pct.get(gene, 0.0) < NEGATIVE_PRESENT_PCT:
|
|
335
|
+
continue
|
|
336
|
+
chosen.append(gene)
|
|
337
|
+
total += value
|
|
338
|
+
if chosen:
|
|
339
|
+
scored.append((total, item.name, chosen, item))
|
|
340
|
+
scored.sort(key=lambda row: (-row[0], row[1]))
|
|
341
|
+
hits = []
|
|
342
|
+
for _, name, chosen, item in scored[:10]:
|
|
343
|
+
forbidden = [
|
|
344
|
+
gene
|
|
345
|
+
for gene in item.negative
|
|
346
|
+
if by_gene.get(gene, 0.0) >= 1 and pct.get(gene, 0.0) >= 0.25
|
|
347
|
+
]
|
|
348
|
+
hits.append({"name": name, "genes": chosen, "negative": forbidden})
|
|
349
|
+
have = {item["gene"] for item in genes}
|
|
350
|
+
for hit in hits:
|
|
351
|
+
for gene in [*hit["genes"], *hit["negative"]]:
|
|
352
|
+
if gene in have or gene not in by_gene:
|
|
353
|
+
continue
|
|
354
|
+
genes.append({"gene": gene, "logfc": round(by_gene[gene], 1)})
|
|
355
|
+
have.add(gene)
|
|
356
|
+
if source is not None and cluster is not None:
|
|
357
|
+
random.Random(f"{source}|{donor}|{cluster}").shuffle(hits)
|
|
358
|
+
payload: dict[str, object] = {
|
|
359
|
+
"species": species,
|
|
360
|
+
"tissue": tissue,
|
|
361
|
+
"stage": stage,
|
|
362
|
+
"genes": genes,
|
|
363
|
+
"dictionary_hits": hits,
|
|
364
|
+
}
|
|
365
|
+
if previous is not None:
|
|
366
|
+
payload["previous"] = previous
|
|
367
|
+
return payload
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cluster_payload(
|
|
371
|
+
adata,
|
|
372
|
+
cluster_key: str,
|
|
373
|
+
cluster: str,
|
|
374
|
+
hypotheses: list[Hypothesis],
|
|
375
|
+
*,
|
|
376
|
+
species: str = "",
|
|
377
|
+
tissue: str = "",
|
|
378
|
+
stage: str = "",
|
|
379
|
+
previous: dict[str, object] | None = None,
|
|
380
|
+
source: str | None = None,
|
|
381
|
+
donor: str | None = None,
|
|
382
|
+
tables: dict[str, list[tuple[str, float]]] | None = None,
|
|
383
|
+
) -> dict[str, object]:
|
|
384
|
+
"""User message: top genes plus dictionary hits.
|
|
385
|
+
|
|
386
|
+
Gene order is the Wilcoxon score order. Marker genes with logfc>0 that
|
|
387
|
+
fall outside the top 25 are appended. A caller that builds many clusters
|
|
388
|
+
passes the ``wilcoxon_order`` result as ``tables``, so the matrix is not
|
|
389
|
+
checked again for every cluster.
|
|
390
|
+
"""
|
|
391
|
+
if tables is None:
|
|
392
|
+
tables = wilcoxon_order(adata, cluster_key)
|
|
393
|
+
order = tables.get(str(cluster), [])
|
|
394
|
+
return ranked_payload(
|
|
395
|
+
order,
|
|
396
|
+
_detection(adata, str(cluster)),
|
|
397
|
+
hypotheses,
|
|
398
|
+
species=species,
|
|
399
|
+
tissue=tissue,
|
|
400
|
+
stage=stage,
|
|
401
|
+
previous=previous,
|
|
402
|
+
source=source,
|
|
403
|
+
donor=donor,
|
|
404
|
+
cluster=str(cluster),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def wilcoxon_payload(
|
|
409
|
+
adata,
|
|
410
|
+
cluster_key: str,
|
|
411
|
+
cluster: str,
|
|
412
|
+
hypotheses: list[Hypothesis],
|
|
413
|
+
*,
|
|
414
|
+
species: str = "",
|
|
415
|
+
tissue: str = "",
|
|
416
|
+
stage: str = "",
|
|
417
|
+
previous: dict[str, object] | None = None,
|
|
418
|
+
) -> dict[str, object]:
|
|
419
|
+
"""Training-order payload. ``cluster_payload`` uses this ranking."""
|
|
420
|
+
return cluster_payload(
|
|
421
|
+
adata,
|
|
422
|
+
cluster_key,
|
|
423
|
+
cluster,
|
|
424
|
+
hypotheses,
|
|
425
|
+
species=species,
|
|
426
|
+
tissue=tissue,
|
|
427
|
+
stage=stage,
|
|
428
|
+
previous=previous,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def rank_measurements(adata, cluster: str) -> tuple[list[dict], dict[str, dict]]:
|
|
433
|
+
"""Positive genes in Wilcoxon order, plus a measurement for every ranked gene.
|
|
434
|
+
|
|
435
|
+
``logfc`` is scanpy ``logfoldchanges``. It is not replaced by a detection-aware score.
|
|
436
|
+
"""
|
|
437
|
+
result = adata.uns.get("rank_genes_groups")
|
|
438
|
+
if not result:
|
|
439
|
+
raise ValueError("rank_genes_groups is missing")
|
|
440
|
+
key = str(cluster)
|
|
441
|
+
names = [_gene_name(gene) for gene in result["names"][key]]
|
|
442
|
+
changes = [float(value) for value in result["logfoldchanges"][key]]
|
|
443
|
+
pct_in = detection_fractions(result["pts"], key, names)
|
|
444
|
+
pct_out = detection_fractions(result["pts_rest"], key, names)
|
|
445
|
+
measurements: dict[str, dict] = {}
|
|
446
|
+
positive: list[dict] = []
|
|
447
|
+
seen: set[str] = set()
|
|
448
|
+
for gene, change, inside, outside in zip(names, changes, pct_in, pct_out, strict=True):
|
|
449
|
+
row = {"gene": gene, "logfc": change, "pct_in": float(inside), "pct_out": float(outside)}
|
|
450
|
+
measurements[gene] = row
|
|
451
|
+
if change > 0 and gene not in seen:
|
|
452
|
+
seen.add(gene)
|
|
453
|
+
positive.append(row)
|
|
454
|
+
return positive, measurements
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def detection_fractions(frame, cluster: str, names: list[str]) -> np.ndarray:
|
|
458
|
+
"""Detection fraction of each gene in ``names``. A gene or cluster the table lacks is 0."""
|
|
459
|
+
if cluster not in frame.columns:
|
|
460
|
+
return np.zeros(len(names))
|
|
461
|
+
return frame[cluster].reindex(names).fillna(0.0).to_numpy(dtype=float)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _rounded_measurement(gene: str, row: dict) -> dict:
|
|
465
|
+
return {
|
|
466
|
+
"gene": gene,
|
|
467
|
+
"logfc": round(float(row["logfc"]), 3),
|
|
468
|
+
"pct_in": round(float(row["pct_in"]), 3),
|
|
469
|
+
"pct_out": round(float(row["pct_out"]), 3),
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# The ML message keeps this many leading positive genes, then every other
|
|
474
|
+
# positive dictionary marker.
|
|
475
|
+
CACHE_HEAD = 24
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def retained_measurements(
|
|
479
|
+
positive: list[dict],
|
|
480
|
+
hypotheses: list[Hypothesis],
|
|
481
|
+
measurements: dict[str, dict],
|
|
482
|
+
) -> list[dict]:
|
|
483
|
+
"""Gene rows kept for the ML message, rounded to 3 decimals."""
|
|
484
|
+
rows: list[dict] = []
|
|
485
|
+
have: set[str] = set()
|
|
486
|
+
for row in positive:
|
|
487
|
+
if float(row["logfc"]) <= 0:
|
|
488
|
+
continue
|
|
489
|
+
gene = str(row["gene"])
|
|
490
|
+
if gene in have:
|
|
491
|
+
continue
|
|
492
|
+
rows.append(_rounded_measurement(gene, row))
|
|
493
|
+
have.add(gene)
|
|
494
|
+
if len(rows) >= CACHE_HEAD:
|
|
495
|
+
break
|
|
496
|
+
for item in hypotheses:
|
|
497
|
+
for gene in item.positive:
|
|
498
|
+
if gene in have:
|
|
499
|
+
continue
|
|
500
|
+
src = measurements.get(gene)
|
|
501
|
+
if src is None or float(src["logfc"]) <= 0:
|
|
502
|
+
continue
|
|
503
|
+
rows.append(_rounded_measurement(gene, src))
|
|
504
|
+
have.add(gene)
|
|
505
|
+
return rows
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def ml_user_from_hits(
|
|
509
|
+
action_payload: dict[str, object],
|
|
510
|
+
hypotheses: list[Hypothesis],
|
|
511
|
+
measurements: dict[str, dict],
|
|
512
|
+
positive: list[dict],
|
|
513
|
+
) -> dict[str, object]:
|
|
514
|
+
"""ML user message. No marker field.
|
|
515
|
+
|
|
516
|
+
``positive`` is the Wilcoxon-ordered positive gene list. The emitted gene
|
|
517
|
+
list is the retained list, not that whole list. Candidate order follows
|
|
518
|
+
``dictionary_hits``. Statistics use the unrounded measurement of each
|
|
519
|
+
positive and negative marker.
|
|
520
|
+
"""
|
|
521
|
+
by_name = {item.name: item for item in hypotheses}
|
|
522
|
+
genes = retained_measurements(positive, hypotheses, measurements)
|
|
523
|
+
precise = {str(row["gene"]): row for row in genes}
|
|
524
|
+
for item in hypotheses:
|
|
525
|
+
for gene in (*item.positive, *item.negative):
|
|
526
|
+
src = measurements.get(gene)
|
|
527
|
+
if src is None:
|
|
528
|
+
continue
|
|
529
|
+
precise[gene] = {
|
|
530
|
+
"gene": gene,
|
|
531
|
+
"logfc": float(src["logfc"]),
|
|
532
|
+
"pct_in": float(src["pct_in"]),
|
|
533
|
+
"pct_out": float(src["pct_out"]),
|
|
534
|
+
}
|
|
535
|
+
panels = []
|
|
536
|
+
for hit in action_payload.get("dictionary_hits") or []:
|
|
537
|
+
item = by_name.get(str(hit["name"]))
|
|
538
|
+
if item is None:
|
|
539
|
+
continue
|
|
540
|
+
measured = [gene for gene in item.positive if gene in measurements]
|
|
541
|
+
panels.append((item.name, measured, list(item.negative)))
|
|
542
|
+
return ml_user(
|
|
543
|
+
str(action_payload.get("species") or ""),
|
|
544
|
+
str(action_payload.get("tissue") or ""),
|
|
545
|
+
str(action_payload.get("stage") or ""),
|
|
546
|
+
genes,
|
|
547
|
+
panels,
|
|
548
|
+
precise,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def payload_agreement(runtime: dict[str, object], wilcoxon: dict[str, object]) -> dict[str, object]:
|
|
553
|
+
"""Overlap of the two rankings. A mismatch is a contract gap, not a model error."""
|
|
554
|
+
|
|
555
|
+
def ranked(payload: dict[str, object]) -> list[str]:
|
|
556
|
+
return [str(item["gene"]) for item in list(payload["genes"])[:25]]
|
|
557
|
+
|
|
558
|
+
def hits(payload: dict[str, object]) -> list[tuple[str, tuple[str, ...]]]:
|
|
559
|
+
return [
|
|
560
|
+
(str(item["name"]), tuple(str(gene) for gene in item["genes"]))
|
|
561
|
+
for item in list(payload["dictionary_hits"])
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
left = ranked(runtime)
|
|
565
|
+
right = ranked(wilcoxon)
|
|
566
|
+
left_hits = hits(runtime)
|
|
567
|
+
right_hits = hits(wilcoxon)
|
|
568
|
+
return {
|
|
569
|
+
"top25_shared": len(set(left) & set(right)),
|
|
570
|
+
"top25_runtime": len(left),
|
|
571
|
+
"top25_wilcoxon": len(right),
|
|
572
|
+
"same_top25_order": left == right,
|
|
573
|
+
"hit_names_runtime": [name for name, _ in left_hits],
|
|
574
|
+
"hit_names_wilcoxon": [name for name, _ in right_hits],
|
|
575
|
+
"same_hit_names": [name for name, _ in left_hits] == [name for name, _ in right_hits],
|
|
576
|
+
"same_hit_genes": left_hits == right_hits,
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def user_text(task: str, payload: dict | str) -> str:
|
|
581
|
+
"""Serialize one user message.
|
|
582
|
+
|
|
583
|
+
The name task uses spaced JSON. ML uses compact JSON.
|
|
584
|
+
A string is sent unchanged.
|
|
585
|
+
"""
|
|
586
|
+
if isinstance(payload, str):
|
|
587
|
+
return payload
|
|
588
|
+
separators = (",", ":") if task == "ml" else (", ", ": ")
|
|
589
|
+
return json.dumps(payload, ensure_ascii=False, separators=separators)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def post_chat(
|
|
593
|
+
url: str,
|
|
594
|
+
system: str,
|
|
595
|
+
payload: dict | str,
|
|
596
|
+
timeout: float = 60,
|
|
597
|
+
*,
|
|
598
|
+
task: str = "name",
|
|
599
|
+
) -> str:
|
|
600
|
+
"""POST one chat completion. ``system`` is the task prompt, not a shared default."""
|
|
601
|
+
endpoint = url.rstrip("/")
|
|
602
|
+
if not endpoint.endswith("/v1/chat/completions"):
|
|
603
|
+
endpoint += "/v1/chat/completions"
|
|
604
|
+
body = {
|
|
605
|
+
"temperature": 0,
|
|
606
|
+
"max_tokens": 32,
|
|
607
|
+
"chat_template_kwargs": {"enable_thinking": False},
|
|
608
|
+
"messages": [
|
|
609
|
+
{"role": "system", "content": system},
|
|
610
|
+
{"role": "user", "content": user_text(task, payload)},
|
|
611
|
+
],
|
|
612
|
+
}
|
|
613
|
+
request = urllib.request.Request(
|
|
614
|
+
endpoint,
|
|
615
|
+
data=json.dumps(body).encode(),
|
|
616
|
+
headers={"Content-Type": "application/json"},
|
|
617
|
+
method="POST",
|
|
618
|
+
)
|
|
619
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
620
|
+
data = json.loads(response.read().decode())
|
|
621
|
+
try:
|
|
622
|
+
return str(data["choices"][0]["message"]["content"])
|
|
623
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
624
|
+
raise RuntimeError(f"chat reply has no message content: {str(data)[:200]}") from exc
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def hit_names(payload: dict[str, object]) -> set[str]:
|
|
628
|
+
return {str(item["name"]) for item in payload.get("dictionary_hits") or []}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def fetch_name(url: str, payload: dict[str, object], timeout: float = 60) -> Decision:
|
|
632
|
+
return parse_decision(
|
|
633
|
+
post_chat(url, SYSTEM_NAME, payload, timeout, task="name"), hit_names(payload)
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def candidate_names(payload: dict[str, object]) -> set[str]:
|
|
638
|
+
names: set[str] = set()
|
|
639
|
+
for item in payload.get("candidates") or []:
|
|
640
|
+
if isinstance(item, list) and item:
|
|
641
|
+
names.add(str(item[0]))
|
|
642
|
+
elif isinstance(item, dict) and item.get("name"):
|
|
643
|
+
names.add(str(item["name"]))
|
|
644
|
+
return names
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def fetch_ml(url: str, payload: dict[str, object], timeout: float = 120) -> MlResult:
|
|
648
|
+
return parse_ml(
|
|
649
|
+
post_chat(url, SYSTEM_ML, payload, timeout, task="ml"),
|
|
650
|
+
candidate_names(payload),
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _negative_high(row: dict | None) -> bool:
|
|
655
|
+
return row is not None and float(row["logfc"]) >= 1 and float(row["pct_in"]) >= 0.25
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def ml_stat_row(positive: list[dict], negative: list[str], measurements: dict[str, dict]) -> list:
|
|
659
|
+
"""Training stat columns. Raw logfc is unchanged, including exploded scanpy values."""
|
|
660
|
+
values = [float(row["logfc"]) for row in positive]
|
|
661
|
+
return [
|
|
662
|
+
len(positive),
|
|
663
|
+
sum(float(row["logfc"]) > SUPPORT_LOGFC for row in positive),
|
|
664
|
+
sum(float(row["logfc"]) < 0 for row in positive),
|
|
665
|
+
sum(float(row["pct_in"]) >= 0.05 for row in positive),
|
|
666
|
+
len(negative),
|
|
667
|
+
sum(_negative_high(measurements.get(gene)) for gene in negative),
|
|
668
|
+
sum(gene not in measurements for gene in negative),
|
|
669
|
+
(sum(values) / len(values)) if values else None,
|
|
670
|
+
]
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def trial_support(positive: list[dict], negative: list[str], measurements: dict[str, dict]) -> dict:
|
|
674
|
+
"""Detection-aware support. This is not written into the ML user message."""
|
|
675
|
+
detected = [
|
|
676
|
+
row
|
|
677
|
+
for row in positive
|
|
678
|
+
if float(row["logfc"]) > SUPPORT_LOGFC and float(row["pct_in"]) >= 0.25
|
|
679
|
+
]
|
|
680
|
+
blocked = sum(_negative_high(measurements.get(gene)) for gene in negative)
|
|
681
|
+
mean = sum(float(row["logfc"]) for row in detected) / len(detected) if detected else None
|
|
682
|
+
return {
|
|
683
|
+
"n_positive_supported_detected": len(detected),
|
|
684
|
+
"mean_detected_logfc": mean,
|
|
685
|
+
"n_negative_high": blocked,
|
|
686
|
+
"blocked_by_negative": blocked > 0,
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _measured(row: dict | None, gene: str) -> list:
|
|
691
|
+
if row is None:
|
|
692
|
+
return [gene, None, None, None]
|
|
693
|
+
return [gene, row["logfc"], row["pct_in"], row["pct_out"]]
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def ml_user(
|
|
697
|
+
species: str,
|
|
698
|
+
tissue: str,
|
|
699
|
+
stage: str,
|
|
700
|
+
genes: list[dict],
|
|
701
|
+
panels: list[tuple[str, list[str], list[str]]],
|
|
702
|
+
measurements: dict[str, dict],
|
|
703
|
+
) -> dict[str, object]:
|
|
704
|
+
"""ML user object. ``panels`` is ``(name, positive genes, negative genes)``."""
|
|
705
|
+
selected: set[str] = set()
|
|
706
|
+
candidates = []
|
|
707
|
+
for name, positive, negative in panels:
|
|
708
|
+
selected.update(positive)
|
|
709
|
+
selected.update(negative)
|
|
710
|
+
rows = [measurements[gene] for gene in positive if gene in measurements]
|
|
711
|
+
candidates.append(
|
|
712
|
+
[name, list(positive), list(negative), ml_stat_row(rows, negative, measurements)]
|
|
713
|
+
)
|
|
714
|
+
return {
|
|
715
|
+
"species": species,
|
|
716
|
+
"tissue": tissue,
|
|
717
|
+
"stage": stage,
|
|
718
|
+
"measurement_columns": list(ML_MEASUREMENT_COLUMNS),
|
|
719
|
+
"genes": [_measured(row, str(row["gene"])) for row in genes],
|
|
720
|
+
"marker_measurements": [
|
|
721
|
+
_measured(measurements.get(gene), gene) for gene in sorted(selected)
|
|
722
|
+
],
|
|
723
|
+
"candidate_stat_columns": list(ML_STAT_COLUMNS),
|
|
724
|
+
"candidates": candidates,
|
|
725
|
+
}
|