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,172 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Literature backed marker panels checked against the current expression matrix."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import scanpy as sc
|
|
13
|
+
from anndata import AnnData
|
|
14
|
+
from scipy import sparse
|
|
15
|
+
|
|
16
|
+
from .evidence import measure_gene
|
|
17
|
+
from .markers import load_entries
|
|
18
|
+
|
|
19
|
+
MIN_PCT_IN = 0.25
|
|
20
|
+
MIN_LOGFC = 0.5
|
|
21
|
+
MAX_PVALUE = 0.05
|
|
22
|
+
MAX_MARKERS_PER_TYPE = 4
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def select_marker_panels(
|
|
26
|
+
adata: AnnData, label_key: str, species: str | None, tissue: str | None
|
|
27
|
+
) -> tuple[dict[str, list[str]], dict[str, list[dict[str, object]]], dict[str, str]]:
|
|
28
|
+
"""Keep cited positive and support markers that are expressed and enriched.
|
|
29
|
+
|
|
30
|
+
A type with no passing marker is still drawn with its best-measured cited
|
|
31
|
+
genes; its status stays ``no_marker_passed_expression_check``.
|
|
32
|
+
|
|
33
|
+
The specificity contrast uses the strongest *other labeled type* so panels
|
|
34
|
+
form diagonal blocks in a dotplot. The p-value tests the type against all
|
|
35
|
+
other cells, including cells without a written label.
|
|
36
|
+
"""
|
|
37
|
+
labels = adata.obs[label_key]
|
|
38
|
+
names = list(dict.fromkeys(labels.dropna().astype(str)))
|
|
39
|
+
panels: dict[str, list[str]] = {}
|
|
40
|
+
checks: dict[str, list[dict[str, object]]] = {}
|
|
41
|
+
status: dict[str, str] = {}
|
|
42
|
+
entries = {
|
|
43
|
+
entry.cell_type: entry
|
|
44
|
+
for entry in load_entries()
|
|
45
|
+
if species == entry.species
|
|
46
|
+
and (entry.scope == "any" or entry.scope == tissue)
|
|
47
|
+
and "lit=PMID:" in entry.evidence
|
|
48
|
+
}
|
|
49
|
+
observed = set(map(str, adata.var_names))
|
|
50
|
+
# StringDtype NA stays NA through astype(str); a later equality is not a mask.
|
|
51
|
+
label_text = labels.astype("string").fillna("").to_numpy()
|
|
52
|
+
position = {name: i for i, name in enumerate(names)}
|
|
53
|
+
type_codes = np.array([position.get(x, -1) for x in label_text])
|
|
54
|
+
type_sizes = np.bincount(type_codes[type_codes >= 0], minlength=len(names))
|
|
55
|
+
for name in names:
|
|
56
|
+
entry = entries.get(name)
|
|
57
|
+
if entry is None:
|
|
58
|
+
checks[name] = []
|
|
59
|
+
status[name] = "no_literature_backed_dictionary_row"
|
|
60
|
+
continue
|
|
61
|
+
mask = label_text == name
|
|
62
|
+
if mask.all():
|
|
63
|
+
checks[name] = []
|
|
64
|
+
status[name] = "no_other_cells_for_expression_test"
|
|
65
|
+
continue
|
|
66
|
+
rows: list[dict[str, object]] = []
|
|
67
|
+
pmids = re.findall(r"PMID:\d+", entry.evidence)
|
|
68
|
+
cited = (*entry.positive, *entry.support)
|
|
69
|
+
n_tests = sum(gene in observed for gene in cited)
|
|
70
|
+
for gene in cited:
|
|
71
|
+
if gene not in observed:
|
|
72
|
+
rows.append(
|
|
73
|
+
{
|
|
74
|
+
"gene": gene,
|
|
75
|
+
"literature": entry.evidence,
|
|
76
|
+
"pmids": pmids,
|
|
77
|
+
"selected": False,
|
|
78
|
+
"reason": "not_measured",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
continue
|
|
82
|
+
evidence = measure_gene(adata, mask, gene, direction="positive")
|
|
83
|
+
column = adata[:, gene].X
|
|
84
|
+
values = (
|
|
85
|
+
column.toarray().ravel() if sparse.issparse(column) else np.asarray(column).ravel()
|
|
86
|
+
)
|
|
87
|
+
linear = np.expm1(values)
|
|
88
|
+
kept = type_codes >= 0
|
|
89
|
+
sums = np.bincount(type_codes[kept], weights=linear[kept], minlength=len(names))
|
|
90
|
+
means = sums / np.maximum(type_sizes, 1)
|
|
91
|
+
competitor_means = [float(m) for i, m in enumerate(means) if names[i] != name]
|
|
92
|
+
strongest_other = max(competitor_means, default=evidence.mean_out)
|
|
93
|
+
specificity_logfc = float(np.log2((evidence.mean_in + 1) / (strongest_other + 1)))
|
|
94
|
+
adjusted_pvalue = (
|
|
95
|
+
min(1.0, evidence.pvalue * n_tests) if evidence.pvalue is not None else None
|
|
96
|
+
)
|
|
97
|
+
reasons = []
|
|
98
|
+
if evidence.pct_in < MIN_PCT_IN:
|
|
99
|
+
reasons.append("low_detection")
|
|
100
|
+
if evidence.logfc < MIN_LOGFC:
|
|
101
|
+
reasons.append("not_enriched_vs_all_cells")
|
|
102
|
+
if specificity_logfc < MIN_LOGFC:
|
|
103
|
+
reasons.append("not_specific_vs_other_labels")
|
|
104
|
+
if adjusted_pvalue is None or adjusted_pvalue > MAX_PVALUE:
|
|
105
|
+
reasons.append("not_significant")
|
|
106
|
+
rows.append(
|
|
107
|
+
{
|
|
108
|
+
"gene": gene,
|
|
109
|
+
"literature": entry.evidence,
|
|
110
|
+
"pmids": pmids,
|
|
111
|
+
"pct_in": evidence.pct_in,
|
|
112
|
+
"mean_in": evidence.mean_in,
|
|
113
|
+
"mean_out": evidence.mean_out,
|
|
114
|
+
"logfc": evidence.logfc,
|
|
115
|
+
"strongest_other_mean": strongest_other,
|
|
116
|
+
"specificity_logfc": specificity_logfc,
|
|
117
|
+
"pvalue": evidence.pvalue,
|
|
118
|
+
"adjusted_pvalue": adjusted_pvalue,
|
|
119
|
+
"selected": False,
|
|
120
|
+
"reason": "+".join(reasons) if reasons else None,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
eligible = [row for row in rows if row["reason"] is None]
|
|
124
|
+
eligible.sort(key=lambda row: (-row["specificity_logfc"], -row["pct_in"], row["gene"]))
|
|
125
|
+
chosen = eligible[:MAX_MARKERS_PER_TYPE]
|
|
126
|
+
for row in chosen:
|
|
127
|
+
row["selected"] = True
|
|
128
|
+
if chosen:
|
|
129
|
+
panels[name] = [str(row["gene"]) for row in chosen]
|
|
130
|
+
status[name] = "selected"
|
|
131
|
+
else:
|
|
132
|
+
status[name] = "no_marker_passed_expression_check"
|
|
133
|
+
# The type is still drawn, with its best cited genes, so the figure
|
|
134
|
+
# shows what was checked. These rows stay selected=False.
|
|
135
|
+
measured = [row for row in rows if "specificity_logfc" in row]
|
|
136
|
+
measured.sort(key=lambda row: (-row["specificity_logfc"], -row["pct_in"], row["gene"]))
|
|
137
|
+
if measured:
|
|
138
|
+
panels[name] = [str(row["gene"]) for row in measured[:MAX_MARKERS_PER_TYPE]]
|
|
139
|
+
checks[name] = rows
|
|
140
|
+
return panels, checks, status
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def plot_marker_panels(
|
|
144
|
+
adata: AnnData,
|
|
145
|
+
label_key: str,
|
|
146
|
+
panels: dict[str, list[str]],
|
|
147
|
+
order: list[str],
|
|
148
|
+
path: Path | str | None = None,
|
|
149
|
+
) -> str | None:
|
|
150
|
+
"""Plot cited, data checked markers with matching type and marker order."""
|
|
151
|
+
if not panels:
|
|
152
|
+
return None
|
|
153
|
+
part = adata[adata.obs[label_key].notna()].copy()
|
|
154
|
+
ordered_panels = {name: panels[name] for name in order if name in panels}
|
|
155
|
+
plot = sc.pl.dotplot(
|
|
156
|
+
part,
|
|
157
|
+
ordered_panels,
|
|
158
|
+
groupby=label_key,
|
|
159
|
+
categories_order=order,
|
|
160
|
+
dendrogram=False,
|
|
161
|
+
use_raw=False,
|
|
162
|
+
standard_scale="var",
|
|
163
|
+
show=False,
|
|
164
|
+
return_fig=True,
|
|
165
|
+
)
|
|
166
|
+
if path is None:
|
|
167
|
+
plot.show()
|
|
168
|
+
return None
|
|
169
|
+
target = Path(path)
|
|
170
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
plot.savefig(str(target))
|
|
172
|
+
return str(target)
|
annotuneagent/markers.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Built-in marker dictionary.
|
|
5
|
+
|
|
6
|
+
Rows live in ``markers.tsv``. A row is one species and either ``any`` tissue
|
|
7
|
+
or one tissue. Gene symbols are the ones used in that species' matrix.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .hypotheses import Hypothesis
|
|
18
|
+
|
|
19
|
+
SPECIES = ("human", "mouse")
|
|
20
|
+
_TABLE = Path(__file__).with_name("markers.tsv")
|
|
21
|
+
# Author phrases that are not themselves a page name. The caller still chooses.
|
|
22
|
+
_AUTHOR_TISSUE = (
|
|
23
|
+
("dorsal root ganglion", "drg"),
|
|
24
|
+
("dorsal root ganglia", "drg"),
|
|
25
|
+
("corneal", "cornea"),
|
|
26
|
+
("retinal", "retina"),
|
|
27
|
+
("placental", "placenta"),
|
|
28
|
+
("thymic", "thymus"),
|
|
29
|
+
("endometrial", "endometrium"),
|
|
30
|
+
("ovarian", "ovary"),
|
|
31
|
+
("testicular", "testis"),
|
|
32
|
+
("breast", "mammary"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class MarkerEntry:
|
|
38
|
+
species: str
|
|
39
|
+
scope: str
|
|
40
|
+
cell_type: str
|
|
41
|
+
parent: str | None
|
|
42
|
+
positive: tuple[str, ...]
|
|
43
|
+
negative: tuple[str, ...]
|
|
44
|
+
support: tuple[str, ...]
|
|
45
|
+
note: str
|
|
46
|
+
evidence: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _split_genes(text: str) -> tuple[str, ...]:
|
|
50
|
+
return tuple(part.strip() for part in text.split(";") if part.strip())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_entries() -> list[MarkerEntry]:
|
|
54
|
+
entries: list[MarkerEntry] = []
|
|
55
|
+
with _TABLE.open(newline="") as handle:
|
|
56
|
+
for row in csv.DictReader(handle, delimiter="\t"):
|
|
57
|
+
species = row["species"].strip()
|
|
58
|
+
scope = row["scope"].strip()
|
|
59
|
+
if species not in SPECIES:
|
|
60
|
+
raise ValueError(f"unknown species {species!r} in {_TABLE.name}")
|
|
61
|
+
if not scope:
|
|
62
|
+
raise ValueError("empty scope")
|
|
63
|
+
parent = row["parent"].strip() or None
|
|
64
|
+
entries.append(
|
|
65
|
+
MarkerEntry(
|
|
66
|
+
species=species,
|
|
67
|
+
scope=scope,
|
|
68
|
+
cell_type=row["cell_type"].strip(),
|
|
69
|
+
parent=parent,
|
|
70
|
+
positive=_split_genes(row["positive"]),
|
|
71
|
+
negative=_split_genes(row["negative"]),
|
|
72
|
+
support=_split_genes(row.get("support") or ""),
|
|
73
|
+
note=row.get("note", "").strip(),
|
|
74
|
+
evidence=row.get("evidence", "").strip(),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
seen: set[tuple[str, str]] = set()
|
|
78
|
+
for entry in entries:
|
|
79
|
+
key = (entry.species, entry.cell_type)
|
|
80
|
+
if key in seen:
|
|
81
|
+
raise ValueError(f"duplicate marker row for {key}")
|
|
82
|
+
if not entry.positive:
|
|
83
|
+
raise ValueError(f"{entry.cell_type}: no positive markers")
|
|
84
|
+
seen.add(key)
|
|
85
|
+
# A parent is another row of the same species. A removed umbrella type must not linger here.
|
|
86
|
+
for entry in entries:
|
|
87
|
+
if entry.parent is not None and (entry.species, entry.parent) not in seen:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"{entry.cell_type}: parent {entry.parent!r} is not a {entry.species} row"
|
|
90
|
+
)
|
|
91
|
+
return entries
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_ENTRIES = load_entries()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _available(species: str, tissue: str | None) -> list[MarkerEntry]:
|
|
98
|
+
_check_species(species)
|
|
99
|
+
if tissue is not None and tissue not in tissue_pages(species):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"unknown {species} tissue {tissue!r}; choose from tissue_pages({species!r})"
|
|
102
|
+
)
|
|
103
|
+
return [
|
|
104
|
+
entry
|
|
105
|
+
for entry in _ENTRIES
|
|
106
|
+
if entry.species == species and (entry.scope == "any" or entry.scope == tissue)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _check_species(species: str) -> None:
|
|
111
|
+
if species not in SPECIES:
|
|
112
|
+
raise ValueError(f"species must be one of {', '.join(SPECIES)}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def tissue_pages(species: str | None = None) -> list[str]:
|
|
116
|
+
"""Tissue pages that can be added to the core dictionary.
|
|
117
|
+
|
|
118
|
+
The caller chooses one page and passes it as ``tissue``. Pages are listed
|
|
119
|
+
in table order.
|
|
120
|
+
"""
|
|
121
|
+
if species is not None:
|
|
122
|
+
_check_species(species)
|
|
123
|
+
pages: list[str] = []
|
|
124
|
+
for entry in _ENTRIES:
|
|
125
|
+
if species is not None and entry.species != species:
|
|
126
|
+
continue
|
|
127
|
+
if entry.scope == "any" or entry.scope in pages:
|
|
128
|
+
continue
|
|
129
|
+
pages.append(entry.scope)
|
|
130
|
+
return pages
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def suggest_tissues(author: str, species: str | None = None) -> list[str]:
|
|
134
|
+
"""Pages consistent with an author tissue phrase.
|
|
135
|
+
|
|
136
|
+
The caller chooses one and passes it to ``run``. An unmatched phrase
|
|
137
|
+
returns every page. Dorsal root ganglion returns the ``drg`` page.
|
|
138
|
+
"""
|
|
139
|
+
pages = tissue_pages(species)
|
|
140
|
+
text = " ".join(author.casefold().replace("_", " ").replace("-", " ").split())
|
|
141
|
+
tokens = set(re.findall(r"[a-z0-9]+", text))
|
|
142
|
+
hits: list[str] = []
|
|
143
|
+
for phrase, page in _AUTHOR_TISSUE:
|
|
144
|
+
if phrase in text and page in pages and page not in hits:
|
|
145
|
+
hits.append(page)
|
|
146
|
+
for page in pages:
|
|
147
|
+
if page in tokens and page not in hits:
|
|
148
|
+
hits.append(page)
|
|
149
|
+
if not hits:
|
|
150
|
+
return pages
|
|
151
|
+
return [page for page in pages if page in set(hits)]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def marker_names(species: str, tissue: str | None = None) -> list[str]:
|
|
155
|
+
"""Types that can be scored for this species and optional tissue."""
|
|
156
|
+
return [entry.cell_type for entry in _available(species, tissue)]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def hypotheses_from_names(
|
|
160
|
+
names: list[str],
|
|
161
|
+
species: str,
|
|
162
|
+
tissue: str | None = None,
|
|
163
|
+
) -> list[Hypothesis]:
|
|
164
|
+
"""Look up hypotheses. Tissue rows are visible only when ``tissue`` matches."""
|
|
165
|
+
by_name = {entry.cell_type: entry for entry in _available(species, tissue)}
|
|
166
|
+
missing = [name for name in names if name not in by_name]
|
|
167
|
+
if missing:
|
|
168
|
+
where = _explain_missing(missing[0], species, tissue)
|
|
169
|
+
raise KeyError(where)
|
|
170
|
+
return [
|
|
171
|
+
Hypothesis(
|
|
172
|
+
name,
|
|
173
|
+
positive=by_name[name].positive,
|
|
174
|
+
negative=by_name[name].negative,
|
|
175
|
+
support=by_name[name].support,
|
|
176
|
+
)
|
|
177
|
+
for name in names
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _explain_missing(name: str, species: str, tissue: str | None) -> str:
|
|
182
|
+
other = [
|
|
183
|
+
entry
|
|
184
|
+
for entry in _ENTRIES
|
|
185
|
+
if entry.cell_type == name and entry.species == species and entry.scope != "any"
|
|
186
|
+
]
|
|
187
|
+
if other and tissue != other[0].scope:
|
|
188
|
+
return f"{name!r} is a {species} {other[0].scope} type; pass tissue={other[0].scope!r}"
|
|
189
|
+
known = ", ".join(marker_names(species, tissue))
|
|
190
|
+
return f"unknown cell type {name!r} for {species}. Known names: {known}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parents_for(
|
|
194
|
+
names: list[str] | None = None,
|
|
195
|
+
*,
|
|
196
|
+
species: str,
|
|
197
|
+
tissue: str | None = None,
|
|
198
|
+
) -> dict[str, str]:
|
|
199
|
+
"""Map a type to its parent when the selected rows define one."""
|
|
200
|
+
chosen = marker_names(species, tissue) if names is None else names
|
|
201
|
+
by_name = {entry.cell_type: entry for entry in _available(species, tissue)}
|
|
202
|
+
return {
|
|
203
|
+
name: by_name[name].parent
|
|
204
|
+
for name in chosen
|
|
205
|
+
if name in by_name and by_name[name].parent is not None
|
|
206
|
+
}
|