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,2037 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Cluster workflow: marker, name, and ML results, then a judgment writes the title."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from collections.abc import Callable, Collection
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
import scanpy as sc
|
|
18
|
+
from anndata import AnnData
|
|
19
|
+
from scipy import sparse
|
|
20
|
+
|
|
21
|
+
from .annotate import annotate
|
|
22
|
+
from .arbitrate import DEFAULT_MIN_MARGIN
|
|
23
|
+
from .decide import (
|
|
24
|
+
SYSTEM_ML,
|
|
25
|
+
SYSTEM_NAME,
|
|
26
|
+
Decision,
|
|
27
|
+
MlResult,
|
|
28
|
+
candidate_names,
|
|
29
|
+
cluster_payload,
|
|
30
|
+
detection_fractions,
|
|
31
|
+
fetch_ml,
|
|
32
|
+
fetch_name,
|
|
33
|
+
hit_names,
|
|
34
|
+
ml_user_from_hits,
|
|
35
|
+
parse_decision,
|
|
36
|
+
parse_ml,
|
|
37
|
+
propose_hypotheses,
|
|
38
|
+
rank_measurements,
|
|
39
|
+
trial_support,
|
|
40
|
+
wilcoxon_order,
|
|
41
|
+
)
|
|
42
|
+
from .evidence import NEGATIVE_PRESENT_PCT, cluster_mask, require_unique_var_names
|
|
43
|
+
from .hypotheses import Hypothesis
|
|
44
|
+
from .marker_plot import (
|
|
45
|
+
MAX_MARKERS_PER_TYPE,
|
|
46
|
+
MAX_PVALUE,
|
|
47
|
+
MIN_LOGFC,
|
|
48
|
+
MIN_PCT_IN,
|
|
49
|
+
plot_marker_panels,
|
|
50
|
+
select_marker_panels,
|
|
51
|
+
)
|
|
52
|
+
from .markers import hypotheses_from_names, marker_names, parents_for, tissue_pages
|
|
53
|
+
from .prepare import _timed, doublet_summary, format_doublets
|
|
54
|
+
from .votes import agreed_name, gene_vote, panel_vote, subcluster_vote
|
|
55
|
+
|
|
56
|
+
SUBCLUSTER_KEY = "annotune_sub"
|
|
57
|
+
LABEL_KEY = "annotune_label"
|
|
58
|
+
MERGED_KEY = "annotune_cluster"
|
|
59
|
+
MIN_SUBCLUSTER_CELLS = 40
|
|
60
|
+
MIN_AUTOLABEL_CELLS = 20
|
|
61
|
+
SUBCLUSTER_RESOLUTIONS = (0.6, 1.0, 1.4)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class TaskEndpoint:
|
|
66
|
+
"""One adapter. ``ask`` overrides HTTP and local weights. ``system`` overrides the task prompt."""
|
|
67
|
+
|
|
68
|
+
url: str | None = None
|
|
69
|
+
adapter: str | None = None
|
|
70
|
+
system: str = ""
|
|
71
|
+
ask: Callable | None = None
|
|
72
|
+
maxlen: int = 2048
|
|
73
|
+
base: str = ""
|
|
74
|
+
tokenizer: str | None = None
|
|
75
|
+
server: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ModelChain:
|
|
80
|
+
name: TaskEndpoint
|
|
81
|
+
ml: TaskEndpoint
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def local_models(
|
|
85
|
+
root: str | Path, *, url: str | None = None, tokenizer: str | None = None
|
|
86
|
+
) -> ModelChain:
|
|
87
|
+
"""Q6_K base and the name and ML adapters.
|
|
88
|
+
|
|
89
|
+
``url`` is the server root. The default is ``http://127.0.0.1:8080``.
|
|
90
|
+
A server is reused only when its base and both adapters match.
|
|
91
|
+
The tokenizer is the one installed with the package.
|
|
92
|
+
"""
|
|
93
|
+
from .local_lora import packaged_tokenizer, service_url
|
|
94
|
+
|
|
95
|
+
root = Path(root).expanduser().resolve()
|
|
96
|
+
base = str(root / "base" / "Qwen3.5-4B-base.Q6_K.gguf")
|
|
97
|
+
chosen = url or service_url()
|
|
98
|
+
tokenizer = packaged_tokenizer() if tokenizer is None else tokenizer
|
|
99
|
+
|
|
100
|
+
def endpoint(name: str, maxlen: int) -> TaskEndpoint:
|
|
101
|
+
return TaskEndpoint(
|
|
102
|
+
adapter=str(root / "lora" / name),
|
|
103
|
+
base=base,
|
|
104
|
+
maxlen=maxlen,
|
|
105
|
+
tokenizer=tokenizer,
|
|
106
|
+
server=chosen,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return ModelChain(
|
|
110
|
+
name=endpoint("name/annotune-v9h-name-checkpoint-412.lora.f16.gguf", 2048),
|
|
111
|
+
ml=endpoint("ml/annotune-v9e-ml-checkpoint-231.lora.f16.gguf", 8192),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_HINTS = {
|
|
116
|
+
"small_cluster": f"fewer than {MIN_AUTOLABEL_CELLS} cells",
|
|
117
|
+
"model_failure": "model call failed",
|
|
118
|
+
}
|
|
119
|
+
_LINEAGE = {"exact", "ancestor", "descendant"}
|
|
120
|
+
_SOURCE_TITLES = (("marker", "marker"), ("name", "name"), ("ML", "ml"))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def ontology_raw_labels() -> frozenset[str] | None:
|
|
124
|
+
"""Names mapped to a CL id in any species. None if the map is absent."""
|
|
125
|
+
_dag, label_map, _names = _lineage_resources()
|
|
126
|
+
if label_map is None:
|
|
127
|
+
return None
|
|
128
|
+
return frozenset(entry.raw_label for entry in label_map.entries if entry.cl_id)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _lineage_resources(species: str | None = None):
|
|
132
|
+
"""DAG, map, and the names the map sends to a CL id for ``species``.
|
|
133
|
+
|
|
134
|
+
All three are None when either file is missing. A name mapped only for
|
|
135
|
+
the other species is not in the set.
|
|
136
|
+
"""
|
|
137
|
+
from .granularity import ontology_dir
|
|
138
|
+
|
|
139
|
+
return _load_lineage(ontology_dir(), species)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@lru_cache(maxsize=8)
|
|
143
|
+
def _load_lineage(directory: Path, species: str | None):
|
|
144
|
+
from .granularity.cl_dag import load_ontology
|
|
145
|
+
from .granularity.ontology_map import load_map
|
|
146
|
+
|
|
147
|
+
dag_path = directory / "cl_isa.json"
|
|
148
|
+
map_path = directory / "ontology_map.jsonl"
|
|
149
|
+
if not dag_path.is_file() or not map_path.is_file():
|
|
150
|
+
return None, None, None
|
|
151
|
+
label_map = load_map(map_path)
|
|
152
|
+
names = set()
|
|
153
|
+
for raw in {entry.raw_label for entry in label_map.entries}:
|
|
154
|
+
found = label_map.lookup(raw, species)
|
|
155
|
+
if found is not None and found.cl_id:
|
|
156
|
+
names.add(raw)
|
|
157
|
+
# The result is cached and shared, so callers get a set they cannot change.
|
|
158
|
+
return load_ontology(dag_path), label_map, frozenset(names)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def settle_cluster(
|
|
162
|
+
*,
|
|
163
|
+
marker: str | None,
|
|
164
|
+
name: str | None,
|
|
165
|
+
ml: MlResult | None,
|
|
166
|
+
added: list[str],
|
|
167
|
+
cells: int | None = None,
|
|
168
|
+
failed: bool = False,
|
|
169
|
+
ontology_names: set[str] | None = None,
|
|
170
|
+
species: str | None = None,
|
|
171
|
+
ranking: list[dict] | None = None,
|
|
172
|
+
margin: float | None = None,
|
|
173
|
+
marker_labeled: bool = False,
|
|
174
|
+
min_margin: float = DEFAULT_MIN_MARGIN,
|
|
175
|
+
dag=None,
|
|
176
|
+
label_map=None,
|
|
177
|
+
) -> dict[str, object]:
|
|
178
|
+
"""Write one name from marker, name, and ML.
|
|
179
|
+
|
|
180
|
+
Exact, ancestor, and descendant Cell Ontology relations are one lineage.
|
|
181
|
+
Siblings are not. All three calls on one lineage write the finer name when
|
|
182
|
+
its marker score passes, with no hint. Two calls on one lineage write the
|
|
183
|
+
ontology name on that lineage and name the dissenter in the hint. Three
|
|
184
|
+
lineages still write the higher-scoring ontology name and add a conflict
|
|
185
|
+
hint. A marker margin below ``min_margin`` does the same, unless name and
|
|
186
|
+
ML agree on a descendant of the marker call: that descendant is written
|
|
187
|
+
and the close-score hint stays. Platelet or megakaryocyte yields to a
|
|
188
|
+
monocyte program that is also on. A cluster under 40 cells with two
|
|
189
|
+
programs on is not named when marker, name, and ML do not all give the
|
|
190
|
+
same name. When they do, that name is written and the hint lists every
|
|
191
|
+
detected program from high score to low. A failed marker score with no
|
|
192
|
+
ontology name writes nothing. Agreement does not pick the name.
|
|
193
|
+
"""
|
|
194
|
+
ml_name = ml.names[0] if ml is not None and ml.status == "named" and ml.names else None
|
|
195
|
+
proposals: list[dict[str, str]] = []
|
|
196
|
+
if name:
|
|
197
|
+
proposals.append({"source": "name", "name": name})
|
|
198
|
+
if ml_name:
|
|
199
|
+
proposals.append({"source": "ml", "name": ml_name})
|
|
200
|
+
spoken = {"marker": marker, "name": name, "ml": ml_name}
|
|
201
|
+
if species and dag is None:
|
|
202
|
+
dag, label_map, loaded = _lineage_resources(species)
|
|
203
|
+
if ontology_names is None:
|
|
204
|
+
ontology_names = loaded
|
|
205
|
+
label, other, close = _choose_label(
|
|
206
|
+
spoken,
|
|
207
|
+
ontology_names,
|
|
208
|
+
species=species,
|
|
209
|
+
ranking=ranking,
|
|
210
|
+
margin=margin,
|
|
211
|
+
marker_labeled=marker_labeled,
|
|
212
|
+
min_margin=min_margin,
|
|
213
|
+
dag=dag,
|
|
214
|
+
label_map=label_map,
|
|
215
|
+
)
|
|
216
|
+
leukocyte = _monocyte_over_platelet(label, ranking, species, dag, label_map)
|
|
217
|
+
if leukocyte:
|
|
218
|
+
if label and label != leukocyte:
|
|
219
|
+
other = [f"marker {label}", *[item for item in other if item != f"marker {label}"]]
|
|
220
|
+
label = leukocyte
|
|
221
|
+
mixed = (
|
|
222
|
+
cells is not None
|
|
223
|
+
and cells < MIN_SUBCLUSTER_CELLS
|
|
224
|
+
and _mixed_programs(ranking, species, dag, label_map)
|
|
225
|
+
)
|
|
226
|
+
unanimous = bool(marker and name and ml_name and marker == name == ml_name)
|
|
227
|
+
if mixed and not unanimous:
|
|
228
|
+
label = None
|
|
229
|
+
model_failed = failed or (ml is not None and ml.status == "failed")
|
|
230
|
+
hint = None
|
|
231
|
+
if mixed and label is None:
|
|
232
|
+
status, reason = "review", "mixed_programs"
|
|
233
|
+
hint = _program_hint(ranking, species, dag, label_map)
|
|
234
|
+
elif label is None:
|
|
235
|
+
if model_failed:
|
|
236
|
+
status, reason = "review", "model_failure"
|
|
237
|
+
elif cells is not None and cells < MIN_AUTOLABEL_CELLS:
|
|
238
|
+
status, reason = "review", "small_cluster"
|
|
239
|
+
else:
|
|
240
|
+
status, reason = "escalate", "no_supported_name"
|
|
241
|
+
else:
|
|
242
|
+
caveats: list[str] = []
|
|
243
|
+
if other or close:
|
|
244
|
+
caveats.append("evidence_conflict")
|
|
245
|
+
if cells is not None and cells < MIN_AUTOLABEL_CELLS:
|
|
246
|
+
caveats.append("small_cluster")
|
|
247
|
+
if model_failed:
|
|
248
|
+
caveats.append("model_failure")
|
|
249
|
+
status = "named"
|
|
250
|
+
reason = "+".join(caveats) if caveats else "evidence_agreement"
|
|
251
|
+
notes = []
|
|
252
|
+
if other:
|
|
253
|
+
notes.append("conflict: " + ", ".join(other))
|
|
254
|
+
if close:
|
|
255
|
+
notes.append("close marker scores")
|
|
256
|
+
notes.extend(_HINTS[item] for item in caveats if item != "evidence_conflict")
|
|
257
|
+
if mixed:
|
|
258
|
+
notes.append(_program_hint(ranking, species, dag, label_map))
|
|
259
|
+
if reason == "evidence_agreement":
|
|
260
|
+
reason = "mixed_programs"
|
|
261
|
+
if notes:
|
|
262
|
+
hint = "; ".join(notes)
|
|
263
|
+
return {
|
|
264
|
+
"proposals": proposals,
|
|
265
|
+
"added": list(added),
|
|
266
|
+
"written_label": label,
|
|
267
|
+
"hint": hint,
|
|
268
|
+
"arbitration": status != "named",
|
|
269
|
+
"final_status": status,
|
|
270
|
+
"final_reason": reason,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _choose_label(
|
|
275
|
+
spoken: dict[str, str | None],
|
|
276
|
+
ontology_names: set[str] | None,
|
|
277
|
+
*,
|
|
278
|
+
species: str | None,
|
|
279
|
+
ranking: list[dict] | None,
|
|
280
|
+
margin: float | None,
|
|
281
|
+
marker_labeled: bool,
|
|
282
|
+
min_margin: float,
|
|
283
|
+
dag,
|
|
284
|
+
label_map,
|
|
285
|
+
) -> tuple[str | None, list[str], bool]:
|
|
286
|
+
items = [(key, spoken[key]) for key in ("marker", "name", "ml") if spoken[key]]
|
|
287
|
+
close = bool(ranking) and len(ranking) >= 2 and margin is not None and margin < min_margin
|
|
288
|
+
if not items:
|
|
289
|
+
return None, [], close
|
|
290
|
+
|
|
291
|
+
def accepted(value: str) -> bool:
|
|
292
|
+
return ontology_names is None or value in ontology_names
|
|
293
|
+
|
|
294
|
+
components = _components(items, species, dag, label_map)
|
|
295
|
+
components.sort(key=len, reverse=True)
|
|
296
|
+
agreed_model = spoken["name"] if spoken["name"] and spoken["name"] == spoken["ml"] else None
|
|
297
|
+
marker_name = spoken["marker"]
|
|
298
|
+
if (
|
|
299
|
+
close
|
|
300
|
+
and agreed_model
|
|
301
|
+
and marker_name
|
|
302
|
+
and agreed_model != marker_name
|
|
303
|
+
and accepted(agreed_model)
|
|
304
|
+
and _is_descendant(agreed_model, marker_name, species, dag, label_map)
|
|
305
|
+
):
|
|
306
|
+
return agreed_model, _outside(components, agreed_model), close
|
|
307
|
+
agreed = (
|
|
308
|
+
components[0] if len(components) == 1 or len(components[0]) > len(components[1]) else None
|
|
309
|
+
)
|
|
310
|
+
pool = agreed if agreed is not None else items
|
|
311
|
+
names = list(dict.fromkeys(value for _key, value in pool if accepted(value)))
|
|
312
|
+
if not names:
|
|
313
|
+
names = list(dict.fromkeys(value for _key, value in items if accepted(value)))
|
|
314
|
+
if not names:
|
|
315
|
+
if marker_labeled and spoken["marker"]:
|
|
316
|
+
return spoken["marker"], _outside(components, spoken["marker"]), close
|
|
317
|
+
return None, [], close
|
|
318
|
+
scores = {value: _marker_score(value, ranking) for value in names}
|
|
319
|
+
finest = _finest(names, species, dag, label_map)
|
|
320
|
+
marker = spoken["marker"]
|
|
321
|
+
if marker_labeled and marker in finest:
|
|
322
|
+
chosen = marker
|
|
323
|
+
elif any(score is not None for score in scores.values()):
|
|
324
|
+
chosen = max(
|
|
325
|
+
names,
|
|
326
|
+
key=lambda value: (
|
|
327
|
+
scores[value] is not None,
|
|
328
|
+
scores[value] if scores[value] is not None else 0.0,
|
|
329
|
+
value == marker,
|
|
330
|
+
value,
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
elif len(names) == 1:
|
|
334
|
+
chosen = names[0]
|
|
335
|
+
else:
|
|
336
|
+
return None, [], close
|
|
337
|
+
return chosen, _outside(components, chosen), close
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _components(
|
|
341
|
+
items: list[tuple[str, str]], species, dag, label_map
|
|
342
|
+
) -> list[list[tuple[str, str]]]:
|
|
343
|
+
parent = list(range(len(items)))
|
|
344
|
+
|
|
345
|
+
def find(index: int) -> int:
|
|
346
|
+
while parent[index] != index:
|
|
347
|
+
parent[index] = parent[parent[index]]
|
|
348
|
+
index = parent[index]
|
|
349
|
+
return index
|
|
350
|
+
|
|
351
|
+
for left in range(len(items)):
|
|
352
|
+
for right in range(left + 1, len(items)):
|
|
353
|
+
if _same_lineage(items[left][1], items[right][1], species, dag, label_map):
|
|
354
|
+
parent[find(right)] = find(left)
|
|
355
|
+
groups: dict[int, list[tuple[str, str]]] = {}
|
|
356
|
+
for index, item in enumerate(items):
|
|
357
|
+
groups.setdefault(find(index), []).append(item)
|
|
358
|
+
return list(groups.values())
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _same_lineage(left: str, right: str, species, dag, label_map) -> bool:
|
|
362
|
+
if left == right:
|
|
363
|
+
return True
|
|
364
|
+
if not species or dag is None or label_map is None:
|
|
365
|
+
return False
|
|
366
|
+
from .granularity.relations import relation
|
|
367
|
+
|
|
368
|
+
found = relation(left, right, species, dag, label_map, formal=False)
|
|
369
|
+
return found["relation"] in _LINEAGE
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _finest(names: list[str], species, dag, label_map) -> list[str]:
|
|
373
|
+
kept: list[str] = []
|
|
374
|
+
for name in names:
|
|
375
|
+
if any(
|
|
376
|
+
other != name and _is_descendant(other, name, species, dag, label_map)
|
|
377
|
+
for other in names
|
|
378
|
+
):
|
|
379
|
+
continue
|
|
380
|
+
kept.append(name)
|
|
381
|
+
return kept
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _is_descendant(finer: str, coarser: str, species, dag, label_map) -> bool:
|
|
385
|
+
if not species or dag is None or label_map is None:
|
|
386
|
+
return False
|
|
387
|
+
from .granularity.relations import relation
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
relation(finer, coarser, species, dag, label_map, formal=False)["relation"] == "descendant"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _marker_score(name: str, ranking: list[dict] | None) -> float | None:
|
|
395
|
+
for row in ranking or []:
|
|
396
|
+
if row.get("name") == name:
|
|
397
|
+
return float(row["score"])
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _program_hold(record, cells, species, dag, label_map) -> dict[str, object] | None:
|
|
402
|
+
"""Apply the platelet and mixed-program rules when no model call settled the cluster."""
|
|
403
|
+
if record.get("status") != "label" or dag is None or label_map is None:
|
|
404
|
+
return None
|
|
405
|
+
ranking = [dict(row) for row in record.get("ranking") or []]
|
|
406
|
+
label = str(record["cell_type"]) if record.get("cell_type") else None
|
|
407
|
+
if (
|
|
408
|
+
cells is not None
|
|
409
|
+
and cells < MIN_SUBCLUSTER_CELLS
|
|
410
|
+
and _mixed_programs(ranking, species, dag, label_map)
|
|
411
|
+
):
|
|
412
|
+
return {
|
|
413
|
+
"written_label": None,
|
|
414
|
+
"hint": _program_hint(ranking, species, dag, label_map),
|
|
415
|
+
"final_status": "review",
|
|
416
|
+
"final_reason": "mixed_programs",
|
|
417
|
+
"arbitration": True,
|
|
418
|
+
}
|
|
419
|
+
leukocyte = _monocyte_over_platelet(label, ranking, species, dag, label_map)
|
|
420
|
+
if leukocyte and leukocyte != label:
|
|
421
|
+
return {
|
|
422
|
+
"written_label": leukocyte,
|
|
423
|
+
"hint": f"conflict: marker {label}",
|
|
424
|
+
"final_status": "named",
|
|
425
|
+
"final_reason": "evidence_conflict",
|
|
426
|
+
"arbitration": True,
|
|
427
|
+
}
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _monocyte_over_platelet(chosen, ranking, species, dag, label_map) -> str | None:
|
|
432
|
+
"""Platelet RNA on a monocyte program is an aggregate, not a platelet cluster."""
|
|
433
|
+
if chosen not in {"platelet", "megakaryocyte"} or dag is None or label_map is None:
|
|
434
|
+
return None
|
|
435
|
+
best_name = None
|
|
436
|
+
best_score = None
|
|
437
|
+
for row in ranking or []:
|
|
438
|
+
if not row.get("program_on"):
|
|
439
|
+
continue
|
|
440
|
+
name = str(row["name"])
|
|
441
|
+
if not _same_lineage(name, "monocyte", species, dag, label_map):
|
|
442
|
+
continue
|
|
443
|
+
score = float(row["score"])
|
|
444
|
+
if best_name is None or score > best_score:
|
|
445
|
+
best_name = name
|
|
446
|
+
best_score = score
|
|
447
|
+
return best_name
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _detected_programs(ranking, species, dag, label_map) -> list[tuple[str, float]]:
|
|
451
|
+
"""Ontology programs whose positive panel is on, highest score first."""
|
|
452
|
+
if dag is None or label_map is None:
|
|
453
|
+
return []
|
|
454
|
+
found: list[tuple[str, float]] = []
|
|
455
|
+
seen: set[str] = set()
|
|
456
|
+
for row in ranking or []:
|
|
457
|
+
name = row.get("name")
|
|
458
|
+
if not name or not row.get("program_on") or name in seen:
|
|
459
|
+
continue
|
|
460
|
+
entry = label_map.lookup(str(name), species, formal=False)
|
|
461
|
+
if entry is None or not entry.cl_id:
|
|
462
|
+
continue
|
|
463
|
+
seen.add(str(name))
|
|
464
|
+
found.append((str(name), float(row["score"])))
|
|
465
|
+
found.sort(key=lambda item: (-item[1], item[0]))
|
|
466
|
+
return found
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _needs_query(record: dict) -> bool:
|
|
470
|
+
"""Unnamed clusters and named clusters that still carry a question."""
|
|
471
|
+
return not record.get("written_label") or bool(record.get("hint"))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _print_queries(records: list[dict]) -> None:
|
|
475
|
+
rows = [record for record in records if record.get("query")]
|
|
476
|
+
if not rows:
|
|
477
|
+
return
|
|
478
|
+
print("Queries", flush=True)
|
|
479
|
+
for record in rows:
|
|
480
|
+
label = record.get("written_label") or "no label"
|
|
481
|
+
hint = record.get("hint") or record.get("final_reason") or ""
|
|
482
|
+
print(f"cluster {record['cluster']} {label} {hint}", flush=True)
|
|
483
|
+
print(record["query"], flush=True)
|
|
484
|
+
print(flush=True)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _attach_queries(adata, cluster_key, records, hypotheses, species, tissue) -> None:
|
|
488
|
+
"""Pasteable gene features for unnamed clusters and clusters with a question."""
|
|
489
|
+
pending = [record for record in records if _needs_query(record)]
|
|
490
|
+
if not pending:
|
|
491
|
+
return
|
|
492
|
+
if adata.obs[cluster_key].nunique() >= 2:
|
|
493
|
+
try:
|
|
494
|
+
wilcoxon_order(adata, cluster_key)
|
|
495
|
+
except ValueError:
|
|
496
|
+
return
|
|
497
|
+
dag = label_map = None
|
|
498
|
+
if species:
|
|
499
|
+
dag, label_map, _known = _lineage_resources(species)
|
|
500
|
+
by_name = {item.name: item for item in hypotheses}
|
|
501
|
+
for record in pending:
|
|
502
|
+
cluster = str(record["cluster"])
|
|
503
|
+
cells = int((adata.obs[cluster_key].astype(str) == cluster).sum())
|
|
504
|
+
programs = [
|
|
505
|
+
name
|
|
506
|
+
for name, _score in _detected_programs(record.get("ranking"), species, dag, label_map)
|
|
507
|
+
]
|
|
508
|
+
text = _cluster_query(
|
|
509
|
+
adata,
|
|
510
|
+
cluster,
|
|
511
|
+
species=species or "",
|
|
512
|
+
tissue=tissue or "",
|
|
513
|
+
cells=cells,
|
|
514
|
+
programs=programs,
|
|
515
|
+
by_name=by_name,
|
|
516
|
+
)
|
|
517
|
+
if text:
|
|
518
|
+
record["query"] = text
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _cluster_query(
|
|
522
|
+
adata,
|
|
523
|
+
cluster: str,
|
|
524
|
+
*,
|
|
525
|
+
species: str,
|
|
526
|
+
tissue: str,
|
|
527
|
+
cells: int,
|
|
528
|
+
programs: list[str],
|
|
529
|
+
by_name: dict[str, Hypothesis],
|
|
530
|
+
) -> str | None:
|
|
531
|
+
"""Gene list a person can paste into a model and ask for a cell type."""
|
|
532
|
+
result = adata.uns.get("rank_genes_groups")
|
|
533
|
+
if not result or "names" not in result or "pts" not in result:
|
|
534
|
+
return None
|
|
535
|
+
key = str(cluster)
|
|
536
|
+
try:
|
|
537
|
+
names = [str(gene) for gene in result["names"][key]]
|
|
538
|
+
changes = [float(value) for value in result["logfoldchanges"][key]]
|
|
539
|
+
pct_in = result["pts"][key]
|
|
540
|
+
pct_out = result["pts_rest"][key]
|
|
541
|
+
except (KeyError, IndexError, TypeError, ValueError):
|
|
542
|
+
return None
|
|
543
|
+
change_of = dict(zip(names, changes, strict=True))
|
|
544
|
+
|
|
545
|
+
def feature(gene: str) -> str | None:
|
|
546
|
+
if gene not in change_of:
|
|
547
|
+
return None
|
|
548
|
+
inside = float(pct_in.loc[gene])
|
|
549
|
+
outside = float(pct_out.loc[gene])
|
|
550
|
+
return f"{gene} (in {inside:.2f}, out {outside:.2f}, logfc {change_of[gene]:.2f})"
|
|
551
|
+
|
|
552
|
+
enriched: list[str] = []
|
|
553
|
+
seen: set[str] = set()
|
|
554
|
+
for gene, change in zip(names, changes, strict=True):
|
|
555
|
+
if gene in seen or change <= 0:
|
|
556
|
+
continue
|
|
557
|
+
seen.add(gene)
|
|
558
|
+
text = feature(gene)
|
|
559
|
+
if text:
|
|
560
|
+
enriched.append(text)
|
|
561
|
+
if len(enriched) >= 10:
|
|
562
|
+
break
|
|
563
|
+
present: list[str] = []
|
|
564
|
+
for name in programs:
|
|
565
|
+
item = by_name.get(name)
|
|
566
|
+
if item is None:
|
|
567
|
+
continue
|
|
568
|
+
for gene in (*item.positive, *item.support):
|
|
569
|
+
if gene in seen or gene not in change_of:
|
|
570
|
+
continue
|
|
571
|
+
if float(pct_in.loc[gene]) < 0.5:
|
|
572
|
+
continue
|
|
573
|
+
seen.add(gene)
|
|
574
|
+
text = feature(gene)
|
|
575
|
+
if text:
|
|
576
|
+
present.append(text)
|
|
577
|
+
if len(present) >= 16:
|
|
578
|
+
break
|
|
579
|
+
if len(present) >= 16:
|
|
580
|
+
break
|
|
581
|
+
if not enriched and not present:
|
|
582
|
+
return None
|
|
583
|
+
subject = species or "unknown species"
|
|
584
|
+
if tissue:
|
|
585
|
+
subject = f"{subject}, {tissue}"
|
|
586
|
+
lines = [f"{subject}, {cells} cells. What cell type is this cluster?"]
|
|
587
|
+
if enriched:
|
|
588
|
+
lines.append("Enriched versus other cells: " + "; ".join(enriched) + ".")
|
|
589
|
+
if present:
|
|
590
|
+
lines.append("Also detected: " + "; ".join(present) + ".")
|
|
591
|
+
return "\n".join(lines)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _program_hint(ranking, species, dag, label_map) -> str:
|
|
595
|
+
names = [name for name, _score in _detected_programs(ranking, species, dag, label_map)]
|
|
596
|
+
return "cell programs: " + ", ".join(names)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _mixed_programs(ranking, species, dag, label_map) -> bool:
|
|
600
|
+
"""Two ontology lineages each have a detected positive program."""
|
|
601
|
+
found = _detected_programs(ranking, species, dag, label_map)
|
|
602
|
+
if len(found) < 2:
|
|
603
|
+
return False
|
|
604
|
+
groups = _components([("marker", name) for name, _score in found], species, dag, label_map)
|
|
605
|
+
return len(groups) >= 2
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _outside(components: list[list[tuple[str, str]]], chosen: str) -> list[str]:
|
|
609
|
+
home = next((group for group in components if any(name == chosen for _key, name in group)), [])
|
|
610
|
+
home_keys = {key for key, _name in home}
|
|
611
|
+
spoken = {key: name for group in components for key, name in group}
|
|
612
|
+
return [
|
|
613
|
+
f"{title} {spoken[key]}"
|
|
614
|
+
for title, key in _SOURCE_TITLES
|
|
615
|
+
if key in spoken and key not in home_keys
|
|
616
|
+
]
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def dictionary_hypotheses(
|
|
620
|
+
species: str,
|
|
621
|
+
tissue: str | None = None,
|
|
622
|
+
user: list[Hypothesis] | None = None,
|
|
623
|
+
) -> list[Hypothesis]:
|
|
624
|
+
"""Core and tissue dictionary, with user rows replacing the same name."""
|
|
625
|
+
names = marker_names(species, tissue)
|
|
626
|
+
merged = {item.name: item for item in hypotheses_from_names(names, species, tissue)}
|
|
627
|
+
for item in user or []:
|
|
628
|
+
merged[item.name] = item
|
|
629
|
+
return list(merged.values())
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _in_matrix(
|
|
633
|
+
hypotheses: list[Hypothesis], var_names: set[str]
|
|
634
|
+
) -> tuple[list[Hypothesis], list[dict[str, object]]]:
|
|
635
|
+
"""Keep a type whose positive markers are all measured.
|
|
636
|
+
|
|
637
|
+
A negative gene absent from the matrix is omitted. Absence is not evidence
|
|
638
|
+
for the type, and it does not drop the type. A missing positive marker
|
|
639
|
+
still drops the type. Each dropped row names those genes.
|
|
640
|
+
"""
|
|
641
|
+
kept: list[Hypothesis] = []
|
|
642
|
+
dropped: list[dict[str, object]] = []
|
|
643
|
+
for item in hypotheses:
|
|
644
|
+
missing = [gene for gene in item.positive if gene not in var_names]
|
|
645
|
+
if missing:
|
|
646
|
+
dropped.append(
|
|
647
|
+
{"name": item.name, "reason": "missing positive markers", "missing": missing}
|
|
648
|
+
)
|
|
649
|
+
continue
|
|
650
|
+
negatives = tuple(gene for gene in item.negative if gene in var_names)
|
|
651
|
+
support = tuple(gene for gene in item.support if gene in var_names)
|
|
652
|
+
if negatives == item.negative and support == item.support:
|
|
653
|
+
kept.append(item)
|
|
654
|
+
else:
|
|
655
|
+
kept.append(Hypothesis(item.name, item.positive, negatives, support))
|
|
656
|
+
return kept, dropped
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _variable_genes(part: AnnData) -> int:
|
|
660
|
+
matrix = part.X
|
|
661
|
+
if sparse.issparse(matrix):
|
|
662
|
+
mean = np.asarray(matrix.mean(axis=0)).ravel()
|
|
663
|
+
second = np.asarray(matrix.multiply(matrix).mean(axis=0)).ravel()
|
|
664
|
+
variance = second - mean**2
|
|
665
|
+
else:
|
|
666
|
+
variance = np.asarray(matrix).var(axis=0)
|
|
667
|
+
return int(np.count_nonzero(variance > 1e-8))
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
SILHOUETTE_SAMPLE = 2000
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _subcluster(part: AnnData) -> bool:
|
|
674
|
+
if _variable_genes(part) < 2:
|
|
675
|
+
return False
|
|
676
|
+
n_comps = min(10, part.n_obs - 1, part.n_vars - 1)
|
|
677
|
+
if n_comps < 2:
|
|
678
|
+
return False
|
|
679
|
+
sc.pp.pca(part, n_comps=n_comps, svd_solver="full")
|
|
680
|
+
sc.pp.neighbors(part, n_pcs=n_comps)
|
|
681
|
+
from sklearn.metrics import silhouette_score
|
|
682
|
+
|
|
683
|
+
best_labels = None
|
|
684
|
+
best_score = -1.0
|
|
685
|
+
for resolution in SUBCLUSTER_RESOLUTIONS:
|
|
686
|
+
sc.tl.leiden(
|
|
687
|
+
part,
|
|
688
|
+
resolution=resolution,
|
|
689
|
+
key_added="annotune_sub_try",
|
|
690
|
+
flavor="igraph",
|
|
691
|
+
n_iterations=2,
|
|
692
|
+
random_state=0,
|
|
693
|
+
)
|
|
694
|
+
labels = part.obs["annotune_sub_try"].astype(str)
|
|
695
|
+
if int(labels.nunique()) < 2 or int(labels.nunique()) >= part.n_obs:
|
|
696
|
+
continue
|
|
697
|
+
if part.n_obs > SILHOUETTE_SAMPLE:
|
|
698
|
+
groups = labels.groupby(labels).indices
|
|
699
|
+
if len(groups) >= SILHOUETTE_SAMPLE:
|
|
700
|
+
continue
|
|
701
|
+
rng = np.random.default_rng(0)
|
|
702
|
+
chosen = np.array([rng.choice(indices) for indices in groups.values()])
|
|
703
|
+
remaining = np.setdiff1d(np.arange(part.n_obs), chosen, assume_unique=True)
|
|
704
|
+
extra = rng.choice(remaining, SILHOUETTE_SAMPLE - len(chosen), replace=False)
|
|
705
|
+
sample = np.concatenate((chosen, extra))
|
|
706
|
+
score = float(silhouette_score(part.obsm["X_pca"][sample], labels.iloc[sample]))
|
|
707
|
+
else:
|
|
708
|
+
score = float(silhouette_score(part.obsm["X_pca"], labels))
|
|
709
|
+
if score > best_score:
|
|
710
|
+
best_score = score
|
|
711
|
+
best_labels = labels.to_numpy()
|
|
712
|
+
if best_labels is None:
|
|
713
|
+
return False
|
|
714
|
+
part.obs[SUBCLUSTER_KEY] = best_labels
|
|
715
|
+
return True
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _cluster_order(cluster: str) -> tuple[int, object]:
|
|
719
|
+
try:
|
|
720
|
+
return (0, int(cluster))
|
|
721
|
+
except ValueError:
|
|
722
|
+
return (1, cluster)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def annotation_code(
|
|
726
|
+
adata: AnnData,
|
|
727
|
+
records: list[dict[str, object]],
|
|
728
|
+
cluster_key: str,
|
|
729
|
+
label_key: str,
|
|
730
|
+
marker_dict: dict[str, list[str]],
|
|
731
|
+
type_order: list[str],
|
|
732
|
+
) -> str:
|
|
733
|
+
"""Editable code for labels, type order, UMAP, and checked marker dotplot."""
|
|
734
|
+
group_key = SUBCLUSTER_KEY if SUBCLUSTER_KEY in adata.obs else cluster_key
|
|
735
|
+
units = adata.obs[group_key].astype(str)
|
|
736
|
+
assigned: dict[str, str] = {}
|
|
737
|
+
choices: dict[str, list[str]] = {}
|
|
738
|
+
for unit in units.unique():
|
|
739
|
+
labels = adata.obs.loc[units == unit, label_key]
|
|
740
|
+
names = labels.dropna().astype(str).unique()
|
|
741
|
+
if not labels.isna().any() and len(names) == 1:
|
|
742
|
+
assigned[str(unit)] = str(names[0])
|
|
743
|
+
for record in records:
|
|
744
|
+
cluster = str(record["cluster"])
|
|
745
|
+
if cluster in assigned:
|
|
746
|
+
continue
|
|
747
|
+
names: list[str] = []
|
|
748
|
+
for item in list(record.get("choices") or []):
|
|
749
|
+
if item not in names:
|
|
750
|
+
names.append(str(item))
|
|
751
|
+
for item in record.get("ranking") or []:
|
|
752
|
+
candidate = str(item["name"])
|
|
753
|
+
if candidate not in names:
|
|
754
|
+
names.append(candidate)
|
|
755
|
+
if len(names) >= 2:
|
|
756
|
+
break
|
|
757
|
+
if names:
|
|
758
|
+
choices[cluster] = names[:2]
|
|
759
|
+
order = sorted(assigned, key=_cluster_order)
|
|
760
|
+
mapping = {cluster: assigned[cluster] for cluster in order}
|
|
761
|
+
lines = [
|
|
762
|
+
"import pandas as pd",
|
|
763
|
+
"import scanpy as sc",
|
|
764
|
+
"",
|
|
765
|
+
"# cluster2celltype maps each cluster id to its name.",
|
|
766
|
+
"# fine_order is the UMAP legend and the dotplot row order.",
|
|
767
|
+
"# marker_dict is the dotplot. Gene order inside a list is the column order.",
|
|
768
|
+
"cluster2celltype = {",
|
|
769
|
+
]
|
|
770
|
+
for cluster, name in mapping.items():
|
|
771
|
+
lines.append(f" {cluster!r}: {name!r},")
|
|
772
|
+
lines.append("}")
|
|
773
|
+
if choices:
|
|
774
|
+
lines.extend(["", "# unresolved clusters", "cluster_choices = {"])
|
|
775
|
+
for cluster in sorted(choices, key=_cluster_order):
|
|
776
|
+
lines.append(f" {cluster!r}: {choices[cluster]!r},")
|
|
777
|
+
lines.append("}")
|
|
778
|
+
lines.extend(
|
|
779
|
+
[
|
|
780
|
+
"",
|
|
781
|
+
"# Change this order to control the UMAP legend and dotplot rows.",
|
|
782
|
+
"fine_order = [",
|
|
783
|
+
]
|
|
784
|
+
)
|
|
785
|
+
for name in type_order:
|
|
786
|
+
lines.append(f" {name!r},")
|
|
787
|
+
lines.extend(
|
|
788
|
+
[
|
|
789
|
+
"]",
|
|
790
|
+
"",
|
|
791
|
+
f"assert {group_key!r} in adata.obs, 'run this on the annotated h5ad written by annotune'",
|
|
792
|
+
f"adata.obs['celltype'] = adata.obs[{group_key!r}].astype(str).map(cluster2celltype)",
|
|
793
|
+
"adata.obs['celltype'] = pd.Categorical(",
|
|
794
|
+
" adata.obs['celltype'], categories=fine_order, ordered=True",
|
|
795
|
+
")",
|
|
796
|
+
"print(adata.obs['celltype'].isna().sum(), 'cells without a label')",
|
|
797
|
+
"print(adata.obs['celltype'].value_counts())",
|
|
798
|
+
"if 'X_umap' in adata.obsm:",
|
|
799
|
+
" sc.pl.umap(adata, color='celltype', frameon=False)",
|
|
800
|
+
"",
|
|
801
|
+
"# Cited dictionary markers. Types flagged no_marker_passed show their best measured genes.",
|
|
802
|
+
"# A shared gene is kept only when that type would otherwise have no marker.",
|
|
803
|
+
"# Edit these lists to customize the figure; marker_validation in the report has the measurements.",
|
|
804
|
+
"marker_dict = {",
|
|
805
|
+
]
|
|
806
|
+
)
|
|
807
|
+
for name in type_order:
|
|
808
|
+
if name in marker_dict:
|
|
809
|
+
lines.append(f" {name!r}: {marker_dict[name]!r},")
|
|
810
|
+
lines.append("}")
|
|
811
|
+
if marker_dict:
|
|
812
|
+
lines.extend(
|
|
813
|
+
[
|
|
814
|
+
"# Keep marker columns in the same order as the cell type rows.",
|
|
815
|
+
"ordered_marker_dict = {name: marker_dict[name] for name in fine_order if name in marker_dict}",
|
|
816
|
+
"sc.pl.dotplot(",
|
|
817
|
+
" adata[adata.obs['celltype'].notna()].copy(), ordered_marker_dict,",
|
|
818
|
+
" groupby='celltype', categories_order=fine_order,",
|
|
819
|
+
" dendrogram=False, use_raw=False, standard_scale='var',",
|
|
820
|
+
")",
|
|
821
|
+
]
|
|
822
|
+
)
|
|
823
|
+
lines.append("")
|
|
824
|
+
return "\n".join(lines)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def run(
|
|
828
|
+
adata: AnnData,
|
|
829
|
+
cluster_key: str,
|
|
830
|
+
*,
|
|
831
|
+
species: str | None = None,
|
|
832
|
+
tissue: str | None = None,
|
|
833
|
+
hypotheses: list[Hypothesis] | None = None,
|
|
834
|
+
user_hypotheses: list[Hypothesis] | None = None,
|
|
835
|
+
min_margin: float = DEFAULT_MIN_MARGIN,
|
|
836
|
+
ranker: object | None = None,
|
|
837
|
+
conformal_q: float | None = None,
|
|
838
|
+
catalog: list[Hypothesis] | None = None,
|
|
839
|
+
models: ModelChain | None = None,
|
|
840
|
+
stage: str = "",
|
|
841
|
+
source: str | None = None,
|
|
842
|
+
donor: str | None = None,
|
|
843
|
+
model_clusters: Collection[str] | None = None,
|
|
844
|
+
subcluster: bool = True,
|
|
845
|
+
min_subcluster_cells: int = MIN_SUBCLUSTER_CELLS,
|
|
846
|
+
label_key: str = LABEL_KEY,
|
|
847
|
+
dotplot_path: Path | str | None = None,
|
|
848
|
+
show: bool = False,
|
|
849
|
+
) -> dict[str, object]:
|
|
850
|
+
"""Collect the marker, name, and ML results, then write the title.
|
|
851
|
+
|
|
852
|
+
The marker score, the name model, and the ML model each return a result.
|
|
853
|
+
A judgment reads the three results and writes the title. The title is a
|
|
854
|
+
dictionary name. A cluster can stay unnamed. The name and ML models run
|
|
855
|
+
when ``models`` is set. The name model copies a cell-type name from the
|
|
856
|
+
marker dictionary. The ML model is trained on machine-learning measurements
|
|
857
|
+
of the cluster, differential expression and marker-panel statistics, and
|
|
858
|
+
copies a name from those candidates. Name and ML each receive their own
|
|
859
|
+
payload. Clear clusters are written to ``label_key``. Uncertain clusters
|
|
860
|
+
are subclustered once. Each step prints when it starts and how many seconds
|
|
861
|
+
it took. A cluster with no written name, or a written name that still has a
|
|
862
|
+
hint, prints a gene query that can be pasted into another model. A local
|
|
863
|
+
server started for the adapters stays loaded after this call.
|
|
864
|
+
|
|
865
|
+
With ``models``, the name and ML models are asked for every cluster, or
|
|
866
|
+
only those in ``model_clusters``. That includes clusters the marker score
|
|
867
|
+
already named. The judgment reads all three results and can write a
|
|
868
|
+
different title.
|
|
869
|
+
|
|
870
|
+
``adata`` is changed in place. This writes ``obs`` columns ``label_key``,
|
|
871
|
+
``annotune_cluster``, ``annotune_final_status`` and ``annotune_hint``, and
|
|
872
|
+
``annotune_sub`` when a cluster is split. An ``annotune_sub`` column from
|
|
873
|
+
an earlier run is removed first. It also sets ``uns['log1p']`` when the
|
|
874
|
+
matrix passes the log1p check, and ``uns['rank_genes_groups']`` when it
|
|
875
|
+
runs Wilcoxon.
|
|
876
|
+
"""
|
|
877
|
+
if hypotheses is None:
|
|
878
|
+
if species is None:
|
|
879
|
+
raise ValueError("species is required when hypotheses are omitted")
|
|
880
|
+
hypotheses = dictionary_hypotheses(species, tissue, user_hypotheses)
|
|
881
|
+
elif user_hypotheses:
|
|
882
|
+
merged = {item.name: item for item in hypotheses}
|
|
883
|
+
for item in user_hypotheses:
|
|
884
|
+
merged[item.name] = item
|
|
885
|
+
hypotheses = list(merged.values())
|
|
886
|
+
require_unique_var_names(adata)
|
|
887
|
+
if SUBCLUSTER_KEY in adata.obs.columns:
|
|
888
|
+
del adata.obs[SUBCLUSTER_KEY]
|
|
889
|
+
kept, dropped = _in_matrix(hypotheses, set(map(str, adata.var_names)))
|
|
890
|
+
if len(kept) < 1:
|
|
891
|
+
raise ValueError("no hypothesis has its positive markers in var_names")
|
|
892
|
+
pool = list(catalog) if catalog is not None else list(kept)
|
|
893
|
+
started = time.perf_counter()
|
|
894
|
+
with _timed("run score markers") as note:
|
|
895
|
+
records = _annotate(adata, cluster_key, kept, min_margin, ranker, conformal_q)
|
|
896
|
+
labeled = sum(record["status"] == "label" for record in records)
|
|
897
|
+
note["detail"] = f"{labeled} of {len(records)} clusters labeled"
|
|
898
|
+
used = {item.name for item in kept}
|
|
899
|
+
extras: list[Hypothesis] = []
|
|
900
|
+
seen = set(used)
|
|
901
|
+
for record in records:
|
|
902
|
+
if record["status"] == "label":
|
|
903
|
+
continue
|
|
904
|
+
for item in propose_hypotheses(pool, list(record["evidence"]["de_not_in_markers"]), seen):
|
|
905
|
+
if item.name not in seen:
|
|
906
|
+
extras.append(item)
|
|
907
|
+
seen.add(item.name)
|
|
908
|
+
extras, _ = _in_matrix(extras, set(map(str, adata.var_names)))
|
|
909
|
+
if extras:
|
|
910
|
+
kept = [*kept, *extras]
|
|
911
|
+
with _timed("run score added markers") as note:
|
|
912
|
+
records = _annotate(adata, cluster_key, kept, min_margin, ranker, conformal_q)
|
|
913
|
+
note["detail"] = f"{len(extras)} added"
|
|
914
|
+
children: list[dict[str, object]] = []
|
|
915
|
+
if subcluster:
|
|
916
|
+
with _timed("run subcluster") as note:
|
|
917
|
+
split_any = False
|
|
918
|
+
for record in records:
|
|
919
|
+
if record["status"] == "label":
|
|
920
|
+
continue
|
|
921
|
+
mask = cluster_mask(adata, cluster_key, str(record["cluster"]))
|
|
922
|
+
if int(mask.sum()) < min_subcluster_cells:
|
|
923
|
+
continue
|
|
924
|
+
part = adata[mask].copy()
|
|
925
|
+
if not _subcluster(part):
|
|
926
|
+
continue
|
|
927
|
+
child_records = _annotate(
|
|
928
|
+
part, SUBCLUSTER_KEY, kept, min_margin, ranker, conformal_q
|
|
929
|
+
)
|
|
930
|
+
for child in child_records:
|
|
931
|
+
child["parent"] = str(record["cluster"])
|
|
932
|
+
child["cluster"] = f"{record['cluster']}.{child['cluster']}"
|
|
933
|
+
if not split_any:
|
|
934
|
+
adata.obs[SUBCLUSTER_KEY] = adata.obs[cluster_key].astype(str)
|
|
935
|
+
split_any = True
|
|
936
|
+
parent_labels = adata.obs.loc[mask, cluster_key].astype(str)
|
|
937
|
+
child_labels = part.obs[SUBCLUSTER_KEY].astype(str)
|
|
938
|
+
adata.obs.loc[mask, SUBCLUSTER_KEY] = [
|
|
939
|
+
f"{parent}.{child}"
|
|
940
|
+
for parent, child in zip(parent_labels, child_labels, strict=True)
|
|
941
|
+
]
|
|
942
|
+
children.append({"parent": str(record["cluster"]), "records": child_records})
|
|
943
|
+
note["detail"] = f"{len(children)} clusters"
|
|
944
|
+
by_parent = {str(item["parent"]): item["records"] for item in children}
|
|
945
|
+
resolved = _resolved_labels(adata, cluster_key, records, by_parent)
|
|
946
|
+
with _timed("run Wilcoxon"):
|
|
947
|
+
_apply_votes(adata, cluster_key, records, by_parent, kept, resolved, species, tissue)
|
|
948
|
+
arbitration: list[str] = []
|
|
949
|
+
added_hypotheses: list[Hypothesis] = []
|
|
950
|
+
if models is not None:
|
|
951
|
+
with _timed("run load models"):
|
|
952
|
+
_open_local(models)
|
|
953
|
+
added_hypotheses = _run_models(
|
|
954
|
+
adata,
|
|
955
|
+
cluster_key,
|
|
956
|
+
models,
|
|
957
|
+
records,
|
|
958
|
+
kept,
|
|
959
|
+
pool,
|
|
960
|
+
resolved,
|
|
961
|
+
arbitration,
|
|
962
|
+
species=species or "",
|
|
963
|
+
tissue=tissue or "",
|
|
964
|
+
stage=stage,
|
|
965
|
+
source=source,
|
|
966
|
+
donor=_one_donor(adata, donor),
|
|
967
|
+
model_clusters=model_clusters,
|
|
968
|
+
min_margin=min_margin,
|
|
969
|
+
ranker=ranker,
|
|
970
|
+
conformal_q=conformal_q,
|
|
971
|
+
)
|
|
972
|
+
dag = label_map = None
|
|
973
|
+
if species:
|
|
974
|
+
dag, label_map, _known = _lineage_resources()
|
|
975
|
+
for record in records:
|
|
976
|
+
if "final_status" in record or dag is None:
|
|
977
|
+
continue
|
|
978
|
+
cluster = str(record["cluster"])
|
|
979
|
+
current = resolved.get(cluster)
|
|
980
|
+
if current is not None and set(current) != {cluster}:
|
|
981
|
+
continue
|
|
982
|
+
cells = int((adata.obs[cluster_key].astype(str) == cluster).sum())
|
|
983
|
+
revised = _program_hold(record, cells, species, dag, label_map)
|
|
984
|
+
if revised is None:
|
|
985
|
+
continue
|
|
986
|
+
record.update(revised)
|
|
987
|
+
if revised["written_label"]:
|
|
988
|
+
resolved[cluster] = {cluster: str(revised["written_label"])}
|
|
989
|
+
else:
|
|
990
|
+
resolved.pop(cluster, None)
|
|
991
|
+
for record in records:
|
|
992
|
+
if "final_status" in record:
|
|
993
|
+
continue
|
|
994
|
+
cluster = str(record["cluster"])
|
|
995
|
+
mapping = resolved.get(cluster, {})
|
|
996
|
+
record["final_status"] = "named" if mapping else "review"
|
|
997
|
+
record["final_reason"] = (
|
|
998
|
+
"subcluster_labels"
|
|
999
|
+
if mapping and set(mapping) != {cluster}
|
|
1000
|
+
else "votes"
|
|
1001
|
+
if mapping and record.get("vote_label") == mapping.get(cluster)
|
|
1002
|
+
else "marker_rule"
|
|
1003
|
+
if mapping
|
|
1004
|
+
else "unresolved_evidence"
|
|
1005
|
+
)
|
|
1006
|
+
record["written_label"] = mapping.get(cluster)
|
|
1007
|
+
record["hint"] = None
|
|
1008
|
+
_apply_neighbor_titles(adata, cluster_key, records, resolved, species, dag, label_map)
|
|
1009
|
+
_attach_queries(adata, cluster_key, records, [*kept, *added_hypotheses], species, tissue)
|
|
1010
|
+
labels = _assign_labels(adata, cluster_key, resolved)
|
|
1011
|
+
written = bool(labels.notna().any())
|
|
1012
|
+
adata.obs[label_key] = labels.astype("category")
|
|
1013
|
+
status_by_cluster = {str(record["cluster"]): record["final_status"] for record in records}
|
|
1014
|
+
hint_by_cluster = {str(record["cluster"]): record.get("hint") for record in records}
|
|
1015
|
+
adata.obs["annotune_final_status"] = (
|
|
1016
|
+
adata.obs[cluster_key].astype(str).map(status_by_cluster).astype("category")
|
|
1017
|
+
)
|
|
1018
|
+
adata.obs["annotune_hint"] = (
|
|
1019
|
+
adata.obs[cluster_key].astype(str).map(hint_by_cluster).astype("string")
|
|
1020
|
+
)
|
|
1021
|
+
all_labeled = bool(written) and bool(labels.notna().all())
|
|
1022
|
+
with _timed("run marker panels") as note:
|
|
1023
|
+
marker_dict, marker_validation, marker_status = select_marker_panels(
|
|
1024
|
+
adata, label_key, species, tissue
|
|
1025
|
+
)
|
|
1026
|
+
while _flag_labels_without_markers(records, resolved, marker_status, by_parent):
|
|
1027
|
+
labels = _assign_labels(adata, cluster_key, resolved)
|
|
1028
|
+
written = bool(labels.notna().any())
|
|
1029
|
+
adata.obs[label_key] = labels.astype("category")
|
|
1030
|
+
all_labeled = bool(written) and bool(labels.notna().all())
|
|
1031
|
+
marker_dict, marker_validation, marker_status = select_marker_panels(
|
|
1032
|
+
adata, label_key, species, tissue
|
|
1033
|
+
)
|
|
1034
|
+
# The loop above also flags names with no passing marker; publish both.
|
|
1035
|
+
status_by_cluster = {str(record["cluster"]): record["final_status"] for record in records}
|
|
1036
|
+
hint_by_cluster = {str(record["cluster"]): record.get("hint") for record in records}
|
|
1037
|
+
adata.obs["annotune_final_status"] = (
|
|
1038
|
+
adata.obs[cluster_key].astype(str).map(status_by_cluster).astype("category")
|
|
1039
|
+
)
|
|
1040
|
+
adata.obs["annotune_hint"] = (
|
|
1041
|
+
adata.obs[cluster_key].astype(str).map(hint_by_cluster).astype("string")
|
|
1042
|
+
)
|
|
1043
|
+
_attach_queries(adata, cluster_key, records, [*kept, *added_hypotheses], species, tissue)
|
|
1044
|
+
note["detail"] = f"{len(marker_dict)} types"
|
|
1045
|
+
type_order = list(dict.fromkeys(adata.obs[label_key].dropna().astype(str)))
|
|
1046
|
+
plot = (
|
|
1047
|
+
plot_marker_panels(adata, label_key, marker_dict, type_order, dotplot_path)
|
|
1048
|
+
if dotplot_path is not None
|
|
1049
|
+
else None
|
|
1050
|
+
)
|
|
1051
|
+
code = annotation_code(
|
|
1052
|
+
adata,
|
|
1053
|
+
[*records, *(child for item in children for child in item["records"])],
|
|
1054
|
+
cluster_key,
|
|
1055
|
+
label_key,
|
|
1056
|
+
marker_dict,
|
|
1057
|
+
type_order,
|
|
1058
|
+
)
|
|
1059
|
+
merged = merge_same_labels(adata, cluster_key, label_key)
|
|
1060
|
+
clusters = _cluster_rows(adata, cluster_key, records, species)
|
|
1061
|
+
report = {
|
|
1062
|
+
"records": records,
|
|
1063
|
+
"clusters": clusters,
|
|
1064
|
+
"review": _review_rows(clusters),
|
|
1065
|
+
"dropped_hypotheses": dropped,
|
|
1066
|
+
"subclusters": children,
|
|
1067
|
+
"queue": [
|
|
1068
|
+
str(record["cluster"])
|
|
1069
|
+
for record in records
|
|
1070
|
+
if labels[adata.obs[cluster_key].astype(str) == str(record["cluster"])].isna().any()
|
|
1071
|
+
],
|
|
1072
|
+
"arbitration": arbitration,
|
|
1073
|
+
"merged": merged,
|
|
1074
|
+
"all_labeled": all_labeled,
|
|
1075
|
+
"written": written,
|
|
1076
|
+
"label_key": label_key if written else None,
|
|
1077
|
+
"dotplot": plot,
|
|
1078
|
+
"marker_dict": marker_dict,
|
|
1079
|
+
"marker_validation": marker_validation,
|
|
1080
|
+
"marker_status": marker_status,
|
|
1081
|
+
"cluster_key": cluster_key,
|
|
1082
|
+
"doublets": doublet_summary(adata.uns.get("annotune_prep")),
|
|
1083
|
+
"tissue": tissue,
|
|
1084
|
+
"tissues": tissue_pages(species) if species else [],
|
|
1085
|
+
"marker_thresholds": {
|
|
1086
|
+
"min_pct_in": MIN_PCT_IN,
|
|
1087
|
+
"min_logfc": MIN_LOGFC,
|
|
1088
|
+
"max_pvalue": MAX_PVALUE,
|
|
1089
|
+
"max_markers_per_type": MAX_MARKERS_PER_TYPE,
|
|
1090
|
+
},
|
|
1091
|
+
"annotation_code": code,
|
|
1092
|
+
}
|
|
1093
|
+
_print_queries(records)
|
|
1094
|
+
print(
|
|
1095
|
+
f"run done {time.perf_counter() - started:.1f}s "
|
|
1096
|
+
f"{sum(bool(record.get('written_label')) for record in records)} of {len(records)} clusters named",
|
|
1097
|
+
flush=True,
|
|
1098
|
+
)
|
|
1099
|
+
if show:
|
|
1100
|
+
_show_annotation(adata, cluster_key, label_key, report)
|
|
1101
|
+
if marker_dict:
|
|
1102
|
+
plot_marker_panels(adata, label_key, marker_dict, type_order)
|
|
1103
|
+
print(code)
|
|
1104
|
+
return report
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _model_name(record: dict, step: str):
|
|
1108
|
+
for item in record.get("rounds") or []:
|
|
1109
|
+
if item.get("step") != step:
|
|
1110
|
+
continue
|
|
1111
|
+
if item.get("status") == "failed":
|
|
1112
|
+
return "failed"
|
|
1113
|
+
names = item.get("names") or []
|
|
1114
|
+
if names:
|
|
1115
|
+
return names[0]
|
|
1116
|
+
return item.get("action")
|
|
1117
|
+
return None
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _ontology_note(label_map, label: object, species: str | None) -> dict[str, object] | None:
|
|
1121
|
+
"""Exact map row for a written label. Unreviewed ids stay pending."""
|
|
1122
|
+
if label is None or label_map is None:
|
|
1123
|
+
return None
|
|
1124
|
+
entry = label_map.lookup(str(label), species)
|
|
1125
|
+
if entry is None or not entry.cl_id:
|
|
1126
|
+
return {"status": "unmapped"}
|
|
1127
|
+
return {
|
|
1128
|
+
"cl_id": entry.cl_id,
|
|
1129
|
+
"cl_label": entry.cl_label,
|
|
1130
|
+
"match": entry.match,
|
|
1131
|
+
"status": entry.status,
|
|
1132
|
+
"pending": entry.status != "reviewed",
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _cluster_rows(
|
|
1137
|
+
adata: AnnData, cluster_key: str, records: list[dict], species: str | None
|
|
1138
|
+
) -> list[dict[str, object]]:
|
|
1139
|
+
"""One row per cluster. Sources stay separate. Ontology is an exact map lookup."""
|
|
1140
|
+
label_map = _lineage_resources(species)[1] if species else None
|
|
1141
|
+
rows = []
|
|
1142
|
+
for record in sorted(records, key=lambda row: _cluster_order(str(row["cluster"]))):
|
|
1143
|
+
cluster = str(record["cluster"])
|
|
1144
|
+
label = record.get("written_label")
|
|
1145
|
+
rows.append(
|
|
1146
|
+
{
|
|
1147
|
+
"cluster": cluster,
|
|
1148
|
+
"cells": int((adata.obs[cluster_key].astype(str) == cluster).sum()),
|
|
1149
|
+
"marker": record.get("cell_type"),
|
|
1150
|
+
"name": _model_name(record, "name"),
|
|
1151
|
+
"ml": _model_name(record, "ml"),
|
|
1152
|
+
"label": label,
|
|
1153
|
+
"hint": record.get("hint"),
|
|
1154
|
+
"query": record.get("query"),
|
|
1155
|
+
"reason": record.get("final_reason"),
|
|
1156
|
+
"ontology": _ontology_note(label_map, label, species),
|
|
1157
|
+
}
|
|
1158
|
+
)
|
|
1159
|
+
return rows
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _review_rows(clusters: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
1163
|
+
"""Clusters to read first. The rank is not an accuracy score."""
|
|
1164
|
+
ranked = []
|
|
1165
|
+
for row in clusters:
|
|
1166
|
+
priority = _review_priority(row)
|
|
1167
|
+
if priority is None:
|
|
1168
|
+
continue
|
|
1169
|
+
ranked.append(
|
|
1170
|
+
{
|
|
1171
|
+
"cluster": row["cluster"],
|
|
1172
|
+
"label": row["label"],
|
|
1173
|
+
"reason": row["reason"],
|
|
1174
|
+
"hint": row["hint"],
|
|
1175
|
+
"query": row.get("query"),
|
|
1176
|
+
"ontology": row["ontology"],
|
|
1177
|
+
"priority": priority,
|
|
1178
|
+
}
|
|
1179
|
+
)
|
|
1180
|
+
ranked.sort(key=lambda row: (row["priority"], _cluster_order(str(row["cluster"]))))
|
|
1181
|
+
return ranked
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _review_priority(row: dict[str, object]) -> int | None:
|
|
1185
|
+
if not row.get("label"):
|
|
1186
|
+
return 0
|
|
1187
|
+
reason = str(row.get("reason") or "")
|
|
1188
|
+
hint = str(row.get("hint") or "")
|
|
1189
|
+
if "evidence_conflict" in reason or hint.startswith("conflict") or "close marker" in hint:
|
|
1190
|
+
return 1
|
|
1191
|
+
if "model_failure" in reason or "model call failed" in hint:
|
|
1192
|
+
return 2
|
|
1193
|
+
if "small_cluster" in reason or hint.startswith("fewer than"):
|
|
1194
|
+
return 3
|
|
1195
|
+
if hint:
|
|
1196
|
+
return 4
|
|
1197
|
+
return None
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _show_annotation(adata, cluster_key: str, label_key: str, report: dict) -> None:
|
|
1201
|
+
import pandas as pd
|
|
1202
|
+
|
|
1203
|
+
shown = [
|
|
1204
|
+
{
|
|
1205
|
+
"cluster": row["cluster"],
|
|
1206
|
+
"cells": row["cells"],
|
|
1207
|
+
"marker": row["marker"],
|
|
1208
|
+
"name": row["name"],
|
|
1209
|
+
"ml": row["ml"],
|
|
1210
|
+
"label": row["label"],
|
|
1211
|
+
"hint": row["hint"],
|
|
1212
|
+
"reason": row["reason"],
|
|
1213
|
+
}
|
|
1214
|
+
for row in report["clusters"]
|
|
1215
|
+
]
|
|
1216
|
+
table = pd.DataFrame(shown)
|
|
1217
|
+
try:
|
|
1218
|
+
from IPython.display import display
|
|
1219
|
+
|
|
1220
|
+
display(table)
|
|
1221
|
+
except Exception:
|
|
1222
|
+
print(table.to_string(index=False))
|
|
1223
|
+
print(format_doublets(report["doublets"]))
|
|
1224
|
+
_print_dropped(report["dropped_hypotheses"])
|
|
1225
|
+
_print_review(report["review"])
|
|
1226
|
+
print("merged", report["merged"])
|
|
1227
|
+
sc.pl.umap(adata, color=label_key, legend_loc="right margin", frameon=False, show=True)
|
|
1228
|
+
sc.pl.umap(adata, color=MERGED_KEY, legend_loc="on data", title=MERGED_KEY, show=True)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _print_dropped(dropped: list[dict[str, object]]) -> None:
|
|
1232
|
+
if not dropped:
|
|
1233
|
+
return
|
|
1234
|
+
print("Dropped hypotheses")
|
|
1235
|
+
for item in dropped:
|
|
1236
|
+
missing = ", ".join(str(gene) for gene in item["missing"])
|
|
1237
|
+
print(f" {item['name']}: {item['reason']}: {missing}")
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _print_review(review: list[dict[str, object]]) -> None:
|
|
1241
|
+
print("Review")
|
|
1242
|
+
if not review:
|
|
1243
|
+
print(" none")
|
|
1244
|
+
return
|
|
1245
|
+
print(" Order is for manual review. It is not an accuracy score.")
|
|
1246
|
+
for item in review:
|
|
1247
|
+
label = item["label"] or "no label"
|
|
1248
|
+
ontology = item.get("ontology") or {}
|
|
1249
|
+
suffix = ""
|
|
1250
|
+
if ontology.get("cl_id"):
|
|
1251
|
+
state = "pending" if ontology.get("pending") else ontology.get("status")
|
|
1252
|
+
suffix = f" [{ontology['cl_id']}, {state}]"
|
|
1253
|
+
detail = item["hint"] or item["reason"]
|
|
1254
|
+
print(f" {item['cluster']} {label}{suffix} {detail}")
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def merge_same_labels(adata: AnnData, cluster_key: str, label_key: str) -> list[dict[str, object]]:
|
|
1258
|
+
"""Give cells that share a written name one cluster id.
|
|
1259
|
+
|
|
1260
|
+
The original ``cluster_key`` stays. ``annotune_cluster`` is the merged id.
|
|
1261
|
+
A cluster that still holds more than one written name is left as it is.
|
|
1262
|
+
Cells with no name keep their own id. Hints stay on the original records.
|
|
1263
|
+
"""
|
|
1264
|
+
units = adata.obs[cluster_key].astype(str)
|
|
1265
|
+
if SUBCLUSTER_KEY in adata.obs:
|
|
1266
|
+
sub = adata.obs[SUBCLUSTER_KEY]
|
|
1267
|
+
sub_text = sub.astype(str)
|
|
1268
|
+
use_sub = sub.notna() & ~sub_text.isin(["", "nan", "None", "<NA>"])
|
|
1269
|
+
units = sub_text.where(use_sub, units)
|
|
1270
|
+
text = adata.obs[label_key].astype(str)
|
|
1271
|
+
named = (
|
|
1272
|
+
adata.obs[label_key].notna().to_numpy() & ~text.isin(["", "nan", "None", "<NA>"]).to_numpy()
|
|
1273
|
+
)
|
|
1274
|
+
unit = units.to_numpy()
|
|
1275
|
+
names = text.to_numpy()
|
|
1276
|
+
names_per_unit: dict[str, set[str]] = defaultdict(set)
|
|
1277
|
+
for uid, name, ok in zip(unit, names, named, strict=True):
|
|
1278
|
+
if ok:
|
|
1279
|
+
names_per_unit[str(uid)].add(str(name))
|
|
1280
|
+
by_name: dict[str, list[str]] = defaultdict(list)
|
|
1281
|
+
for uid, found in names_per_unit.items():
|
|
1282
|
+
if len(found) == 1:
|
|
1283
|
+
by_name[next(iter(found))].append(uid)
|
|
1284
|
+
chosen_for: dict[str, str] = {}
|
|
1285
|
+
merged: list[dict[str, object]] = []
|
|
1286
|
+
for name, members in by_name.items():
|
|
1287
|
+
ordered = sorted(members, key=_unit_key)
|
|
1288
|
+
chosen = ordered[0]
|
|
1289
|
+
for uid in ordered:
|
|
1290
|
+
chosen_for[uid] = chosen
|
|
1291
|
+
if len(ordered) > 1:
|
|
1292
|
+
merged.append({"name": name, "cluster": chosen, "members": ordered})
|
|
1293
|
+
new_ids = [
|
|
1294
|
+
chosen_for[str(uid)] if ok and str(uid) in chosen_for else str(uid)
|
|
1295
|
+
for uid, ok in zip(unit, named, strict=True)
|
|
1296
|
+
]
|
|
1297
|
+
adata.obs[MERGED_KEY] = new_ids
|
|
1298
|
+
return merged
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def _unit_key(value: str) -> tuple[int, int | str]:
|
|
1302
|
+
return (0, int(value)) if value.isdigit() else (1, value)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _one_donor(adata, donor: str | None) -> str | None:
|
|
1306
|
+
if donor is not None:
|
|
1307
|
+
return donor
|
|
1308
|
+
if "donor_id" in adata.obs.columns:
|
|
1309
|
+
values = adata.obs["donor_id"].astype(str).unique()
|
|
1310
|
+
if len(values) == 1:
|
|
1311
|
+
return str(values[0])
|
|
1312
|
+
return None
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _failure_text(exc: BaseException) -> str:
|
|
1316
|
+
if type(exc).__name__ == "OutOfMemoryError":
|
|
1317
|
+
return "OutOfMemoryError"
|
|
1318
|
+
return str(exc)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _is_runtime_failure(exc: BaseException) -> bool:
|
|
1322
|
+
return isinstance(exc, (OSError, ValueError, RuntimeError))
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _open_local(models: ModelChain) -> bool:
|
|
1326
|
+
"""Start one base when both tasks have a local GGUF adapter."""
|
|
1327
|
+
ends = (models.name, models.ml)
|
|
1328
|
+
if not any(item.adapter for item in ends):
|
|
1329
|
+
return False
|
|
1330
|
+
if any(item.ask is not None for item in ends):
|
|
1331
|
+
raise ValueError("local adapters cannot be combined with ask callbacks")
|
|
1332
|
+
if not all(item.adapter and not item.url for item in ends):
|
|
1333
|
+
raise ValueError("name and ml share one base; pass both GGUF adapters")
|
|
1334
|
+
from .local_lora import open_adapters, packaged_tokenizer
|
|
1335
|
+
|
|
1336
|
+
base = models.name.base
|
|
1337
|
+
if models.ml.base != base:
|
|
1338
|
+
raise ValueError("name and ml share one GGUF base")
|
|
1339
|
+
if not str(base).endswith(".gguf"):
|
|
1340
|
+
raise ValueError("local adapters require a GGUF --base")
|
|
1341
|
+
gguf = base
|
|
1342
|
+
chosen = [item.tokenizer for item in ends if item.tokenizer]
|
|
1343
|
+
if len(set(chosen)) > 1:
|
|
1344
|
+
raise ValueError("name and ml share one tokenizer")
|
|
1345
|
+
tokenizer = chosen[0] if chosen else packaged_tokenizer()
|
|
1346
|
+
servers = {item.server for item in ends if item.server}
|
|
1347
|
+
if len(servers) > 1:
|
|
1348
|
+
raise ValueError("name and ml share one server")
|
|
1349
|
+
kwargs = {"url": next(iter(servers))} if servers else {}
|
|
1350
|
+
open_adapters(
|
|
1351
|
+
{"name": models.name.adapter, "ml": models.ml.adapter},
|
|
1352
|
+
gguf=gguf,
|
|
1353
|
+
tokenizer=tokenizer,
|
|
1354
|
+
**kwargs,
|
|
1355
|
+
)
|
|
1356
|
+
return True
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _run_models(
|
|
1360
|
+
adata,
|
|
1361
|
+
cluster_key: str,
|
|
1362
|
+
models: ModelChain,
|
|
1363
|
+
records: list[dict],
|
|
1364
|
+
kept: list[Hypothesis],
|
|
1365
|
+
pool: list[Hypothesis],
|
|
1366
|
+
resolved: dict,
|
|
1367
|
+
arbitration: list[str],
|
|
1368
|
+
*,
|
|
1369
|
+
species: str,
|
|
1370
|
+
tissue: str,
|
|
1371
|
+
stage: str,
|
|
1372
|
+
source: str | None,
|
|
1373
|
+
donor: str | None,
|
|
1374
|
+
model_clusters,
|
|
1375
|
+
min_margin: float,
|
|
1376
|
+
ranker,
|
|
1377
|
+
conformal_q,
|
|
1378
|
+
) -> list[Hypothesis]:
|
|
1379
|
+
"""Score model replies. A retest adds types for that cluster only."""
|
|
1380
|
+
chosen = None if model_clusters is None else {str(item) for item in model_clusters}
|
|
1381
|
+
found: list[Hypothesis] = []
|
|
1382
|
+
dag, label_map, known_names = _lineage_resources(species or None)
|
|
1383
|
+
tables = wilcoxon_order(adata, cluster_key)
|
|
1384
|
+
for index, record in enumerate(records):
|
|
1385
|
+
cluster = str(record["cluster"])
|
|
1386
|
+
if chosen is not None and cluster not in chosen:
|
|
1387
|
+
continue
|
|
1388
|
+
print(f"run models cluster {cluster} ...", flush=True)
|
|
1389
|
+
model_started = time.perf_counter()
|
|
1390
|
+
payload = cluster_payload(
|
|
1391
|
+
adata,
|
|
1392
|
+
cluster_key,
|
|
1393
|
+
cluster,
|
|
1394
|
+
kept,
|
|
1395
|
+
species=species,
|
|
1396
|
+
tissue=tissue,
|
|
1397
|
+
stage=stage,
|
|
1398
|
+
source=source,
|
|
1399
|
+
donor=donor,
|
|
1400
|
+
tables=tables,
|
|
1401
|
+
)
|
|
1402
|
+
if "marker" in payload:
|
|
1403
|
+
raise RuntimeError("model payload must not contain a marker field")
|
|
1404
|
+
rounds: list[dict[str, object]] = []
|
|
1405
|
+
decision, name_error = _invoke(
|
|
1406
|
+
models.name, payload, fetch_name, _parse_name, SYSTEM_NAME, "name"
|
|
1407
|
+
)
|
|
1408
|
+
name: str | None = None
|
|
1409
|
+
if name_error is not None:
|
|
1410
|
+
record["model_error"] = str(name_error)
|
|
1411
|
+
rounds.append({"step": "name", "status": "failed", "error": str(name_error)})
|
|
1412
|
+
else:
|
|
1413
|
+
name = decision.names[0] if decision is not None and decision.names else None
|
|
1414
|
+
rounds.append(
|
|
1415
|
+
{
|
|
1416
|
+
"step": "name",
|
|
1417
|
+
"action": None if decision is None else decision.action,
|
|
1418
|
+
"names": [] if decision is None else list(decision.names),
|
|
1419
|
+
}
|
|
1420
|
+
)
|
|
1421
|
+
try:
|
|
1422
|
+
positive, measurements = rank_measurements(adata, cluster)
|
|
1423
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
1424
|
+
measurements = None
|
|
1425
|
+
ml_result, ml_error = None, exc
|
|
1426
|
+
else:
|
|
1427
|
+
ml_result, ml_error = _call_ml(models.ml, payload, kept, positive, measurements)
|
|
1428
|
+
if ml_error is not None:
|
|
1429
|
+
ml_result = MlResult("failed", error=_failure_text(ml_error))
|
|
1430
|
+
rounds.append(
|
|
1431
|
+
{
|
|
1432
|
+
"step": "ml",
|
|
1433
|
+
"status": ml_result.status,
|
|
1434
|
+
"names": list(ml_result.names),
|
|
1435
|
+
"error": ml_result.error,
|
|
1436
|
+
}
|
|
1437
|
+
)
|
|
1438
|
+
added: list[Hypothesis] = []
|
|
1439
|
+
if name is not None and record.get("status") != "label":
|
|
1440
|
+
proposal = Decision("retest", (name,))
|
|
1441
|
+
added = _retest_hypotheses(proposal, pool, set(kept_names(kept)))
|
|
1442
|
+
added, _ = _in_matrix(added, set(map(str, adata.var_names)))
|
|
1443
|
+
if added:
|
|
1444
|
+
found.extend(added)
|
|
1445
|
+
updated = _annotate(
|
|
1446
|
+
adata,
|
|
1447
|
+
cluster_key,
|
|
1448
|
+
[*kept, *added],
|
|
1449
|
+
min_margin,
|
|
1450
|
+
ranker,
|
|
1451
|
+
conformal_q,
|
|
1452
|
+
clusters=[cluster],
|
|
1453
|
+
)[0]
|
|
1454
|
+
# The vote views were recorded on the record this one replaces.
|
|
1455
|
+
for field in ("votes", "vote_label", "escalate"):
|
|
1456
|
+
if field in record:
|
|
1457
|
+
updated[field] = record[field]
|
|
1458
|
+
records[index] = updated
|
|
1459
|
+
record = updated
|
|
1460
|
+
if record.get("status") == "label":
|
|
1461
|
+
resolved[cluster] = {cluster: str(record["cell_type"])}
|
|
1462
|
+
ranking = [dict(row) for row in record.get("ranking") or []]
|
|
1463
|
+
top = str(ranking[0]["name"]) if ranking else None
|
|
1464
|
+
labeled = bool(
|
|
1465
|
+
record.get("status") == "label"
|
|
1466
|
+
and record.get("cell_type")
|
|
1467
|
+
and top == str(record["cell_type"])
|
|
1468
|
+
)
|
|
1469
|
+
if top is None and record.get("status") == "label" and record.get("cell_type"):
|
|
1470
|
+
top = str(record["cell_type"])
|
|
1471
|
+
labeled = True
|
|
1472
|
+
settled = settle_cluster(
|
|
1473
|
+
marker=top,
|
|
1474
|
+
name=name,
|
|
1475
|
+
ml=ml_result,
|
|
1476
|
+
added=[item.name for item in added],
|
|
1477
|
+
cells=int(cluster_mask(adata, cluster_key, cluster).sum()),
|
|
1478
|
+
failed=name_error is not None,
|
|
1479
|
+
ontology_names=known_names,
|
|
1480
|
+
species=species or None,
|
|
1481
|
+
ranking=ranking,
|
|
1482
|
+
margin=None if record.get("margin") is None else float(record["margin"]),
|
|
1483
|
+
marker_labeled=labeled,
|
|
1484
|
+
min_margin=min_margin,
|
|
1485
|
+
dag=dag,
|
|
1486
|
+
label_map=label_map,
|
|
1487
|
+
)
|
|
1488
|
+
if settled["written_label"]:
|
|
1489
|
+
resolved[cluster] = {cluster: str(settled["written_label"])}
|
|
1490
|
+
elif settled["final_reason"] == "mixed_programs":
|
|
1491
|
+
resolved.pop(cluster, None)
|
|
1492
|
+
else:
|
|
1493
|
+
mapping = resolved.get(cluster) or {}
|
|
1494
|
+
if mapping and set(mapping) != {cluster}:
|
|
1495
|
+
settled = {
|
|
1496
|
+
**settled,
|
|
1497
|
+
"written_label": None,
|
|
1498
|
+
"hint": None,
|
|
1499
|
+
"arbitration": False,
|
|
1500
|
+
"final_status": "named",
|
|
1501
|
+
"final_reason": "subcluster_labels",
|
|
1502
|
+
}
|
|
1503
|
+
else:
|
|
1504
|
+
resolved.pop(cluster, None)
|
|
1505
|
+
record["rounds"] = rounds
|
|
1506
|
+
record["proposals"] = settled["proposals"]
|
|
1507
|
+
record["added"] = settled["added"]
|
|
1508
|
+
record["written_label"] = settled["written_label"]
|
|
1509
|
+
record["hint"] = settled["hint"]
|
|
1510
|
+
record["arbitration"] = settled["arbitration"]
|
|
1511
|
+
record["final_status"] = settled["final_status"]
|
|
1512
|
+
record["final_reason"] = settled["final_reason"]
|
|
1513
|
+
record["ml_trial"] = [] if measurements is None else _trial_for(payload, kept, measurements)
|
|
1514
|
+
if settled["arbitration"]:
|
|
1515
|
+
arbitration.append(cluster)
|
|
1516
|
+
written = settled.get("written_label") or "no label"
|
|
1517
|
+
print(
|
|
1518
|
+
f"run models cluster {cluster} {time.perf_counter() - model_started:.1f}s {written}",
|
|
1519
|
+
flush=True,
|
|
1520
|
+
)
|
|
1521
|
+
return found
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _invoke(endpoint: TaskEndpoint, payload: dict, fetch, parse, system: str, task: str):
|
|
1525
|
+
"""Ask one task. ``fetch(url, payload)`` serves a URL. ``parse(text, payload)`` reads local text."""
|
|
1526
|
+
try:
|
|
1527
|
+
if endpoint.ask is not None:
|
|
1528
|
+
return endpoint.ask(payload), None
|
|
1529
|
+
if endpoint.adapter:
|
|
1530
|
+
from .local_lora import complete
|
|
1531
|
+
|
|
1532
|
+
text = complete(task, endpoint.system or system, payload, maxlen=endpoint.maxlen)
|
|
1533
|
+
return parse(text, payload), None
|
|
1534
|
+
if endpoint.url:
|
|
1535
|
+
return fetch(endpoint.url, payload), None
|
|
1536
|
+
raise ValueError("task needs a url, an adapter, or an ask function")
|
|
1537
|
+
except Exception as exc:
|
|
1538
|
+
if _is_runtime_failure(exc):
|
|
1539
|
+
return None, exc
|
|
1540
|
+
raise
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def _flag_labels_without_markers(
|
|
1544
|
+
records: list[dict], resolved: dict, marker_status: dict, by_parent: dict
|
|
1545
|
+
) -> bool:
|
|
1546
|
+
"""Flag a written name when none of its cited markers passed.
|
|
1547
|
+
|
|
1548
|
+
The name stays written and the record keeps a hint, so it is still queued
|
|
1549
|
+
for review. The marker call replaces the name only when that call is a
|
|
1550
|
+
different name that did pass. Returns True when a name was replaced.
|
|
1551
|
+
"""
|
|
1552
|
+
blocked = {
|
|
1553
|
+
name
|
|
1554
|
+
for name, status in marker_status.items()
|
|
1555
|
+
if status == "no_marker_passed_expression_check"
|
|
1556
|
+
}
|
|
1557
|
+
if not blocked:
|
|
1558
|
+
return False
|
|
1559
|
+
changed = False
|
|
1560
|
+
for record in records:
|
|
1561
|
+
label = record.get("written_label")
|
|
1562
|
+
cluster = str(record["cluster"])
|
|
1563
|
+
mapping = resolved.get(cluster)
|
|
1564
|
+
if mapping and set(mapping) != {cluster}:
|
|
1565
|
+
failed = {unit for unit, name in mapping.items() if name in blocked}
|
|
1566
|
+
if not failed:
|
|
1567
|
+
continue
|
|
1568
|
+
for child in by_parent.get(cluster, []):
|
|
1569
|
+
if str(child["cluster"]) in failed:
|
|
1570
|
+
child["final_reason"] = "no_marker_passed"
|
|
1571
|
+
child["hint"] = "no marker passed"
|
|
1572
|
+
record["hint"] = "one or more subclusters have no marker that passed validation"
|
|
1573
|
+
continue
|
|
1574
|
+
if label not in blocked or mapping != {cluster: label}:
|
|
1575
|
+
continue
|
|
1576
|
+
marker = record.get("cell_type") if record.get("status") == "label" else None
|
|
1577
|
+
if marker and marker != label and marker not in blocked:
|
|
1578
|
+
changed = True
|
|
1579
|
+
record["written_label"] = marker
|
|
1580
|
+
record["final_status"] = "named"
|
|
1581
|
+
record["final_reason"] = "marker_rule"
|
|
1582
|
+
record["hint"] = None
|
|
1583
|
+
resolved[cluster] = {cluster: str(marker)}
|
|
1584
|
+
continue
|
|
1585
|
+
if record.get("final_reason") == "no_marker_passed":
|
|
1586
|
+
continue
|
|
1587
|
+
record["final_status"] = "named"
|
|
1588
|
+
record["final_reason"] = "no_marker_passed"
|
|
1589
|
+
hint = record.get("hint")
|
|
1590
|
+
record["hint"] = "no marker passed" if not hint else f"{hint}; no marker passed"
|
|
1591
|
+
return changed
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def _parse_name(text: str, payload: dict):
|
|
1595
|
+
return parse_decision(text, hit_names(payload))
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def _parse_ml(text: str, payload: dict):
|
|
1599
|
+
return parse_ml(text, candidate_names(payload))
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _call_ml(endpoint: TaskEndpoint, payload: dict, hypotheses, positive, measurements):
|
|
1603
|
+
user = ml_user_from_hits(payload, hypotheses, measurements, positive)
|
|
1604
|
+
if "marker" in user:
|
|
1605
|
+
raise RuntimeError("ml payload must not contain a marker field")
|
|
1606
|
+
return _invoke(endpoint, user, fetch_ml, _parse_ml, SYSTEM_ML, "ml")
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
def _trial_for(payload: dict, hypotheses, measurements: dict[str, dict]) -> list[dict]:
|
|
1610
|
+
by_name = {item.name: item for item in hypotheses}
|
|
1611
|
+
rows = []
|
|
1612
|
+
for hit in payload.get("dictionary_hits") or []:
|
|
1613
|
+
item = by_name.get(str(hit["name"]))
|
|
1614
|
+
if item is None:
|
|
1615
|
+
continue
|
|
1616
|
+
positive = [measurements[gene] for gene in item.positive if gene in measurements]
|
|
1617
|
+
rows.append(
|
|
1618
|
+
{
|
|
1619
|
+
"name": item.name,
|
|
1620
|
+
**trial_support(positive, list(item.negative), measurements),
|
|
1621
|
+
}
|
|
1622
|
+
)
|
|
1623
|
+
return rows
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _annotate(adata, cluster_key, hypotheses, min_margin, ranker, conformal_q, clusters=None):
|
|
1627
|
+
return annotate(
|
|
1628
|
+
adata,
|
|
1629
|
+
cluster_key,
|
|
1630
|
+
hypotheses,
|
|
1631
|
+
min_margin=min_margin,
|
|
1632
|
+
ranker=ranker,
|
|
1633
|
+
conformal_q=conformal_q,
|
|
1634
|
+
clusters=clusters,
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def kept_names(hypotheses: list[Hypothesis]) -> list[str]:
|
|
1639
|
+
return [item.name for item in hypotheses]
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _children_labeled(children: list[dict[str, object]]) -> bool:
|
|
1643
|
+
return bool(children) and all(row["status"] == "label" for row in children)
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def _child_label_map(children: list[dict[str, object]]) -> dict[str, str]:
|
|
1647
|
+
return {str(row["cluster"]): str(row["cell_type"]) for row in children}
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def _apply_votes(
|
|
1651
|
+
adata, cluster_key, records, by_parent, hypotheses, resolved, species, tissue
|
|
1652
|
+
) -> None:
|
|
1653
|
+
"""Write a label when three views agree. Record every vote either way."""
|
|
1654
|
+
pending = [record for record in records if str(record["cluster"]) not in resolved]
|
|
1655
|
+
if not pending:
|
|
1656
|
+
return
|
|
1657
|
+
parents = parents_for(species=species, tissue=tissue) if species else {}
|
|
1658
|
+
known_names = _lineage_resources(species or None)[2]
|
|
1659
|
+
logfc = _logfc_by_cluster(adata, cluster_key)
|
|
1660
|
+
labeled = {
|
|
1661
|
+
str(record["cluster"]): str(record["cell_type"])
|
|
1662
|
+
for record in records
|
|
1663
|
+
if record["status"] == "label" and record.get("cell_type")
|
|
1664
|
+
}
|
|
1665
|
+
for record in pending:
|
|
1666
|
+
cluster = str(record["cluster"])
|
|
1667
|
+
children = list(by_parent.get(cluster, []))
|
|
1668
|
+
votes = {
|
|
1669
|
+
"panel": panel_vote(record),
|
|
1670
|
+
"genes": gene_vote(hypotheses, logfc.get(cluster, {})),
|
|
1671
|
+
"subcluster": subcluster_vote(children),
|
|
1672
|
+
"neighbors": _neighbor_vote(adata, cluster_key, cluster, labeled),
|
|
1673
|
+
}
|
|
1674
|
+
record["votes"] = votes
|
|
1675
|
+
name = agreed_name(votes, parents)
|
|
1676
|
+
if name is None:
|
|
1677
|
+
record["escalate"] = "views_disagree"
|
|
1678
|
+
continue
|
|
1679
|
+
# A shared parent can be a coarse name the dictionary and ontology do not have.
|
|
1680
|
+
if known_names is not None and name not in known_names:
|
|
1681
|
+
record["escalate"] = "vote_name_not_in_ontology"
|
|
1682
|
+
continue
|
|
1683
|
+
resolved[cluster] = {cluster: name}
|
|
1684
|
+
record["vote_label"] = name
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def _logfc_by_cluster(adata: AnnData, cluster_key: str) -> dict[str, dict[str, float]]:
|
|
1688
|
+
"""Log fold change of every gene, including genes that are lower inside.
|
|
1689
|
+
|
|
1690
|
+
The name payload reads this same Wilcoxon table. A gene detected in fewer
|
|
1691
|
+
than ``NEGATIVE_PRESENT_PCT`` of the cluster's cells is never reported as
|
|
1692
|
+
higher inside, because its fold change is unstable.
|
|
1693
|
+
"""
|
|
1694
|
+
if adata.obs[cluster_key].nunique() < 2 or _variable_genes(adata) < 2:
|
|
1695
|
+
return {}
|
|
1696
|
+
try:
|
|
1697
|
+
wilcoxon_order(adata, cluster_key)
|
|
1698
|
+
except ValueError:
|
|
1699
|
+
return {}
|
|
1700
|
+
result = adata.uns.get("rank_genes_groups")
|
|
1701
|
+
if not result or "names" not in result:
|
|
1702
|
+
return {}
|
|
1703
|
+
tables: dict[str, dict[str, float]] = {}
|
|
1704
|
+
for cluster in adata.obs[cluster_key].astype(str).unique():
|
|
1705
|
+
key = str(cluster)
|
|
1706
|
+
try:
|
|
1707
|
+
names = [str(gene) for gene in result["names"][key]]
|
|
1708
|
+
changes = [float(value) for value in result["logfoldchanges"][key]]
|
|
1709
|
+
except (KeyError, IndexError, TypeError, ValueError):
|
|
1710
|
+
continue
|
|
1711
|
+
detected = detection_fractions(result["pts"], key, names) >= NEGATIVE_PRESENT_PCT
|
|
1712
|
+
tables[key] = {
|
|
1713
|
+
gene: change if seen or change < 0 else 0.0
|
|
1714
|
+
for gene, change, seen in zip(names, changes, detected, strict=True)
|
|
1715
|
+
}
|
|
1716
|
+
return tables
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _apply_neighbor_titles(adata, cluster_key, records, resolved, species, dag, label_map) -> None:
|
|
1720
|
+
"""Give an unclear cluster the title of one clear cluster it touches.
|
|
1721
|
+
|
|
1722
|
+
A title is clear when the name and ML calls both wrote it, or when no model
|
|
1723
|
+
ran and the marker rule wrote it without a hint. An unclear cluster takes
|
|
1724
|
+
that title when the neighbor graph or the UMAP places it with exactly one
|
|
1725
|
+
such lineage. A title the model already wrote on that lineage stays. Two
|
|
1726
|
+
clear lineages do not choose a title; a one-sided name from another lineage
|
|
1727
|
+
is withdrawn and the clear clusters are named in the hint.
|
|
1728
|
+
"""
|
|
1729
|
+
if "connectivities" not in adata.obsp and "X_umap" not in adata.obsm:
|
|
1730
|
+
return
|
|
1731
|
+
by_id = {str(record["cluster"]): record for record in records}
|
|
1732
|
+
clear = {
|
|
1733
|
+
cluster: str(record["written_label"])
|
|
1734
|
+
for cluster, record in by_id.items()
|
|
1735
|
+
if _title_is_clear(record)
|
|
1736
|
+
}
|
|
1737
|
+
if not clear:
|
|
1738
|
+
return
|
|
1739
|
+
linked = _linked_clusters(adata, cluster_key)
|
|
1740
|
+
for record in records:
|
|
1741
|
+
cluster = str(record["cluster"])
|
|
1742
|
+
if cluster in clear or record.get("final_reason") == "subcluster_labels":
|
|
1743
|
+
continue
|
|
1744
|
+
mapping = resolved.get(cluster)
|
|
1745
|
+
if mapping and set(mapping) != {cluster}:
|
|
1746
|
+
continue
|
|
1747
|
+
neighbors = []
|
|
1748
|
+
seen: set[str] = set()
|
|
1749
|
+
for other in sorted(linked.get(cluster, ()), key=_cluster_order):
|
|
1750
|
+
if other not in clear or other in seen:
|
|
1751
|
+
continue
|
|
1752
|
+
seen.add(other)
|
|
1753
|
+
neighbors.append((other, clear[other]))
|
|
1754
|
+
if neighbors:
|
|
1755
|
+
_title_from_clear_neighbors(
|
|
1756
|
+
record, cluster, neighbors, resolved, species, dag, label_map
|
|
1757
|
+
)
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def _title_is_clear(record: dict) -> bool:
|
|
1761
|
+
label = record.get("written_label")
|
|
1762
|
+
if not label or record.get("final_status") != "named":
|
|
1763
|
+
return False
|
|
1764
|
+
if record.get("final_reason") == "subcluster_labels":
|
|
1765
|
+
return False
|
|
1766
|
+
name = _proposal_name(record, "name")
|
|
1767
|
+
ml = _proposal_name(record, "ml")
|
|
1768
|
+
if name == label and ml == label:
|
|
1769
|
+
return True
|
|
1770
|
+
return (
|
|
1771
|
+
not record.get("rounds")
|
|
1772
|
+
and not record.get("hint")
|
|
1773
|
+
and record.get("final_reason") in {"marker_rule", "evidence_agreement", "votes"}
|
|
1774
|
+
)
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
def _proposal_name(record: dict, step: str):
|
|
1778
|
+
value = _model_name(record, step)
|
|
1779
|
+
if value in (None, "escalate", "failed", "refuse"):
|
|
1780
|
+
return None
|
|
1781
|
+
return str(value)
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _title_from_clear_neighbors(record, cluster, neighbors, resolved, species, dag, label_map):
|
|
1785
|
+
labels = list(dict.fromkeys(label for _other, label in neighbors))
|
|
1786
|
+
groups = _label_groups(labels, species, dag, label_map)
|
|
1787
|
+
near = "near " + ", ".join(f"{other} {label}" for other, label in neighbors)
|
|
1788
|
+
written = record.get("written_label")
|
|
1789
|
+
written_fits = bool(written) and any(
|
|
1790
|
+
_same_lineage(str(written), label, species, dag, label_map) for label in labels
|
|
1791
|
+
)
|
|
1792
|
+
if len(groups) != 1:
|
|
1793
|
+
if written and not written_fits:
|
|
1794
|
+
_withdraw_title(record, cluster, near, resolved)
|
|
1795
|
+
else:
|
|
1796
|
+
_join_hint(record, near)
|
|
1797
|
+
return
|
|
1798
|
+
if written_fits:
|
|
1799
|
+
_join_hint(record, near)
|
|
1800
|
+
return
|
|
1801
|
+
chosen = _coarsest(groups[0], species, dag, label_map)
|
|
1802
|
+
if chosen is None:
|
|
1803
|
+
if written and not written_fits:
|
|
1804
|
+
_withdraw_title(record, cluster, near, resolved)
|
|
1805
|
+
else:
|
|
1806
|
+
_join_hint(record, near)
|
|
1807
|
+
return
|
|
1808
|
+
note = _other_calls(record, chosen)
|
|
1809
|
+
record["written_label"] = chosen
|
|
1810
|
+
record["final_status"] = "named"
|
|
1811
|
+
record["final_reason"] = "near_clear_cluster"
|
|
1812
|
+
record["hint"] = "; ".join(part for part in (note, near) if part)
|
|
1813
|
+
resolved[cluster] = {cluster: chosen}
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def _withdraw_title(record, cluster, near, resolved) -> None:
|
|
1817
|
+
note = _other_calls(record, None)
|
|
1818
|
+
record["written_label"] = None
|
|
1819
|
+
record["final_status"] = "review"
|
|
1820
|
+
record["final_reason"] = "near_clear_cluster"
|
|
1821
|
+
record["hint"] = "; ".join(part for part in (note, near) if part)
|
|
1822
|
+
resolved.pop(cluster, None)
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _other_calls(record: dict, title: str | None) -> str:
|
|
1826
|
+
parts = []
|
|
1827
|
+
marker = record.get("cell_type") if record.get("status") == "label" else None
|
|
1828
|
+
if marker and marker != title:
|
|
1829
|
+
parts.append(f"marker {marker}")
|
|
1830
|
+
name = _proposal_name(record, "name")
|
|
1831
|
+
if name and name != title:
|
|
1832
|
+
parts.append(f"name {name}")
|
|
1833
|
+
ml = _proposal_name(record, "ml")
|
|
1834
|
+
if ml and ml != title:
|
|
1835
|
+
parts.append(f"ml {ml}")
|
|
1836
|
+
return ", ".join(parts)
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _join_hint(record: dict, extra: str) -> None:
|
|
1840
|
+
current = record.get("hint")
|
|
1841
|
+
record["hint"] = extra if not current else f"{current}; {extra}"
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def _label_groups(names: list[str], species, dag, label_map) -> list[list[str]]:
|
|
1845
|
+
parent = list(range(len(names)))
|
|
1846
|
+
|
|
1847
|
+
def find(index: int) -> int:
|
|
1848
|
+
while parent[index] != index:
|
|
1849
|
+
parent[index] = parent[parent[index]]
|
|
1850
|
+
index = parent[index]
|
|
1851
|
+
return index
|
|
1852
|
+
|
|
1853
|
+
for left in range(len(names)):
|
|
1854
|
+
for right in range(left + 1, len(names)):
|
|
1855
|
+
if _same_lineage(names[left], names[right], species, dag, label_map):
|
|
1856
|
+
parent[find(right)] = find(left)
|
|
1857
|
+
groups: dict[int, list[str]] = {}
|
|
1858
|
+
for index, name in enumerate(names):
|
|
1859
|
+
groups.setdefault(find(index), []).append(name)
|
|
1860
|
+
return list(groups.values())
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def _coarsest(names: list[str], species, dag, label_map) -> str | None:
|
|
1864
|
+
unique = list(dict.fromkeys(names))
|
|
1865
|
+
if len(unique) == 1:
|
|
1866
|
+
return unique[0]
|
|
1867
|
+
parents = [
|
|
1868
|
+
name
|
|
1869
|
+
for name in unique
|
|
1870
|
+
if all(
|
|
1871
|
+
other == name or _is_descendant(other, name, species, dag, label_map)
|
|
1872
|
+
for other in unique
|
|
1873
|
+
)
|
|
1874
|
+
]
|
|
1875
|
+
if len(parents) == 1:
|
|
1876
|
+
return parents[0]
|
|
1877
|
+
return None
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
def _linked_clusters(adata: AnnData, cluster_key: str) -> dict[str, set[str]]:
|
|
1881
|
+
"""Clusters joined by the neighbor graph or by touching on the UMAP."""
|
|
1882
|
+
labels = adata.obs[cluster_key].astype(str).to_numpy()
|
|
1883
|
+
linked = {str(cluster): set() for cluster in pd.unique(labels)}
|
|
1884
|
+
if "connectivities" in adata.obsp:
|
|
1885
|
+
_add_links(linked, _connectivity_ratios(labels, adata.obsp["connectivities"]))
|
|
1886
|
+
if "X_umap" in adata.obsm:
|
|
1887
|
+
adjacency = _umap_adjacency(np.asarray(adata.obsm["X_umap"], dtype=float))
|
|
1888
|
+
_add_links(linked, _connectivity_ratios(labels, adjacency))
|
|
1889
|
+
return linked
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
def _add_links(linked: dict[str, set[str]], ratios: dict[str, list[tuple[str, float]]]) -> None:
|
|
1893
|
+
for cluster, pairs in ratios.items():
|
|
1894
|
+
linked.setdefault(cluster, set()).update(other for other, _ratio in pairs)
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
def _connectivity_ratios(labels: np.ndarray, adjacency) -> dict[str, list[tuple[str, float]]]:
|
|
1898
|
+
"""PAGA's observed-over-expected edge ratio for each pair of clusters.
|
|
1899
|
+
|
|
1900
|
+
A pair is returned only when the ratio is at least 1, the same comparison
|
|
1901
|
+
scanpy uses before capping the value for display.
|
|
1902
|
+
"""
|
|
1903
|
+
matrix = sparse.csr_matrix(adjacency)
|
|
1904
|
+
n = matrix.shape[0]
|
|
1905
|
+
empty: dict[str, list[tuple[str, float]]] = {}
|
|
1906
|
+
if n == 0 or n != len(labels) or matrix.shape[1] != n:
|
|
1907
|
+
return empty
|
|
1908
|
+
ones = matrix.copy()
|
|
1909
|
+
ones.data = np.ones(len(ones.data), dtype=float)
|
|
1910
|
+
ones.setdiag(0)
|
|
1911
|
+
ones.eliminate_zeros()
|
|
1912
|
+
clusters = [str(cluster) for cluster in pd.unique(labels)]
|
|
1913
|
+
index = {name: pos for pos, name in enumerate(clusters)}
|
|
1914
|
+
codes = np.array([index[str(label)] for label in labels])
|
|
1915
|
+
size = len(clusters)
|
|
1916
|
+
outgoing = np.zeros((size, size), dtype=float)
|
|
1917
|
+
internal = np.zeros(size, dtype=float)
|
|
1918
|
+
coo = ones.tocoo()
|
|
1919
|
+
for row, col in zip(coo.row, coo.col, strict=True):
|
|
1920
|
+
left = codes[row]
|
|
1921
|
+
right = codes[col]
|
|
1922
|
+
if left == right:
|
|
1923
|
+
internal[left] += 1.0
|
|
1924
|
+
else:
|
|
1925
|
+
outgoing[left, right] += 1.0
|
|
1926
|
+
counts = np.bincount(codes, minlength=size).astype(float)
|
|
1927
|
+
strength = internal + outgoing.sum(axis=1)
|
|
1928
|
+
between = outgoing + outgoing.T
|
|
1929
|
+
result = {name: [] for name in clusters}
|
|
1930
|
+
denom = max(n - 1, 1)
|
|
1931
|
+
for left in range(size):
|
|
1932
|
+
for right in range(left + 1, size):
|
|
1933
|
+
observed = between[left, right]
|
|
1934
|
+
if observed <= 0:
|
|
1935
|
+
continue
|
|
1936
|
+
expected = (strength[left] * counts[right] + strength[right] * counts[left]) / denom
|
|
1937
|
+
ratio = observed / expected if expected else 1.0
|
|
1938
|
+
if ratio >= 1:
|
|
1939
|
+
result[clusters[left]].append((clusters[right], float(ratio)))
|
|
1940
|
+
result[clusters[right]].append((clusters[left], float(ratio)))
|
|
1941
|
+
return result
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
def _umap_adjacency(coords: np.ndarray):
|
|
1945
|
+
"""Cells within one typical UMAP neighbor-spacing of each other."""
|
|
1946
|
+
from scipy.spatial import cKDTree
|
|
1947
|
+
|
|
1948
|
+
n = coords.shape[0]
|
|
1949
|
+
if n < 2:
|
|
1950
|
+
return sparse.csr_matrix((n, n))
|
|
1951
|
+
distances, _indexes = cKDTree(coords).query(coords, k=2)
|
|
1952
|
+
spacing = float(np.median(distances[:, 1]))
|
|
1953
|
+
if not np.isfinite(spacing) or spacing <= 0:
|
|
1954
|
+
return sparse.csr_matrix((n, n))
|
|
1955
|
+
rows, cols = cKDTree(coords).query_pairs(spacing, output_type="ndarray").T
|
|
1956
|
+
if len(rows) == 0:
|
|
1957
|
+
return sparse.csr_matrix((n, n))
|
|
1958
|
+
data = np.ones(len(rows) * 2, dtype=float)
|
|
1959
|
+
return sparse.csr_matrix(
|
|
1960
|
+
(data, (np.concatenate([rows, cols]), np.concatenate([cols, rows]))),
|
|
1961
|
+
shape=(n, n),
|
|
1962
|
+
)
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
def _neighbor_vote(
|
|
1966
|
+
adata: AnnData, cluster_key: str, cluster: str, labeled: dict[str, str]
|
|
1967
|
+
) -> str | None:
|
|
1968
|
+
if "connectivities" not in adata.obsp:
|
|
1969
|
+
return None
|
|
1970
|
+
labels = adata.obs[cluster_key].astype(str).to_numpy()
|
|
1971
|
+
inside = np.flatnonzero(labels == str(cluster))
|
|
1972
|
+
if len(inside) == 0:
|
|
1973
|
+
return None
|
|
1974
|
+
weights = np.asarray(adata.obsp["connectivities"][inside].sum(axis=0)).ravel()
|
|
1975
|
+
names = pd.Series(labels).map(labeled).to_numpy(dtype=object)
|
|
1976
|
+
usable = (weights > 0) & (labels != str(cluster)) & pd.notna(names)
|
|
1977
|
+
if not usable.any():
|
|
1978
|
+
return None
|
|
1979
|
+
totals = pd.Series(weights[usable]).groupby(names[usable]).sum()
|
|
1980
|
+
if totals.sum() <= 0:
|
|
1981
|
+
return None
|
|
1982
|
+
name = str(totals.idxmax())
|
|
1983
|
+
if totals.max() / totals.sum() < 0.15:
|
|
1984
|
+
return None
|
|
1985
|
+
return name
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
def _resolved_labels(adata, cluster_key, records, by_parent) -> dict[str, dict[str, str]]:
|
|
1989
|
+
resolved: dict[str, dict[str, str]] = {}
|
|
1990
|
+
for record in records:
|
|
1991
|
+
cluster = str(record["cluster"])
|
|
1992
|
+
if record["status"] == "label":
|
|
1993
|
+
resolved[cluster] = {cluster: str(record["cell_type"])}
|
|
1994
|
+
continue
|
|
1995
|
+
children = list(by_parent.get(cluster, []))
|
|
1996
|
+
if _children_labeled(children):
|
|
1997
|
+
resolved[cluster] = _child_label_map(children)
|
|
1998
|
+
return resolved
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def _retest_hypotheses(decision, pool: list[Hypothesis], used: set[str]) -> list[Hypothesis]:
|
|
2002
|
+
by_name = {item.name: item for item in pool}
|
|
2003
|
+
added: list[Hypothesis] = []
|
|
2004
|
+
for name in decision.names:
|
|
2005
|
+
item = by_name.get(name)
|
|
2006
|
+
if item is not None and name not in used:
|
|
2007
|
+
added.append(item)
|
|
2008
|
+
used.add(name)
|
|
2009
|
+
for item in decision.markers:
|
|
2010
|
+
if item.name not in used:
|
|
2011
|
+
added.append(item)
|
|
2012
|
+
used.add(item.name)
|
|
2013
|
+
return added
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
def _replace_record(records: list[dict[str, object]], updated: dict[str, object]) -> None:
|
|
2017
|
+
for index, record in enumerate(records):
|
|
2018
|
+
if str(record["cluster"]) == str(updated["cluster"]):
|
|
2019
|
+
records[index] = updated
|
|
2020
|
+
return
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def _assign_labels(adata, cluster_key, resolved: dict[str, dict[str, str]]):
|
|
2024
|
+
import pandas as pd
|
|
2025
|
+
|
|
2026
|
+
labels = pd.Series(pd.NA, index=adata.obs_names, dtype="string")
|
|
2027
|
+
clusters = adata.obs[cluster_key].astype(str)
|
|
2028
|
+
sub = adata.obs[SUBCLUSTER_KEY].astype(str) if SUBCLUSTER_KEY in adata.obs.columns else None
|
|
2029
|
+
for cluster, mapping in resolved.items():
|
|
2030
|
+
mask = clusters == cluster
|
|
2031
|
+
if set(mapping) == {cluster}:
|
|
2032
|
+
labels.loc[mask] = mapping[cluster]
|
|
2033
|
+
continue
|
|
2034
|
+
if sub is None:
|
|
2035
|
+
continue
|
|
2036
|
+
labels.loc[mask] = sub.loc[mask].map(mapping)
|
|
2037
|
+
return labels
|