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.
Files changed (38) hide show
  1. annotuneagent/__init__.py +68 -0
  2. annotuneagent/__main__.py +6 -0
  3. annotuneagent/_version.py +19 -0
  4. annotuneagent/annotate.py +93 -0
  5. annotuneagent/arbitrate.py +141 -0
  6. annotuneagent/cli.py +366 -0
  7. annotuneagent/decide.py +725 -0
  8. annotuneagent/evaluate.py +121 -0
  9. annotuneagent/evidence.py +606 -0
  10. annotuneagent/granularity/__init__.py +28 -0
  11. annotuneagent/granularity/cl_dag.py +179 -0
  12. annotuneagent/granularity/ontology_map.py +108 -0
  13. annotuneagent/granularity/relations.py +96 -0
  14. annotuneagent/hypotheses.py +38 -0
  15. annotuneagent/local_lora.py +706 -0
  16. annotuneagent/marker_plot.py +172 -0
  17. annotuneagent/markers.py +206 -0
  18. annotuneagent/markers.tsv +378 -0
  19. annotuneagent/mltools.py +128 -0
  20. annotuneagent/ontology/cl_isa.json +1 -0
  21. annotuneagent/ontology/granularity_policy.yaml +8 -0
  22. annotuneagent/ontology/ontology_map.jsonl +198 -0
  23. annotuneagent/prepare.py +1385 -0
  24. annotuneagent/py.typed +0 -0
  25. annotuneagent/read.py +76 -0
  26. annotuneagent/tokenizer/LICENSE +202 -0
  27. annotuneagent/tokenizer/merges.txt +247587 -0
  28. annotuneagent/tokenizer/tokenizer.json +495920 -0
  29. annotuneagent/tokenizer/tokenizer_config.json +305 -0
  30. annotuneagent/tokenizer/vocab.json +248046 -0
  31. annotuneagent/votes.py +83 -0
  32. annotuneagent/workflow.py +2037 -0
  33. annotuneagent-0.1.0.dist-info/METADATA +208 -0
  34. annotuneagent-0.1.0.dist-info/RECORD +38 -0
  35. annotuneagent-0.1.0.dist-info/WHEEL +5 -0
  36. annotuneagent-0.1.0.dist-info/entry_points.txt +2 -0
  37. annotuneagent-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. 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)
@@ -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
+ }