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,68 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""AnnoTuneAgent public API.
|
|
5
|
+
|
|
6
|
+
Recommended usage::
|
|
7
|
+
|
|
8
|
+
import annotuneagent as ata
|
|
9
|
+
|
|
10
|
+
records = ata.annotate(adata, cluster_key="leiden", hypotheses=[...])
|
|
11
|
+
adata = ata.read_10x(base_dir, ["sample_a", "sample_b"])
|
|
12
|
+
prepared = ata.prepare(adata, species="human", batch_key="sample")
|
|
13
|
+
report = ata.run(
|
|
14
|
+
prepared, "leiden", species="human",
|
|
15
|
+
models=ata.local_models(model_dir),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
``annotate`` scores caller-supplied hypotheses for each cluster. ``run``
|
|
19
|
+
collects three results, the marker score, the name model, and the ML model,
|
|
20
|
+
then a judgment writes the title to ``annotune_label``. The title is a
|
|
21
|
+
dictionary name. A cluster can stay unnamed. Uncertain clusters are
|
|
22
|
+
subclustered once. The name and ML models run when a ``ModelChain`` is
|
|
23
|
+
supplied. The name model copies a cell-type name from the marker dictionary.
|
|
24
|
+
The ML model is trained on machine-learning measurements of the cluster,
|
|
25
|
+
differential expression and marker-panel statistics, and copies a name from
|
|
26
|
+
those candidates.
|
|
27
|
+
``local_models`` takes the directory containing the Q6_K base and the name and
|
|
28
|
+
ml adapters. The tokenizer is installed with the package. The server is
|
|
29
|
+
``http://127.0.0.1:8080`` unless ``url`` or ``ANNOTUNE_URL`` says otherwise.
|
|
30
|
+
A matching local server is reused. Remote name and ML services use separate
|
|
31
|
+
``TaskEndpoint(url=...)`` values. ``attach`` connects to an existing service
|
|
32
|
+
without loading weights.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from ._version import __version__
|
|
38
|
+
from .annotate import annotate
|
|
39
|
+
from .evaluate import evaluate
|
|
40
|
+
from .hypotheses import Hypothesis
|
|
41
|
+
from .local_lora import attach, local_server
|
|
42
|
+
from .markers import hypotheses_from_names, marker_names, suggest_tissues, tissue_pages
|
|
43
|
+
from .mltools import conformal_quantile, fit_ranker
|
|
44
|
+
from .prepare import prepare
|
|
45
|
+
from .read import read_10x
|
|
46
|
+
from .workflow import ModelChain, TaskEndpoint, dictionary_hypotheses, local_models, run
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Hypothesis",
|
|
50
|
+
"__version__",
|
|
51
|
+
"annotate",
|
|
52
|
+
"attach",
|
|
53
|
+
"conformal_quantile",
|
|
54
|
+
"dictionary_hypotheses",
|
|
55
|
+
"evaluate",
|
|
56
|
+
"fit_ranker",
|
|
57
|
+
"hypotheses_from_names",
|
|
58
|
+
"ModelChain",
|
|
59
|
+
"TaskEndpoint",
|
|
60
|
+
"local_models",
|
|
61
|
+
"local_server",
|
|
62
|
+
"marker_names",
|
|
63
|
+
"prepare",
|
|
64
|
+
"read_10x",
|
|
65
|
+
"run",
|
|
66
|
+
"suggest_tissues",
|
|
67
|
+
"tissue_pages",
|
|
68
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Package version."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
__all__ = ["__version__", "version", "version_tuple"]
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
version = __version__
|
|
12
|
+
|
|
13
|
+
_parts: list[int | str] = []
|
|
14
|
+
for _seg in __version__.split("."):
|
|
15
|
+
try:
|
|
16
|
+
_parts.append(int(_seg))
|
|
17
|
+
except ValueError:
|
|
18
|
+
_parts.append(_seg)
|
|
19
|
+
version_tuple: tuple[int | str, ...] = tuple(_parts)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Fixed cluster annotation loop."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Collection
|
|
9
|
+
|
|
10
|
+
from anndata import AnnData
|
|
11
|
+
|
|
12
|
+
from .arbitrate import DEFAULT_MIN_MARGIN, arbitrate, score_hypothesis
|
|
13
|
+
from .evidence import (
|
|
14
|
+
cluster_ids,
|
|
15
|
+
cluster_mask,
|
|
16
|
+
evidence_from_stats,
|
|
17
|
+
marker_statistics,
|
|
18
|
+
undeclared_groups,
|
|
19
|
+
validate_expression,
|
|
20
|
+
)
|
|
21
|
+
from .hypotheses import Hypothesis
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def annotate(
|
|
25
|
+
adata: AnnData,
|
|
26
|
+
cluster_key: str,
|
|
27
|
+
hypotheses: list[Hypothesis],
|
|
28
|
+
*,
|
|
29
|
+
min_margin: float = DEFAULT_MIN_MARGIN,
|
|
30
|
+
ranker: object | None = None,
|
|
31
|
+
conformal_q: float | None = None,
|
|
32
|
+
clusters: Collection[str] | None = None,
|
|
33
|
+
) -> list[dict[str, object]]:
|
|
34
|
+
"""Score each cluster against ``hypotheses`` and return one record per cluster.
|
|
35
|
+
|
|
36
|
+
``clusters`` limits the records to those cluster ids. Each cluster is still
|
|
37
|
+
compared with every other cell, so its record is the same either way.
|
|
38
|
+
|
|
39
|
+
Expression is read from ``adata.X``. Genes named by a hypothesis must be in
|
|
40
|
+
``var_names``. Declared markers are scored together across clusters, using
|
|
41
|
+
every CPU. Mann-Whitney p-values are reported and do not change the score.
|
|
42
|
+
"""
|
|
43
|
+
if adata.n_obs == 0:
|
|
44
|
+
raise ValueError("AnnData has no cells")
|
|
45
|
+
if not hypotheses:
|
|
46
|
+
raise ValueError("at least one hypothesis is required")
|
|
47
|
+
validate_expression(adata)
|
|
48
|
+
ids = cluster_ids(adata, cluster_key)
|
|
49
|
+
if clusters is not None:
|
|
50
|
+
wanted = {str(item) for item in clusters}
|
|
51
|
+
ids = [item for item in ids if item in wanted]
|
|
52
|
+
masks = [cluster_mask(adata, cluster_key, cluster) for cluster in ids]
|
|
53
|
+
genes = list(
|
|
54
|
+
dict.fromkeys(
|
|
55
|
+
gene
|
|
56
|
+
for hypothesis in hypotheses
|
|
57
|
+
for gene in (*hypothesis.positive, *hypothesis.negative, *hypothesis.support)
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
measured = marker_statistics(adata, masks, genes)
|
|
61
|
+
declared = set(genes)
|
|
62
|
+
extras = undeclared_groups(adata, masks, declared)
|
|
63
|
+
records: list[dict[str, object]] = []
|
|
64
|
+
for cluster, cluster_stats, extra in zip(ids, measured, extras, strict=True):
|
|
65
|
+
scored = []
|
|
66
|
+
for hypothesis in hypotheses:
|
|
67
|
+
positive = [
|
|
68
|
+
evidence_from_stats(gene, "positive", cluster_stats[gene])
|
|
69
|
+
for gene in hypothesis.positive
|
|
70
|
+
]
|
|
71
|
+
negative = [
|
|
72
|
+
evidence_from_stats(gene, "negative", cluster_stats[gene])
|
|
73
|
+
for gene in hypothesis.negative
|
|
74
|
+
]
|
|
75
|
+
support = [
|
|
76
|
+
evidence_from_stats(gene, "positive", cluster_stats[gene])
|
|
77
|
+
for gene in hypothesis.support
|
|
78
|
+
]
|
|
79
|
+
scored.append(score_hypothesis(hypothesis, positive, negative, support))
|
|
80
|
+
if ranker is not None:
|
|
81
|
+
from .mltools import apply_ranker
|
|
82
|
+
|
|
83
|
+
scored = apply_ranker(scored, ranker)
|
|
84
|
+
records.append(
|
|
85
|
+
arbitrate(
|
|
86
|
+
cluster,
|
|
87
|
+
scored,
|
|
88
|
+
min_margin=min_margin,
|
|
89
|
+
de_not_in_markers=extra,
|
|
90
|
+
conformal_q=conformal_q,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return records
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Turn gene evidence into one cluster record. This is not a model tool."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .evidence import NEGATIVE_PRESENT_PCT, GeneEvidence, negative_penalty, positive_contribution
|
|
11
|
+
from .hypotheses import Hypothesis
|
|
12
|
+
|
|
13
|
+
# Score gap below this refuses a hard label.
|
|
14
|
+
DEFAULT_MIN_MARGIN = 0.15
|
|
15
|
+
# A positive panel is on when at least half of its genes are detected in the cluster.
|
|
16
|
+
_PROGRAM_PCT = 0.5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ScoredHypothesis:
|
|
21
|
+
hypothesis: Hypothesis
|
|
22
|
+
positive: tuple[GeneEvidence, ...]
|
|
23
|
+
negative: tuple[GeneEvidence, ...]
|
|
24
|
+
score: float
|
|
25
|
+
penalty: float
|
|
26
|
+
|
|
27
|
+
def as_evidence(self) -> dict[str, object]:
|
|
28
|
+
return {
|
|
29
|
+
"positive": [gene.as_dict() for gene in self.positive],
|
|
30
|
+
"negative": [gene.as_dict() for gene in self.negative],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def score_hypothesis(
|
|
35
|
+
hypothesis: Hypothesis,
|
|
36
|
+
positive: list[GeneEvidence],
|
|
37
|
+
negative: list[GeneEvidence],
|
|
38
|
+
support: list[GeneEvidence] | None = None,
|
|
39
|
+
) -> ScoredHypothesis:
|
|
40
|
+
# A panel gene detected in fewer than 25% of the cells is absent here.
|
|
41
|
+
# It stays out of the mean so it cannot dilute a gene that is actually high.
|
|
42
|
+
detected = [gene for gene in positive if gene.pct_in >= NEGATIVE_PRESENT_PCT]
|
|
43
|
+
# Two detected genes can be averaged without the absent ones.
|
|
44
|
+
# One detected gene must not stand in for the whole panel.
|
|
45
|
+
used = detected if len(detected) >= 2 else positive
|
|
46
|
+
parts = [positive_contribution(gene) for gene in used]
|
|
47
|
+
score = float(sum(parts) / len(parts)) if parts else 0.0
|
|
48
|
+
score += float(sum(positive_contribution(gene) for gene in support or []))
|
|
49
|
+
fired = [value for value in (negative_penalty(gene) for gene in negative) if value > 0]
|
|
50
|
+
# One opposing gene weighs like one positive gene. Subtracting the whole
|
|
51
|
+
# logfc let a single shared gene erase a specific panel.
|
|
52
|
+
penalty = float(sum(fired) / len(parts)) if fired and parts else float(sum(fired))
|
|
53
|
+
return ScoredHypothesis(
|
|
54
|
+
hypothesis=hypothesis,
|
|
55
|
+
positive=tuple(positive),
|
|
56
|
+
negative=tuple(negative),
|
|
57
|
+
score=score - penalty,
|
|
58
|
+
penalty=penalty,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def program_detected(genes: tuple[GeneEvidence, ...] | list[GeneEvidence]) -> bool:
|
|
63
|
+
"""True when two positive genes, and at least half the panel, are detected.
|
|
64
|
+
|
|
65
|
+
One detected gene is not a second program. A two-gene panel therefore needs
|
|
66
|
+
both genes, including when the other gene is ambient.
|
|
67
|
+
"""
|
|
68
|
+
if len(genes) < 2:
|
|
69
|
+
return False
|
|
70
|
+
detected = sum(gene.pct_in >= _PROGRAM_PCT for gene in genes)
|
|
71
|
+
return detected >= 2 and detected * 2 >= len(genes)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def arbitrate(
|
|
75
|
+
cluster: str,
|
|
76
|
+
scored: list[ScoredHypothesis],
|
|
77
|
+
*,
|
|
78
|
+
min_margin: float = DEFAULT_MIN_MARGIN,
|
|
79
|
+
de_not_in_markers: list[str] | None = None,
|
|
80
|
+
conformal_q: float | None = None,
|
|
81
|
+
) -> dict[str, object]:
|
|
82
|
+
if len(scored) < 1:
|
|
83
|
+
raise ValueError("no hypotheses to arbitrate")
|
|
84
|
+
ranked = sorted(scored, key=lambda item: (-item.score, item.hypothesis.name))
|
|
85
|
+
top = ranked[0]
|
|
86
|
+
runner = ranked[1] if len(ranked) > 1 else None
|
|
87
|
+
margin = top.score if runner is None else top.score - runner.score
|
|
88
|
+
accepted: list[str] | None = None
|
|
89
|
+
if conformal_q is not None:
|
|
90
|
+
from .mltools import prediction_set
|
|
91
|
+
|
|
92
|
+
accepted = prediction_set(ranked, conformal_q)
|
|
93
|
+
status = "label"
|
|
94
|
+
if top.penalty > 0 or not any(positive_contribution(gene) > 0 for gene in top.positive):
|
|
95
|
+
status = "uncertain"
|
|
96
|
+
elif accepted is not None:
|
|
97
|
+
status = "label" if accepted == [top.hypothesis.name] else "uncertain"
|
|
98
|
+
elif runner is not None and margin < min_margin:
|
|
99
|
+
status = "uncertain"
|
|
100
|
+
cell_type = top.hypothesis.name if status == "label" else None
|
|
101
|
+
reasoning = _reasoning(top, status)
|
|
102
|
+
return {
|
|
103
|
+
"cluster": str(cluster),
|
|
104
|
+
"status": status,
|
|
105
|
+
"cell_type": cell_type,
|
|
106
|
+
"margin": margin,
|
|
107
|
+
"score": top.score,
|
|
108
|
+
"runner_up": None if runner is None else runner.hypothesis.name,
|
|
109
|
+
"reasoning": reasoning,
|
|
110
|
+
"evidence": {
|
|
111
|
+
**top.as_evidence(),
|
|
112
|
+
"de_not_in_markers": list(de_not_in_markers or []),
|
|
113
|
+
},
|
|
114
|
+
"ranking": [
|
|
115
|
+
{
|
|
116
|
+
"name": item.hypothesis.name,
|
|
117
|
+
"score": item.score,
|
|
118
|
+
"program_on": program_detected(item.positive),
|
|
119
|
+
}
|
|
120
|
+
for item in ranked
|
|
121
|
+
],
|
|
122
|
+
"prediction_set": accepted,
|
|
123
|
+
"hypotheses_tested": [item.hypothesis.name for item in ranked],
|
|
124
|
+
"used_tools": ["score_positive_markers", "score_negative_markers", "run_de"],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _reasoning(top: ScoredHypothesis, status: str) -> str:
|
|
129
|
+
up = [gene.gene for gene in top.positive if positive_contribution(gene) > 0]
|
|
130
|
+
against = [gene.gene for gene in top.negative if negative_penalty(gene) > 0]
|
|
131
|
+
parts: list[str] = []
|
|
132
|
+
if up:
|
|
133
|
+
parts.append(f"{', '.join(up)} enriched in the cluster")
|
|
134
|
+
if against:
|
|
135
|
+
parts.append(f"{', '.join(against)} enriched despite being negative markers")
|
|
136
|
+
if not parts:
|
|
137
|
+
parts.append("no positive enrichment")
|
|
138
|
+
sentence = "; ".join(parts) + "."
|
|
139
|
+
if status == "uncertain":
|
|
140
|
+
return sentence + " No hard label."
|
|
141
|
+
return sentence
|
annotuneagent/cli.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Read an h5ad, score clusters, write JSON."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import anndata as ad
|
|
15
|
+
import scanpy as sc
|
|
16
|
+
|
|
17
|
+
from .evaluate import evaluate
|
|
18
|
+
from .hypotheses import Hypothesis
|
|
19
|
+
from .local_lora import packaged_tokenizer
|
|
20
|
+
from .markers import hypotheses_from_names, parents_for, suggest_tissues, tissue_pages
|
|
21
|
+
from .prepare import install_hint, prepare
|
|
22
|
+
from .workflow import ModelChain, TaskEndpoint, dictionary_hypotheses, run
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _endpoint(
|
|
26
|
+
url: str | None,
|
|
27
|
+
adapter: str | None,
|
|
28
|
+
base: str,
|
|
29
|
+
maxlen: int,
|
|
30
|
+
tokenizer: str | None,
|
|
31
|
+
server: str | None,
|
|
32
|
+
) -> TaskEndpoint:
|
|
33
|
+
return TaskEndpoint(
|
|
34
|
+
url=url, adapter=adapter, base=base, maxlen=maxlen, tokenizer=tokenizer, server=server
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _models(args, parser) -> ModelChain | None:
|
|
39
|
+
specified = [
|
|
40
|
+
args.name_url,
|
|
41
|
+
args.ml_url,
|
|
42
|
+
args.name_adapter,
|
|
43
|
+
args.ml_adapter,
|
|
44
|
+
]
|
|
45
|
+
if not any(specified):
|
|
46
|
+
return None
|
|
47
|
+
if not (args.name_url or args.name_adapter):
|
|
48
|
+
parser.error("name needs --name-url or --name-adapter")
|
|
49
|
+
if not (args.ml_url or args.ml_adapter):
|
|
50
|
+
parser.error("ml needs --ml-url or --ml-adapter")
|
|
51
|
+
if any((args.name_adapter, args.ml_adapter)):
|
|
52
|
+
if args.name_url or args.ml_url:
|
|
53
|
+
parser.error("local adapters cannot be combined with --name-url or --ml-url")
|
|
54
|
+
if not (args.name_adapter and args.ml_adapter):
|
|
55
|
+
parser.error("local adapters require both --name-adapter and --ml-adapter")
|
|
56
|
+
if args.base is None:
|
|
57
|
+
parser.error("local adapters require --base GGUF")
|
|
58
|
+
return ModelChain(
|
|
59
|
+
name=_endpoint(
|
|
60
|
+
args.name_url,
|
|
61
|
+
args.name_adapter,
|
|
62
|
+
args.base or "",
|
|
63
|
+
2048,
|
|
64
|
+
packaged_tokenizer(),
|
|
65
|
+
args.server,
|
|
66
|
+
),
|
|
67
|
+
ml=_endpoint(
|
|
68
|
+
args.ml_url, args.ml_adapter, args.base or "", 8192, packaged_tokenizer(), args.server
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _hypotheses_from_file(path: Path) -> list[Hypothesis]:
|
|
74
|
+
payload = json.loads(path.read_text())
|
|
75
|
+
if not isinstance(payload, list):
|
|
76
|
+
raise ValueError("hypotheses file must be a JSON list")
|
|
77
|
+
hypotheses: list[Hypothesis] = []
|
|
78
|
+
for item in payload:
|
|
79
|
+
hypotheses.append(
|
|
80
|
+
Hypothesis(
|
|
81
|
+
str(item["name"]),
|
|
82
|
+
positive=tuple(item["positive"]),
|
|
83
|
+
negative=tuple(item.get("negative", ())),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return hypotheses
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(argv: list[str] | None = None) -> int:
|
|
90
|
+
parser = argparse.ArgumentParser(prog="annotuneagent")
|
|
91
|
+
parser.add_argument("--input", type=Path, help="count or clustered .h5ad")
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--cluster-key", help="obs column of clusters; default leiden after --prepare"
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--output",
|
|
97
|
+
type=Path,
|
|
98
|
+
help="JSON report; a .markers.pdf is written beside it when markers pass validation",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--prepare", action="store_true", help="filter, select genes, and cluster before annotation"
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--batch-key", help="obs column for per-sample QC, HVG, and ambient subtraction"
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--batch-correction",
|
|
108
|
+
choices=["none", "harmony", "combat", "scanorama", "scvi", "scvi+harmony"],
|
|
109
|
+
default="none",
|
|
110
|
+
help=(
|
|
111
|
+
"neighbor representation for Leiden and UMAP; "
|
|
112
|
+
"scvi and scvi+harmony use raw counts and can use GPU"
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument("--doublet-method", choices=["none", "scrublet", "solo"], default="none")
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--doublet-chip",
|
|
118
|
+
choices=["10k", "20k"],
|
|
119
|
+
default="10k",
|
|
120
|
+
help=(
|
|
121
|
+
"multiplet rate for Scrublet and SOLO from recovered cells: "
|
|
122
|
+
"10k is 0.8%% per 1,000, 20k is 0.4%% per 1,000"
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--solo-expected-rates",
|
|
127
|
+
type=Path,
|
|
128
|
+
help="JSON map of sample to expected doublet fraction; requires --doublet-method solo",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--scvi-model-dir", type=Path, help="save the trained scVI integration model"
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--denoise", action="store_true", help="subtract ambient RNA with AmbiDose; requires --raw"
|
|
135
|
+
)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--raw",
|
|
138
|
+
type=Path,
|
|
139
|
+
help=(
|
|
140
|
+
"unfiltered counts for AmbiDose: one matrix, or a parent folder "
|
|
141
|
+
"with one sample subfolder each (raw_feature_bc_matrix.h5)"
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument("--resolution", type=float, help="Leiden resolution; default 0.4")
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"--n-pcs",
|
|
147
|
+
type=int,
|
|
148
|
+
help="PCA count, or the scVI latent size; default is the variance elbow, or 30 for scvi and scvi+harmony",
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument("--n-top-genes", type=int, help="highly variable genes; default 2000")
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
"--norm",
|
|
153
|
+
choices=["log1p", "shifted"],
|
|
154
|
+
default="log1p",
|
|
155
|
+
help="log1p scales to 10000 counts; shifted scales to the median library size",
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--scfair", action="store_true", help="use scFair for variable genes and the resolution"
|
|
159
|
+
)
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"--max-pct-mt",
|
|
162
|
+
type=float,
|
|
163
|
+
help="mitochondrial percent cap from 0 to 100; 10 means 10%%",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument("--output-h5ad", type=Path, help="write workflow labels to this .h5ad")
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--types",
|
|
168
|
+
action="append",
|
|
169
|
+
help="built-in dictionary name; repeat for each candidate",
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument("--hypotheses", type=Path, help="JSON list of {name, positive, negative}")
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--catalog",
|
|
174
|
+
type=Path,
|
|
175
|
+
help="extra dictionary types scored when a cluster is unresolved; with --species, the species dictionary",
|
|
176
|
+
)
|
|
177
|
+
parser.add_argument(
|
|
178
|
+
"--species",
|
|
179
|
+
choices=["human", "mouse"],
|
|
180
|
+
help="human or mouse; required for --prepare, --tissue, and --types",
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument("--tissue", help="adds that tissue page to the core dictionary")
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"--list-tissues",
|
|
185
|
+
action="store_true",
|
|
186
|
+
help="print tissue pages and exit; with --author-tissue, print the matching pages",
|
|
187
|
+
)
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--author-tissue",
|
|
190
|
+
help="author tissue phrase, such as DRG; choose one printed page with --tissue",
|
|
191
|
+
)
|
|
192
|
+
parser.add_argument("--label-key", help="author labels in obs, used only for evaluation")
|
|
193
|
+
parser.add_argument("--min-margin", type=float, default=None)
|
|
194
|
+
parser.add_argument(
|
|
195
|
+
"--server",
|
|
196
|
+
help="server root, default http://127.0.0.1:8080; reuse requires matching GGUF and adapters",
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument("--name-url", help="name adapter server root")
|
|
199
|
+
parser.add_argument("--ml-url", help="ml adapter server root")
|
|
200
|
+
parser.add_argument("--name-adapter", help="name LoRA GGUF; loaded with ml on one base")
|
|
201
|
+
parser.add_argument("--ml-adapter", help="ml LoRA GGUF; enabled only for the ml request")
|
|
202
|
+
parser.add_argument("--base", help="GGUF base model for local adapters")
|
|
203
|
+
args = parser.parse_args(argv)
|
|
204
|
+
if args.list_tissues:
|
|
205
|
+
pages = (
|
|
206
|
+
suggest_tissues(args.author_tissue, args.species)
|
|
207
|
+
if args.author_tissue
|
|
208
|
+
else tissue_pages(args.species)
|
|
209
|
+
)
|
|
210
|
+
sys.stdout.write("\n".join(pages) + ("\n" if pages else ""))
|
|
211
|
+
return 0
|
|
212
|
+
if args.input is None or args.output is None:
|
|
213
|
+
parser.error("--input and --output are required")
|
|
214
|
+
if args.tissue is not None:
|
|
215
|
+
if args.species is None:
|
|
216
|
+
parser.error("--tissue requires --species")
|
|
217
|
+
if args.tissue not in tissue_pages(args.species):
|
|
218
|
+
parser.error(
|
|
219
|
+
f"unknown {args.species} tissue {args.tissue!r}; "
|
|
220
|
+
f"choose one from --list-tissues --species {args.species}"
|
|
221
|
+
)
|
|
222
|
+
if not args.prepare:
|
|
223
|
+
prepare_options = [
|
|
224
|
+
name
|
|
225
|
+
for name, used in (
|
|
226
|
+
("--batch-key", args.batch_key is not None),
|
|
227
|
+
("--batch-correction", args.batch_correction != "none"),
|
|
228
|
+
("--doublet-method", args.doublet_method != "none"),
|
|
229
|
+
("--doublet-chip", args.doublet_chip != "10k"),
|
|
230
|
+
("--solo-expected-rates", args.solo_expected_rates is not None),
|
|
231
|
+
("--scvi-model-dir", args.scvi_model_dir is not None),
|
|
232
|
+
("--denoise", args.denoise),
|
|
233
|
+
("--raw", args.raw is not None),
|
|
234
|
+
("--resolution", args.resolution is not None),
|
|
235
|
+
("--n-pcs", args.n_pcs is not None),
|
|
236
|
+
("--n-top-genes", args.n_top_genes is not None),
|
|
237
|
+
("--norm", args.norm != "log1p"),
|
|
238
|
+
("--scfair", args.scfair),
|
|
239
|
+
("--max-pct-mt", args.max_pct_mt is not None),
|
|
240
|
+
)
|
|
241
|
+
if used
|
|
242
|
+
]
|
|
243
|
+
if prepare_options:
|
|
244
|
+
parser.error(f"{', '.join(prepare_options)} require --prepare")
|
|
245
|
+
if args.raw is not None and not args.denoise:
|
|
246
|
+
parser.error("--raw requires --denoise")
|
|
247
|
+
if args.author_tissue and args.tissue is None:
|
|
248
|
+
pages = suggest_tissues(args.author_tissue, args.species)
|
|
249
|
+
parser.error("choose one tissue page with --tissue: " + ", ".join(pages))
|
|
250
|
+
if args.author_tissue and args.tissue is not None:
|
|
251
|
+
pages = suggest_tissues(args.author_tissue, args.species)
|
|
252
|
+
full = tissue_pages(args.species)
|
|
253
|
+
if pages != full and args.tissue not in pages:
|
|
254
|
+
print(
|
|
255
|
+
f"author source {args.author_tissue!r} matches {', '.join(pages)}; "
|
|
256
|
+
f"this run uses --tissue {args.tissue!r}",
|
|
257
|
+
file=sys.stderr,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if (args.types is None) == (args.hypotheses is None):
|
|
261
|
+
parser.error("pass exactly one of --types or --hypotheses")
|
|
262
|
+
models = _models(args, parser)
|
|
263
|
+
if models is not None and args.output_h5ad is None:
|
|
264
|
+
parser.error("model endpoints require --output-h5ad")
|
|
265
|
+
# Fail now. Prepare and the models can run for a long time before anything is written.
|
|
266
|
+
for path in (args.output, args.output_h5ad):
|
|
267
|
+
if path is None:
|
|
268
|
+
continue
|
|
269
|
+
try:
|
|
270
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
except OSError as exc:
|
|
272
|
+
parser.error(f"cannot create {path.parent}: {exc}")
|
|
273
|
+
if not os.access(path.parent, os.W_OK):
|
|
274
|
+
parser.error(f"cannot write to {path.parent}")
|
|
275
|
+
|
|
276
|
+
if args.types is not None:
|
|
277
|
+
if args.species is None:
|
|
278
|
+
parser.error("--species is required with --types")
|
|
279
|
+
names = [part.strip() for part in args.types if part.strip()]
|
|
280
|
+
hypotheses = hypotheses_from_names(names, args.species, args.tissue)
|
|
281
|
+
parents = parents_for(names, species=args.species, tissue=args.tissue)
|
|
282
|
+
else:
|
|
283
|
+
hypotheses = _hypotheses_from_file(args.hypotheses)
|
|
284
|
+
parents = {}
|
|
285
|
+
if args.catalog is not None:
|
|
286
|
+
catalog = _hypotheses_from_file(args.catalog)
|
|
287
|
+
elif args.species is not None:
|
|
288
|
+
catalog = dictionary_hypotheses(args.species, args.tissue)
|
|
289
|
+
else:
|
|
290
|
+
catalog = None
|
|
291
|
+
|
|
292
|
+
adata = sc.read_h5ad(args.input)
|
|
293
|
+
if args.prepare:
|
|
294
|
+
if args.species is None:
|
|
295
|
+
parser.error("--prepare requires --species")
|
|
296
|
+
if args.denoise and args.raw is None:
|
|
297
|
+
parser.error("No unfiltered droplet matrix. Pass --raw, or omit --denoise.")
|
|
298
|
+
if install_hint(
|
|
299
|
+
batch_correction=args.batch_correction,
|
|
300
|
+
doublet_method=args.doublet_method,
|
|
301
|
+
scfair=args.scfair,
|
|
302
|
+
denoise=args.denoise,
|
|
303
|
+
):
|
|
304
|
+
return 0
|
|
305
|
+
rates = None
|
|
306
|
+
if args.solo_expected_rates is not None:
|
|
307
|
+
rates = json.loads(args.solo_expected_rates.read_text())
|
|
308
|
+
if not isinstance(rates, dict):
|
|
309
|
+
parser.error("--solo-expected-rates must contain a JSON object")
|
|
310
|
+
adata = prepare(
|
|
311
|
+
adata,
|
|
312
|
+
species=args.species,
|
|
313
|
+
batch_key=args.batch_key,
|
|
314
|
+
batch_correction=args.batch_correction,
|
|
315
|
+
doublet_method=args.doublet_method,
|
|
316
|
+
doublet_chip=args.doublet_chip,
|
|
317
|
+
solo_expected_rates=rates,
|
|
318
|
+
scvi_model_dir=args.scvi_model_dir,
|
|
319
|
+
denoise=args.denoise,
|
|
320
|
+
raw=args.raw,
|
|
321
|
+
resolution=args.resolution,
|
|
322
|
+
n_pcs=args.n_pcs,
|
|
323
|
+
n_top_genes=args.n_top_genes,
|
|
324
|
+
norm=args.norm,
|
|
325
|
+
scfair=args.scfair,
|
|
326
|
+
max_pct_mt=args.max_pct_mt,
|
|
327
|
+
cluster_key=args.cluster_key or "leiden",
|
|
328
|
+
)
|
|
329
|
+
cluster_key = args.cluster_key or "leiden"
|
|
330
|
+
elif args.cluster_key is None:
|
|
331
|
+
parser.error("--cluster-key is required")
|
|
332
|
+
else:
|
|
333
|
+
cluster_key = args.cluster_key
|
|
334
|
+
kwargs = {}
|
|
335
|
+
if args.min_margin is not None:
|
|
336
|
+
kwargs["min_margin"] = args.min_margin
|
|
337
|
+
payload = run(
|
|
338
|
+
adata,
|
|
339
|
+
cluster_key,
|
|
340
|
+
species=args.species,
|
|
341
|
+
tissue=args.tissue,
|
|
342
|
+
hypotheses=hypotheses,
|
|
343
|
+
catalog=catalog,
|
|
344
|
+
models=models,
|
|
345
|
+
source=args.input.name,
|
|
346
|
+
dotplot_path=args.output.with_name(args.output.stem + ".markers.pdf"),
|
|
347
|
+
**kwargs,
|
|
348
|
+
)
|
|
349
|
+
if args.label_key is not None:
|
|
350
|
+
payload["evaluation"] = evaluate(
|
|
351
|
+
payload["records"],
|
|
352
|
+
adata,
|
|
353
|
+
cluster_key,
|
|
354
|
+
args.label_key,
|
|
355
|
+
parents=parents,
|
|
356
|
+
prediction_key="annotune_label",
|
|
357
|
+
)
|
|
358
|
+
# The report goes first so a failed h5ad write does not lose the run.
|
|
359
|
+
args.output.write_text(json.dumps(payload, indent=2) + "\n")
|
|
360
|
+
if args.output_h5ad is not None:
|
|
361
|
+
saved = adata.copy()
|
|
362
|
+
if "annotune_prep" in saved.uns:
|
|
363
|
+
saved.uns["annotune_prep"] = json.dumps(saved.uns["annotune_prep"])
|
|
364
|
+
with ad.settings.override(allow_write_nullable_strings=True):
|
|
365
|
+
saved.write_h5ad(args.output_h5ad)
|
|
366
|
+
return 0
|