clacell 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.
clacell/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .classifier import BloodCellClassifier
2
+ from .marker_annotator import MarkerAnnotator
3
+
4
+ __all__ = ["BloodCellClassifier", "MarkerAnnotator"]
@@ -0,0 +1,10 @@
1
+ from .clustering import cluster_scanpy
2
+ from .markers import calculate_auc, compute_cluster_auc
3
+ from .model_selection import select_cluster_model
4
+
5
+ __all__ = [
6
+ "calculate_auc",
7
+ "cluster_scanpy",
8
+ "compute_cluster_auc",
9
+ "select_cluster_model",
10
+ ]
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pandas as pd
8
+
9
+ from .io import read_count_matrix, read_markers
10
+ from .model_selection import select_cluster_model
11
+
12
+
13
+ def _build_parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(description="Marker-based annotation pipeline")
15
+ p.add_argument(
16
+ "--matrix", required=True, help="Gene x cell count matrix CSV (index=gene)"
17
+ )
18
+ p.add_argument("--markers", required=True, help="Markers as JSON or CSV/TSV")
19
+ p.add_argument("--outdir", required=True, help="Output directory")
20
+ p.add_argument("--species", default="human", choices=["human", "mouse"])
21
+ p.add_argument("--min-cluster", type=int, default=3)
22
+ p.add_argument("--max-cluster", type=int, default=20)
23
+ p.add_argument("--seed", type=int, default=0)
24
+ return p
25
+
26
+
27
+ def main() -> None:
28
+ args = _build_parser().parse_args()
29
+
30
+ matrix = read_count_matrix(args.matrix)
31
+ markers = read_markers(args.markers)
32
+
33
+ result = select_cluster_model(
34
+ matrix,
35
+ markers,
36
+ min_cluster=args.min_cluster,
37
+ max_cluster=args.max_cluster,
38
+ species=args.species,
39
+ random_state=args.seed,
40
+ )
41
+
42
+ outdir = Path(args.outdir)
43
+ outdir.mkdir(parents=True, exist_ok=True)
44
+
45
+ params_file = outdir / "best_params.json"
46
+ with params_file.open("w", encoding="utf-8") as f:
47
+ json.dump(result.params_final, f, indent=2)
48
+
49
+ auc_file = outdir / "final_auc.json"
50
+ with auc_file.open("w", encoding="utf-8") as f:
51
+ json.dump(result.auc_final, f, indent=2)
52
+
53
+ clusters = result.model_final.clusters.rename("cluster")
54
+ cluster_labels = pd.DataFrame({"cell": clusters.index, "cluster": clusters.values})
55
+ cluster_labels.to_csv(outdir / "clusters.csv", index=False)
56
+
57
+ labels = pd.DataFrame(
58
+ {
59
+ "cell": result.label_final.index,
60
+ "cell_type_label": result.label_final.values,
61
+ }
62
+ )
63
+ labels.to_csv(outdir / "cell_type_labels.csv", index=False)
64
+
65
+ result.auc_table.to_csv(outdir / "model_grid_auc.csv", index=False)
66
+ result.silhouette_table.to_csv(outdir / "model_grid_silhouette.csv", index=False)
67
+ result.cluster_table.to_csv(outdir / "model_grid_cluster_counts.csv", index=False)
68
+
69
+ print(f"Done. Results written to: {outdir}")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import anndata as ad
4
+ import numpy as np
5
+ import pandas as pd
6
+ import scanpy as sc
7
+
8
+ from .types import ClusterResult
9
+
10
+
11
+ def _to_anndata(umi_count: pd.DataFrame) -> ad.AnnData:
12
+ if not isinstance(umi_count, pd.DataFrame):
13
+ raise TypeError(
14
+ "umi_count must be a pandas DataFrame with genes as index and cells as columns"
15
+ )
16
+ adata = ad.AnnData(X=umi_count.T.copy())
17
+ adata.var_names = umi_count.index.astype(str)
18
+ adata.obs_names = umi_count.columns.astype(str)
19
+ return adata
20
+
21
+
22
+ def cluster_scanpy(
23
+ umi_count: pd.DataFrame,
24
+ number_neighbor: int = 30,
25
+ variable_gene: bool = False,
26
+ resolution: float = 1.0,
27
+ num_pc: int = 50,
28
+ species: str = "human",
29
+ normalize: bool = True,
30
+ min_cells: int = 3,
31
+ min_genes: int = 1,
32
+ random_state: int = 0,
33
+ ) -> ClusterResult:
34
+ adata = _to_anndata(umi_count)
35
+
36
+ sc.pp.filter_cells(adata, min_genes=min_genes)
37
+ sc.pp.filter_genes(adata, min_cells=min_cells)
38
+
39
+ if species == "human":
40
+ adata.var["mito"] = adata.var_names.str.startswith("MT-")
41
+ elif species == "mouse":
42
+ adata.var["mito"] = adata.var_names.str.startswith("mt-")
43
+ else:
44
+ raise ValueError("species must be 'human' or 'mouse'")
45
+
46
+ sc.pp.calculate_qc_metrics(
47
+ adata, qc_vars=["mito"], percent_top=None, log1p=False, inplace=True
48
+ )
49
+
50
+ if normalize:
51
+ sc.pp.normalize_total(adata, target_sum=10000)
52
+ sc.pp.log1p(adata)
53
+ else:
54
+ adata.X = np.log1p(adata.X)
55
+
56
+ if variable_gene:
57
+ sc.pp.highly_variable_genes(adata, min_mean=0.0125, max_mean=3, min_disp=0.5)
58
+ hvg_mask = adata.var["highly_variable"].to_numpy()
59
+ if int(hvg_mask.sum()) == 0:
60
+ adata_use = adata.copy()
61
+ else:
62
+ adata_use = adata[:, hvg_mask].copy()
63
+ else:
64
+ if species == "human":
65
+ mt_rb = adata.var_names.str.contains(
66
+ r"^MT-|^RPL|^RPS|^MRPS|^MRPL", regex=True
67
+ )
68
+ else:
69
+ mt_rb = adata.var_names.str.contains(
70
+ r"^mt-|^Rpl|^Rps|^Mrps|^Mrpl", regex=True
71
+ )
72
+ adata_use = adata[:, ~mt_rb].copy()
73
+
74
+ if adata_use.n_vars == 0:
75
+ adata_use = adata.copy()
76
+
77
+ sc.pp.scale(adata_use)
78
+
79
+ n_pc_eff = min(num_pc, adata_use.n_vars - 1, adata_use.n_obs - 1)
80
+ n_pc_eff = max(n_pc_eff, 2)
81
+
82
+ sc.tl.pca(
83
+ adata_use,
84
+ n_comps=n_pc_eff,
85
+ random_state=random_state,
86
+ mask_var=None,
87
+ )
88
+ sc.pp.neighbors(
89
+ adata_use,
90
+ n_neighbors=number_neighbor,
91
+ n_pcs=n_pc_eff,
92
+ random_state=random_state,
93
+ )
94
+ sc.tl.leiden(
95
+ adata_use,
96
+ resolution=resolution,
97
+ random_state=random_state,
98
+ key_added="cluster",
99
+ flavor="igraph",
100
+ n_iterations=2,
101
+ directed=False,
102
+ )
103
+ sc.tl.tsne(adata_use, n_pcs=n_pc_eff, random_state=random_state)
104
+
105
+ adata.obsm["X_pca"] = adata_use.obsm["X_pca"]
106
+ adata.obsm["X_tsne"] = adata_use.obsm["X_tsne"]
107
+ adata.obs["cluster"] = adata_use.obs["cluster"].astype(str)
108
+
109
+ params = {
110
+ "number_neighbor": number_neighbor,
111
+ "variable_gene": variable_gene,
112
+ "resolution": resolution,
113
+ "num_pc": num_pc,
114
+ "normalize": normalize,
115
+ "min_cells": min_cells,
116
+ "min_genes": min_genes,
117
+ "species": species,
118
+ }
119
+ return ClusterResult(
120
+ adata=adata, clusters=adata.obs["cluster"].copy(), params=params
121
+ )
@@ -0,0 +1 @@
1
+ {"CD4+ T cell":["CD3D+","CD3E+","CD3G+","TRAC+","CD4+","TCF7+","CD27+","IL7R+","CD8A-","CD8B-","GNLY-","NKG7-","CST7-"],"Cytotoxic T cell":["CD3D+","CD3E+","CD3G+","TRAC+","CD8A+","CD8B+","GZMK+","CCL5+","NKG7+","CD4-","FCER1G-"],"B cell":["CD19+","MS4A1+","CD79A+","CD79B+","MZB1+","IGHD+","IGHM+"],"Natural killer cell":["NCAM1+","NKG7+","KLRB1+","KLRD1+","KLRF1+","KLRC1+","KLRC2+","KLRC3+","KLRC4+","CD3D-","CD3E-","CD3G-","CD14-","FCGR3A+","FCGR3B+","ITGAL+","ITGAM+","FCER1G+","TRAC-"],"CD14+ monocyte":["VCAN+","FCN1+","S100A8+","S100A9+","CD14+","ITGAL+","ITGAM+","CSF3R+","CSF1R+","CX3CR1+","FCGR3A-","FCGR3B-","TYROBP+","LYZ+","S100A12+","CD3D-","CD3E-","CD3G-","TRAC-","NKG7-","KLRB1-","KLRD1-"],"CD16+ monocyte":["FCN1+","FCGR3A+","FCGR3B+","ITGAL+","ITGAM+","CSF3R+","CSF1R+","CX3CR1+","CDKN1C+","MS4A7+","S100A8-","S100A9-","S100A12-","CD14-","CD3D-","CD3E-","CD3G-","TRAC-","NKG7-","KLRB1-","KLRD1-"],"Dendritic cell":["HLA-DPB1+","HLA-DPA1+","HLA-DQA1+","ITGAX+","CD3D-","CD3E-","CD3G-","NCAM1-","CD19-","CD14-","CD1C+","CD1E+","FCER1A+","CLEC10A+","FCGR2B+","MS4A1-","CD79A-","CD79B-"],"Plasmacytoid dendritic cell":["IL3RA+","GZMB+","JCHAIN+","IRF7+","TCF4+","LILRA4+","CLEC4C+","ITGAX-","CD3D-","CD3E-","CD3G-","NCAM1-","CD19-","CD14-","MS4A1-","CD79A-","CD79B-"],"Plasma cell":["CD38+","XBP1+","CD27+","SLAMF7+","CD19-","MS4A1-","CD3D-","CD3E-","CD3G-","IGHA1+","IGHA2+","IGHG1+","IGHG2+","IGHG3+","IGHG4+"],"Megakaryocyte":["PF4+","PPBP+","GP5+","ITGA2B+","NRGN+","TUBB1+","SPARC+","RGS18+","MYL9+","GNG11+"]}
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+
8
+ from .types import MarkerDict
9
+
10
+
11
+ def read_count_matrix(path: str | Path) -> pd.DataFrame:
12
+ matrix = pd.read_csv(path, index_col=0)
13
+ return matrix
14
+
15
+
16
+ def read_markers(path: str | Path) -> MarkerDict:
17
+ p = Path(path)
18
+ if p.suffix.lower() == ".json":
19
+ with p.open("r", encoding="utf-8") as f:
20
+ obj = json.load(f)
21
+ return {str(k): [str(x) for x in v] for k, v in obj.items()}
22
+
23
+ if p.suffix.lower() in {".csv", ".tsv"}:
24
+ sep = "\t" if p.suffix.lower() == ".tsv" else ","
25
+ df = pd.read_csv(p, sep=sep)
26
+ required = {"cell_type", "marker"}
27
+ if not required.issubset(set(df.columns)):
28
+ raise ValueError("Marker table must contain columns: cell_type, marker")
29
+ out: MarkerDict = {}
30
+ for ct, grp in df.groupby("cell_type"):
31
+ out[str(ct)] = [str(x) for x in grp["marker"].tolist()]
32
+ return out
33
+
34
+ raise ValueError("Unsupported marker format. Use .json, .csv, or .tsv")
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from dataclasses import dataclass
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from sklearn.metrics import roc_auc_score
9
+
10
+ from .types import MarkerDict
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class ClusterAucResult:
15
+ auc_assign: pd.Series
16
+ cell_type_assign: pd.Series
17
+ auc_table: pd.DataFrame
18
+ cell_type_score: pd.DataFrame
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class AucLabelResult:
23
+ auc: pd.Series
24
+ label: pd.Series
25
+
26
+
27
+ def split_marker(marker: MarkerDict) -> dict[str, tuple[list[str], list[str]]]:
28
+ out: dict[str, tuple[list[str], list[str]]] = {}
29
+ for cell_type, genes in marker.items():
30
+ pos: list[str] = []
31
+ neg: list[str] = []
32
+ for token in genes:
33
+ if not token:
34
+ continue
35
+ if token.endswith("-"):
36
+ neg.append(token[:-1])
37
+ elif token.endswith("+"):
38
+ pos.append(token[:-1])
39
+ else:
40
+ pos.append(token)
41
+ neg_set = set(neg)
42
+ pos = [g for g in pos if g not in neg_set]
43
+ out[cell_type] = (pos, neg)
44
+ return out
45
+
46
+
47
+ def compute_marker_score(
48
+ x: pd.DataFrame, marker_list: tuple[list[str], list[str]]
49
+ ) -> pd.Series:
50
+ total = x.sum(axis=0).replace(0, np.nan)
51
+
52
+ gene_pos = [g for g in marker_list[0] if g in x.index]
53
+ gene_neg = [g for g in marker_list[1] if g in x.index]
54
+
55
+ if gene_pos and gene_neg:
56
+ out = x.loc[gene_pos].sum(axis=0) - x.loc[gene_neg].sum(axis=0)
57
+ elif gene_pos:
58
+ out = x.loc[gene_pos].sum(axis=0)
59
+ elif gene_neg:
60
+ neg_sum = x.loc[gene_neg].sum(axis=0)
61
+ out = neg_sum.max() - neg_sum
62
+ else:
63
+ out = pd.Series(0.0, index=x.columns)
64
+
65
+ res = out / total * 10000.0
66
+ res = res.fillna(0.0)
67
+ res[res < 0] = 0.0
68
+ return np.log1p(res)
69
+
70
+
71
+ def score_auc(score: pd.Series, label: pd.Series) -> float:
72
+ if label.nunique() != 2:
73
+ return 0.5
74
+ try:
75
+ return float(roc_auc_score(label.astype(int), score))
76
+ except ValueError:
77
+ return 0.5
78
+
79
+
80
+ def assign_cluster(auc_table: pd.DataFrame) -> tuple[pd.Series, pd.Series]:
81
+ auc_assign = pd.Series(index=auc_table.columns, dtype=float)
82
+ cell_type_assign = pd.Series(index=auc_table.columns, dtype=object)
83
+ for cluster in auc_table.columns:
84
+ col = auc_table[cluster]
85
+ best_type = str(col.idxmax())
86
+ auc_assign.loc[cluster] = float(col.loc[best_type])
87
+ cell_type_assign.loc[cluster] = best_type
88
+ return auc_assign, cell_type_assign
89
+
90
+
91
+ def _cluster_to_celltype_map(auc_table: pd.DataFrame) -> dict[str, str]:
92
+ return {
93
+ str(cluster): str(auc_table[cluster].idxmax()) for cluster in auc_table.columns
94
+ }
95
+
96
+
97
+ def _merge_detector(
98
+ x: pd.DataFrame,
99
+ cluster_name: str,
100
+ cell_type: str,
101
+ cluster_all: pd.Series,
102
+ cluster_to_type: dict[str, str],
103
+ ) -> float:
104
+ if cluster_to_type[cluster_name] == cell_type:
105
+ return 0.5
106
+
107
+ cluster_rm = [k for k, v in cluster_to_type.items() if v == cell_type]
108
+ keep = cluster_all.isin([cluster_name] + cluster_rm)
109
+ cluster_keep = cluster_all[keep]
110
+ score = x.loc[keep, cluster_to_type[cluster_name]]
111
+ return score_auc(score, cluster_keep == cluster_name)
112
+
113
+
114
+ def _reassign_cluster(
115
+ x: pd.DataFrame,
116
+ cluster: pd.Series,
117
+ auc_table: pd.DataFrame,
118
+ ) -> tuple[pd.Series, pd.Series, pd.DataFrame]:
119
+ cluster_names = list(auc_table.columns.astype(str))
120
+ cluster_to_type = _cluster_to_celltype_map(auc_table)
121
+ cell_type_candidates = sorted(set(cluster_to_type.values()))
122
+ auc_table_update = auc_table.loc[cell_type_candidates, :].copy()
123
+
124
+ for cl in cluster_names:
125
+ if float(auc_table_update[cl].max()) > 0.9:
126
+ continue
127
+
128
+ scores: dict[str, float] = {}
129
+ for ct in cell_type_candidates:
130
+ auc_one = _merge_detector(x, cl, ct, cluster, cluster_to_type)
131
+
132
+ current_type = cluster_to_type[cl]
133
+ if (
134
+ sum(v == current_type for v in cluster_to_type.values()) == 1
135
+ and auc_one > 0.55
136
+ ):
137
+ continue
138
+
139
+ cluster_rm = [k for k, v in cluster_to_type.items() if v == ct and k != cl]
140
+ keep_mask = ~cluster.isin(cluster_rm)
141
+ cluster_keep = cluster[keep_mask]
142
+ cluster_score = x.loc[keep_mask, ct]
143
+ scores[ct] = score_auc(cluster_score, cluster_keep == cl)
144
+
145
+ for ct, val in scores.items():
146
+ auc_table_update.loc[ct, cl] = val
147
+
148
+ auc_assign, cell_type_assign = assign_cluster(auc_table_update)
149
+ return auc_assign, cell_type_assign, auc_table_update
150
+
151
+
152
+ def compute_merge_cluster_auc(
153
+ x: pd.DataFrame,
154
+ cluster: pd.Series,
155
+ auc: pd.Series,
156
+ auc_table: pd.DataFrame,
157
+ merge: bool = True,
158
+ unassigned_threshold: float = 0.65,
159
+ ) -> AucLabelResult:
160
+ auc_curr = auc.copy()
161
+ auc_table_curr = auc_table.copy()
162
+ _, cell_type_curr = assign_cluster(auc_table_curr)
163
+
164
+ if merge:
165
+ auc_update, cell_type_update, auc_table_update = _reassign_cluster(
166
+ x, cluster, auc_table_curr
167
+ )
168
+ num_iter = 1
169
+ while not cell_type_curr.equals(cell_type_update) and num_iter <= 5:
170
+ num_iter += 1
171
+ auc_curr = auc_update
172
+ cell_type_curr = cell_type_update
173
+ auc_table_curr = auc_table_update
174
+ auc_update, cell_type_update, auc_table_update = _reassign_cluster(
175
+ x, cluster, auc_table_curr
176
+ )
177
+ auc_curr = auc_update
178
+ cell_type_curr = cell_type_update
179
+ auc_table_curr = auc_table_update
180
+
181
+ cluster_names = list(auc_table_curr.columns.astype(str))
182
+ cluster_to_type = _cluster_to_celltype_map(auc_table_curr)
183
+
184
+ unassigned_clusters = [
185
+ c
186
+ for c in cluster_names
187
+ if float(auc_table_curr[c].max()) < unassigned_threshold
188
+ ]
189
+ for c in unassigned_clusters:
190
+ cluster_to_type[c] = "Unassigned"
191
+
192
+ merged_auc: dict[str, float] = {}
193
+ for ct in sorted(set(cluster_to_type.values())):
194
+ if ct == "Unassigned":
195
+ continue
196
+ merge_clusters = [c for c, v in cluster_to_type.items() if v == ct]
197
+ merged_auc[ct] = score_auc(x[ct], cluster.isin(merge_clusters))
198
+
199
+ if unassigned_clusters:
200
+ vals = [
201
+ float(auc_curr.loc[c]) for c in unassigned_clusters if c in auc_curr.index
202
+ ]
203
+ if vals:
204
+ merged_auc["Unassigned"] = float(np.mean(vals))
205
+
206
+ label = cluster.astype(str).map(cluster_to_type)
207
+ return AucLabelResult(auc=pd.Series(merged_auc, dtype=float), label=label)
208
+
209
+
210
+ def compute_cluster_auc(
211
+ x: pd.DataFrame, cluster: pd.Series, marker: MarkerDict
212
+ ) -> ClusterAucResult:
213
+ marker_split = split_marker(marker)
214
+ cell_type_score = pd.DataFrame(
215
+ {
216
+ cell_type: compute_marker_score(x=x, marker_list=marker_set)
217
+ for cell_type, marker_set in marker_split.items()
218
+ }
219
+ )
220
+
221
+ uniq_cluster = pd.Index(cluster.astype(str).unique())
222
+ auc_table = pd.DataFrame(
223
+ index=list(marker_split.keys()), columns=uniq_cluster, dtype=float
224
+ )
225
+ for cell_type in marker_split.keys():
226
+ for cl in uniq_cluster:
227
+ auc_table.loc[cell_type, cl] = score_auc(
228
+ cell_type_score[cell_type], cluster.astype(str) == cl
229
+ )
230
+
231
+ auc_assign, cell_type_assign = assign_cluster(auc_table)
232
+ return ClusterAucResult(
233
+ auc_assign=auc_assign,
234
+ cell_type_assign=cell_type_assign,
235
+ auc_table=auc_table,
236
+ cell_type_score=cell_type_score,
237
+ )
238
+
239
+
240
+ def calculate_auc(
241
+ umi_count: pd.DataFrame,
242
+ cluster: pd.Series,
243
+ marker: MarkerDict,
244
+ merge: bool = True,
245
+ unassigned_threshold: float = 0.65,
246
+ ) -> AucLabelResult:
247
+ res = compute_cluster_auc(umi_count, cluster.astype(str), marker)
248
+ return compute_merge_cluster_auc(
249
+ res.cell_type_score,
250
+ cluster.astype(str),
251
+ res.auc_assign,
252
+ res.auc_table,
253
+ merge=merge,
254
+ unassigned_threshold=unassigned_threshold,
255
+ )
256
+
257
+
258
+ def average_auc(auc: pd.Series) -> float:
259
+ counts = Counter(auc.index.tolist())
260
+ if len(auc) >= len(counts) * 2:
261
+ return 0.0
262
+ score = [float(auc.loc[ct]) - 0.7 for ct in counts.keys()]
263
+ return float(np.sum(score))