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,179 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Cell Ontology DAG: is_a edges, cycle check, shortest upward distance, LCA."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from collections import deque
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OntologyCycleError(RuntimeError):
|
|
15
|
+
def __init__(self, cycle: list[str]):
|
|
16
|
+
self.cycle = cycle
|
|
17
|
+
shown = " -> ".join(cycle)
|
|
18
|
+
super().__init__(f"ontology has a cycle: {shown}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CellOntology:
|
|
22
|
+
def __init__(self, parents: dict[str, set[str]], labels: dict[str, str], source: dict):
|
|
23
|
+
self.labels = dict(labels)
|
|
24
|
+
self.parents = {node: set(items) for node, items in parents.items()}
|
|
25
|
+
self.source = dict(source)
|
|
26
|
+
nodes = set(self.parents)
|
|
27
|
+
for items in self.parents.values():
|
|
28
|
+
nodes.update(items)
|
|
29
|
+
self.nodes = nodes
|
|
30
|
+
self._ancestors: dict[str, set[str]] = {}
|
|
31
|
+
self._depth: dict[str, int | None] = {}
|
|
32
|
+
_reject_cycles(self.parents, nodes)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def sha256(self) -> str:
|
|
36
|
+
return str(self.source.get("sha256") or "")
|
|
37
|
+
|
|
38
|
+
def parents_of(self, node: str) -> set[str]:
|
|
39
|
+
return self.parents.get(node, set())
|
|
40
|
+
|
|
41
|
+
def ancestors(self, node: str) -> set[str]:
|
|
42
|
+
cached = self._ancestors.get(node)
|
|
43
|
+
if cached is not None:
|
|
44
|
+
return cached
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
stack = list(self.parents_of(node))
|
|
47
|
+
while stack:
|
|
48
|
+
parent = stack.pop()
|
|
49
|
+
if parent in seen:
|
|
50
|
+
continue
|
|
51
|
+
seen.add(parent)
|
|
52
|
+
stack.extend(self.parents_of(parent))
|
|
53
|
+
self._ancestors[node] = seen
|
|
54
|
+
return seen
|
|
55
|
+
|
|
56
|
+
def depth(self, node: str) -> int | None:
|
|
57
|
+
if node in self._depth:
|
|
58
|
+
return self._depth[node]
|
|
59
|
+
seen = {node}
|
|
60
|
+
queue: deque[tuple[str, int]] = deque([(node, 0)])
|
|
61
|
+
found: int | None = None
|
|
62
|
+
while queue:
|
|
63
|
+
current, dist = queue.popleft()
|
|
64
|
+
if current == "CL:0000000":
|
|
65
|
+
found = dist
|
|
66
|
+
break
|
|
67
|
+
for parent in self.parents_of(current):
|
|
68
|
+
if parent not in seen:
|
|
69
|
+
seen.add(parent)
|
|
70
|
+
queue.append((parent, dist + 1))
|
|
71
|
+
self._depth[node] = found
|
|
72
|
+
return found
|
|
73
|
+
|
|
74
|
+
def up_dist(self, src: str, dst: str) -> tuple[int, list[str]] | None:
|
|
75
|
+
"""Shortest directed distance from src up is_a edges to dst, and one path."""
|
|
76
|
+
queue: deque[tuple[str, list[str]]] = deque([(src, [src])])
|
|
77
|
+
seen = {src}
|
|
78
|
+
while queue:
|
|
79
|
+
current, path = queue.popleft()
|
|
80
|
+
if current == dst:
|
|
81
|
+
return len(path) - 1, path
|
|
82
|
+
for parent in sorted(self.parents_of(current)):
|
|
83
|
+
if parent not in seen:
|
|
84
|
+
seen.add(parent)
|
|
85
|
+
queue.append((parent, path + [parent]))
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def lca(self, left: str, right: str) -> str | None:
|
|
89
|
+
common = (self.ancestors(left) | {left}) & (self.ancestors(right) | {right})
|
|
90
|
+
if not common:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def key(node: str) -> tuple[int, str]:
|
|
94
|
+
dist = self.depth(node)
|
|
95
|
+
return (-1 if dist is None else -dist, node)
|
|
96
|
+
|
|
97
|
+
return min(common, key=key)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_ontology(path: Path) -> CellOntology:
|
|
101
|
+
file = Path(path)
|
|
102
|
+
blob = file.read_bytes()
|
|
103
|
+
data = json.loads(blob)
|
|
104
|
+
if "parents" in data and "labels" in data:
|
|
105
|
+
source = dict(data.get("source") or {})
|
|
106
|
+
source.setdefault("sha256", hashlib.sha256(blob).hexdigest())
|
|
107
|
+
parents = {node: set(items) for node, items in data["parents"].items()}
|
|
108
|
+
return CellOntology(parents, data["labels"], source)
|
|
109
|
+
if "edges" in data and "id_2_lbl" in data:
|
|
110
|
+
parents, labels = _from_popv_snapshot(data)
|
|
111
|
+
source = {
|
|
112
|
+
"name": file.name,
|
|
113
|
+
"sha256": hashlib.sha256(blob).hexdigest(),
|
|
114
|
+
"edges": sum(len(items) for items in parents.values()),
|
|
115
|
+
}
|
|
116
|
+
return CellOntology(parents, labels, source)
|
|
117
|
+
raise ValueError(f"{file} is not a Cell Ontology snapshot")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _from_popv_snapshot(data: dict) -> tuple[dict[str, set[str]], dict[str, str]]:
|
|
121
|
+
labels = {_norm_id(uri): label for uri, label in data["id_2_lbl"].items()}
|
|
122
|
+
parents: dict[str, set[str]] = {}
|
|
123
|
+
for edge in data["edges"]:
|
|
124
|
+
if edge.get("pred") != "is_a":
|
|
125
|
+
continue
|
|
126
|
+
child = _norm_id(edge["sub"])
|
|
127
|
+
parent = _norm_id(edge["obj"])
|
|
128
|
+
parents.setdefault(child, set()).add(parent)
|
|
129
|
+
used = set(parents)
|
|
130
|
+
for items in parents.values():
|
|
131
|
+
used.update(items)
|
|
132
|
+
return parents, {node: labels[node] for node in used if node in labels}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _norm_id(uri: str) -> str:
|
|
136
|
+
token = uri.rstrip("/").rsplit("/", 1)[-1]
|
|
137
|
+
if token.startswith("CL_"):
|
|
138
|
+
return "CL:" + token[3:]
|
|
139
|
+
return token
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _reject_cycles(parents: dict[str, set[str]], nodes: set[str]) -> None:
|
|
143
|
+
indegree = {node: 0 for node in nodes}
|
|
144
|
+
for child, items in parents.items():
|
|
145
|
+
indegree.setdefault(child, 0)
|
|
146
|
+
for parent in items:
|
|
147
|
+
indegree[parent] = indegree.get(parent, 0) + 1
|
|
148
|
+
queue = deque(sorted(node for node, degree in indegree.items() if degree == 0))
|
|
149
|
+
seen = 0
|
|
150
|
+
while queue:
|
|
151
|
+
node = queue.popleft()
|
|
152
|
+
seen += 1
|
|
153
|
+
for parent in parents.get(node, ()):
|
|
154
|
+
indegree[parent] -= 1
|
|
155
|
+
if indegree[parent] == 0:
|
|
156
|
+
queue.append(parent)
|
|
157
|
+
if seen == len(indegree):
|
|
158
|
+
return
|
|
159
|
+
remaining = {node for node, degree in indegree.items() if degree > 0}
|
|
160
|
+
raise OntologyCycleError(_one_cycle(parents, remaining))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _one_cycle(parents: dict[str, set[str]], remaining: set[str]) -> list[str]:
|
|
164
|
+
for start in sorted(remaining):
|
|
165
|
+
path: list[str] = []
|
|
166
|
+
index: dict[str, int] = {}
|
|
167
|
+
node = start
|
|
168
|
+
while node in remaining:
|
|
169
|
+
if node in index:
|
|
170
|
+
return path[index[node] :] + [node]
|
|
171
|
+
index[node] = len(path)
|
|
172
|
+
path.append(node)
|
|
173
|
+
parent = next(
|
|
174
|
+
(item for item in sorted(parents.get(node, ())) if item in remaining), None
|
|
175
|
+
)
|
|
176
|
+
if parent is None:
|
|
177
|
+
break
|
|
178
|
+
node = parent
|
|
179
|
+
return [sorted(remaining)[0]]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Versioned raw-label map. The ontology file itself is not edited."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
MATCHES = {"exact", "close", "broader", "narrower", "unmapped"}
|
|
13
|
+
STATUSES = {"proposed", "reviewed", "needs_review"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class MapEntry:
|
|
18
|
+
map_version: str
|
|
19
|
+
raw_label: str
|
|
20
|
+
species: str
|
|
21
|
+
cl_id: str | None
|
|
22
|
+
cl_label: str | None
|
|
23
|
+
match: str
|
|
24
|
+
status: str
|
|
25
|
+
reviewer: str | None
|
|
26
|
+
evidence: str
|
|
27
|
+
ontology_sha256: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OntologyMap:
|
|
31
|
+
def __init__(self, entries: list[MapEntry]):
|
|
32
|
+
if not entries:
|
|
33
|
+
raise ValueError("ontology map is empty")
|
|
34
|
+
versions = {entry.map_version for entry in entries}
|
|
35
|
+
hashes = {entry.ontology_sha256 for entry in entries}
|
|
36
|
+
if len(versions) != 1:
|
|
37
|
+
raise ValueError(f"ontology map has more than one version: {sorted(versions)}")
|
|
38
|
+
if len(hashes) != 1:
|
|
39
|
+
raise ValueError("ontology map rows do not share one snapshot hash")
|
|
40
|
+
self.version = entries[0].map_version
|
|
41
|
+
self.ontology_sha256 = entries[0].ontology_sha256
|
|
42
|
+
self.entries = list(entries)
|
|
43
|
+
self._rows: dict[tuple[str, str, str], MapEntry] = {}
|
|
44
|
+
for entry in entries:
|
|
45
|
+
key = (entry.map_version, entry.raw_label, entry.species)
|
|
46
|
+
if key in self._rows:
|
|
47
|
+
raise ValueError(f"duplicate map key {key}")
|
|
48
|
+
self._rows[key] = entry
|
|
49
|
+
|
|
50
|
+
def lookup(
|
|
51
|
+
self, raw_label: str, species: str | None, *, formal: bool = False
|
|
52
|
+
) -> MapEntry | None:
|
|
53
|
+
for candidate in _species_order(species):
|
|
54
|
+
entry = self._rows.get((self.version, raw_label, candidate))
|
|
55
|
+
if entry is None:
|
|
56
|
+
continue
|
|
57
|
+
if formal and entry.status != "reviewed":
|
|
58
|
+
continue
|
|
59
|
+
return entry
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def reviewed_only(self) -> bool:
|
|
64
|
+
return all(entry.status == "reviewed" for entry in self.entries)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_map(path: Path) -> OntologyMap:
|
|
68
|
+
entries: list[MapEntry] = []
|
|
69
|
+
for lineno, line in enumerate(Path(path).read_text().splitlines(), start=1):
|
|
70
|
+
if not line.strip():
|
|
71
|
+
continue
|
|
72
|
+
row = json.loads(line)
|
|
73
|
+
match = row.get("match")
|
|
74
|
+
status = row.get("status")
|
|
75
|
+
if match not in MATCHES:
|
|
76
|
+
raise ValueError(f"{path}:{lineno}: bad match {match!r}")
|
|
77
|
+
if status not in STATUSES:
|
|
78
|
+
raise ValueError(f"{path}:{lineno}: bad status {status!r}")
|
|
79
|
+
cl_id = row.get("cl_id")
|
|
80
|
+
if match == "unmapped":
|
|
81
|
+
if cl_id:
|
|
82
|
+
raise ValueError(f"{path}:{lineno}: unmapped row has a CL id")
|
|
83
|
+
elif not cl_id:
|
|
84
|
+
raise ValueError(f"{path}:{lineno}: mapped row has no CL id")
|
|
85
|
+
ontology = row.get("ontology") or {}
|
|
86
|
+
entries.append(
|
|
87
|
+
MapEntry(
|
|
88
|
+
map_version=row["map_version"],
|
|
89
|
+
raw_label=row["raw_label"],
|
|
90
|
+
species=row["species"],
|
|
91
|
+
cl_id=cl_id,
|
|
92
|
+
cl_label=row.get("cl_label"),
|
|
93
|
+
match=match,
|
|
94
|
+
status=status,
|
|
95
|
+
reviewer=row.get("reviewer"),
|
|
96
|
+
evidence=row.get("evidence") or "",
|
|
97
|
+
ontology_sha256=ontology.get("sha256") or "",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return OntologyMap(entries)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _species_order(species: str | None) -> list[str]:
|
|
104
|
+
order: list[str] = []
|
|
105
|
+
if species and species != "*":
|
|
106
|
+
order.append(species)
|
|
107
|
+
order.append("*")
|
|
108
|
+
return order
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Directed Cell Ontology relation between two labels."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .cl_dag import CellOntology
|
|
9
|
+
from .ontology_map import OntologyMap
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def popv_onestep(pred: str, gold: str, dag: CellOntology) -> str:
|
|
13
|
+
"""popV `_ontology_accuracy` order: exact, sibling, parent, child, no match.
|
|
14
|
+
|
|
15
|
+
`parent` means the prediction is the gold label's direct parent (one step coarser).
|
|
16
|
+
`child` means the gold label is the prediction's direct parent (one step finer).
|
|
17
|
+
"""
|
|
18
|
+
if pred == gold:
|
|
19
|
+
return "exact"
|
|
20
|
+
pred_parents = dag.parents_of(pred)
|
|
21
|
+
gold_parents = dag.parents_of(gold)
|
|
22
|
+
if pred_parents & gold_parents:
|
|
23
|
+
return "sibling"
|
|
24
|
+
if pred in gold_parents:
|
|
25
|
+
return "parent"
|
|
26
|
+
if gold in pred_parents:
|
|
27
|
+
return "child"
|
|
28
|
+
return "no match"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def relation(
|
|
32
|
+
pred_raw: str | None,
|
|
33
|
+
gold_raw: str | None,
|
|
34
|
+
species: str | None,
|
|
35
|
+
dag: CellOntology,
|
|
36
|
+
label_map: OntologyMap,
|
|
37
|
+
*,
|
|
38
|
+
formal: bool = False,
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""Relation of the prediction `pred_raw` to the reference `gold_raw`.
|
|
41
|
+
|
|
42
|
+
Ancestor means the prediction is coarser (negative k). Descendant means finer.
|
|
43
|
+
Distance is directed along is_a. Unmapped labels are not guessed.
|
|
44
|
+
"""
|
|
45
|
+
if pred_raw is None or gold_raw is None or pred_raw == "" or gold_raw == "":
|
|
46
|
+
return _blank("abstain")
|
|
47
|
+
pred_entry = label_map.lookup(pred_raw, species, formal=formal)
|
|
48
|
+
gold_entry = label_map.lookup(gold_raw, species, formal=formal)
|
|
49
|
+
pred_id = pred_entry.cl_id if pred_entry is not None else None
|
|
50
|
+
gold_id = gold_entry.cl_id if gold_entry is not None else None
|
|
51
|
+
if pred_id is None or gold_id is None:
|
|
52
|
+
return _blank("unmapped")
|
|
53
|
+
step = popv_onestep(pred_id, gold_id, dag)
|
|
54
|
+
if pred_id == gold_id:
|
|
55
|
+
return {
|
|
56
|
+
"relation": "exact",
|
|
57
|
+
"signed_k": 0,
|
|
58
|
+
"path": [pred_id],
|
|
59
|
+
"popv_onestep": step,
|
|
60
|
+
"same_cl_diff_raw": pred_raw != gold_raw,
|
|
61
|
+
}
|
|
62
|
+
if pred_id in dag.ancestors(gold_id):
|
|
63
|
+
found = dag.up_dist(gold_id, pred_id)
|
|
64
|
+
if found is None:
|
|
65
|
+
raise RuntimeError(f"missing upward path {gold_id} -> {pred_id}")
|
|
66
|
+
dist, path = found
|
|
67
|
+
return _row("ancestor", -dist, path, step)
|
|
68
|
+
if gold_id in dag.ancestors(pred_id):
|
|
69
|
+
found = dag.up_dist(pred_id, gold_id)
|
|
70
|
+
if found is None:
|
|
71
|
+
raise RuntimeError(f"missing upward path {pred_id} -> {gold_id}")
|
|
72
|
+
dist, path = found
|
|
73
|
+
return _row("descendant", dist, path, step)
|
|
74
|
+
if dag.parents_of(pred_id) & dag.parents_of(gold_id):
|
|
75
|
+
return _row("sibling", None, None, step)
|
|
76
|
+
return _row("unrelated", None, None, step)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _blank(kind: str) -> dict:
|
|
80
|
+
return {
|
|
81
|
+
"relation": kind,
|
|
82
|
+
"signed_k": None,
|
|
83
|
+
"path": None,
|
|
84
|
+
"popv_onestep": None,
|
|
85
|
+
"same_cl_diff_raw": False,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _row(kind: str, signed_k: int | None, path: list[str] | None, step: str) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"relation": kind,
|
|
92
|
+
"signed_k": signed_k,
|
|
93
|
+
"path": path,
|
|
94
|
+
"popv_onestep": step,
|
|
95
|
+
"same_cl_diff_raw": False,
|
|
96
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Caller-supplied cell-type hypotheses."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Hypothesis:
|
|
13
|
+
"""One candidate label and the markers that support or oppose it.
|
|
14
|
+
|
|
15
|
+
``positive`` genes should be enriched in the cluster if this label is right.
|
|
16
|
+
Their mean fold change is the score. A gene detected in fewer than 25% of
|
|
17
|
+
the cluster's cells is left out of that mean when at least two genes are
|
|
18
|
+
detected, so an absent gene does not dilute a high one. With fewer than two
|
|
19
|
+
detected, the whole panel is averaged. ``support`` genes add their fold
|
|
20
|
+
change when enriched and do not lower the score when absent. ``negative``
|
|
21
|
+
genes should not be detected. An enriched negative gene lowers the score
|
|
22
|
+
by its fold change, scaled by the number of positive genes averaged.
|
|
23
|
+
Absence of a negative gene is not evidence for the label.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
positive: tuple[str, ...]
|
|
28
|
+
negative: tuple[str, ...] = ()
|
|
29
|
+
support: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if not self.name:
|
|
33
|
+
raise ValueError("hypothesis name is empty")
|
|
34
|
+
if not self.positive:
|
|
35
|
+
raise ValueError(f"{self.name}: at least one positive marker is required")
|
|
36
|
+
object.__setattr__(self, "positive", tuple(self.positive))
|
|
37
|
+
object.__setattr__(self, "negative", tuple(self.negative))
|
|
38
|
+
object.__setattr__(self, "support", tuple(self.support))
|