cellstate-c2s 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 impatient_hedgehog
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: cellstate_c2s
3
+ Version: 0.1.0
4
+ Summary: Deviation-from-reference gene ordering for Cell2Sentence cell-state prediction
5
+ Author-email: impatient_hedgehog <impatienthedgehog@duck.com>
6
+ License-Expression: MIT
7
+ Keywords: single-cell,cell2sentence,llm,scRNA-seq,xenium
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: numpy
15
+ Requires-Dist: scipy
16
+ Requires-Dist: pandas
17
+ Requires-Dist: anndata
18
+ Provides-Extra: c2s
19
+ Requires-Dist: cell2sentence; extra == "c2s"
20
+ Requires-Dist: datasets; extra == "c2s"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest; extra == "dev"
23
+ Requires-Dist: datasets; extra == "dev"
24
+ Requires-Dist: build; extra == "dev"
25
+ Requires-Dist: twine; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # cellstate_c2s
29
+
30
+ Custom cell sentences for [Cell2Sentence](https://github.com/vandijklab/cell2sentence) fine-tuning and prediction.
31
+
32
+ Cell2Sentence normally writes each cell as its genes ordered by expression. This package replaces that with genes ordered by **deviation from a reference mean** (for example, the mean AT2 profile), so the sentence describes how a cell differs from the reference state rather than which genes are simply most abundant. You build the arrow dataset with Cell2Sentence as usual, then swap in the new sentences before creating `CSData`.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install cellstate_c2s # core: numpy, scipy, pandas, anndata
38
+ pip install "cellstate_c2s[c2s]" # plus cell2sentence and datasets
39
+ ```
40
+
41
+ From a local clone:
42
+
43
+ ```bash
44
+ pip install -e ".[dev]"
45
+ pytest
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ import cell2sentence as cs
52
+ from cellstate_c2s import DeviationSentenceBuilder, replace_sentences
53
+
54
+ # 1. Build the arrow dataset with Cell2Sentence as usual
55
+ arrow_ds, vocabulary = cs.CSData.adata_to_arrow(
56
+ adata=adata_train, random_state=SEED, sentence_delimiter=" ",
57
+ label_col_names=["annotation_cell_states", "organism", "tissue"],
58
+ )
59
+
60
+ # 2. Fit the reference and build deviation sentences
61
+ builder = DeviationSentenceBuilder(top_k=200)
62
+ builder.fit(adata_train, mask=adata_train.obs["cell_type"] == "AT2") # optional mask
63
+ names, sentences = builder.transform(adata_train)
64
+ builder.save("reference.npz")
65
+
66
+ # 3. Swap the sentences in, matched by cell name, then continue with Cell2Sentence
67
+ arrow_ds = replace_sentences(arrow_ds, names, sentences)
68
+ csdata = cs.CSData.csdata_from_arrow(
69
+ arrow_dataset=arrow_ds, vocabulary=vocabulary,
70
+ save_dir=save_dir, save_name=save_name, dataset_backend="arrow",
71
+ )
72
+ ```
73
+
74
+ **Held-out test data (same platform):** reuse the reference fitted on training data. Do not fit on the test set.
75
+
76
+ ```python
77
+ builder = DeviationSentenceBuilder.load("reference.npz")
78
+ names, sentences = builder.transform(adata_test)
79
+ ```
80
+
81
+ **Another platform (e.g. Xenium):** fit a separate reference on that dataset, then do the Cell2Sentence vocabulary swap yourself.
82
+
83
+ ```python
84
+ names_x, sents_x = DeviationSentenceBuilder(top_k=200).fit_transform(adata_xenium)
85
+ arrow_ds_xenium = replace_sentences(arrow_ds_xenium, names_x, sents_x)
86
+ ```
87
+
88
+ ## How sentences are built
89
+
90
+ For each cell, `deviation = expression - reference_mean`. Genes are sorted by deviation, highest first, and the first `top_k` are kept. Ties keep the order of `var_names` (stable sort), so the same input always gives the same sentence.
91
+
92
+ | Option | Default | Effect |
93
+ |---|---|---|
94
+ | `top_k` | 200 | Maximum genes per sentence. Capped at the number of shared genes. |
95
+ | `mask_unexpressed` | `True` | Drops genes whose value in that cell is exactly 0, so sentences can be shorter than `top_k`. Set `False` for a pure ranking of all genes. |
96
+ | `chunk_size` | 2048 | Cells densified at a time. Lower it if memory is tight. |
97
+ | `fit(..., mask=)` | all cells | Boolean array selecting the cells that define the reference. |
98
+
99
+ `transform` uses only the genes shared by the reference and the AnnData passed in, warns about missing reference genes, and ignores extra genes.
100
+
101
+ ## Before you use this
102
+
103
+ The code cannot check most of these, so they are on you. The first four are the most likely to cause silent errors.
104
+
105
+ 1. **`adata.X` is normalized, the same way in every dataset.** Deviation is in expression units, so raw counts in one dataset and log-normalized values in another give meaningless ranks. The package never reads `layers` or `.raw`.
106
+ 2. **Zero means "not expressed."** With `mask_unexpressed=True`, any gene with a value of exactly 0 in a cell is dropped.
107
+ 3. **The cells passed to `fit` define the reference.** Pass only the cells you want (e.g. AT2), or use `mask=`.
108
+ 4. **Fit on training data only for evaluation.** `fit_transform` on a test set computes the reference from test cells.
109
+ 5. **Gene names are unique and use one naming system** (symbols or Ensembl IDs, not a mix). Duplicates raise an error; mismatched naming just shrinks the shared gene set.
110
+ 6. **Cell names (`obs_names`) are unique and match the arrow dataset's `cell_name` column.** Field names are parameters of `replace_sentences` if your Cell2Sentence version differs.
111
+ 7. **The datasets are already subset to the genes you want.** The package does not select genes.
112
+
113
+ ## What is checked
114
+
115
+ | Checked in code | Not checked |
116
+ |---|---|
117
+ | `fit` called before `transform` | Normalization |
118
+ | Unique gene names | Gene naming system |
119
+ | Shared genes exist; warns on missing ones | Whether the reference cells are the right ones |
120
+ | Every arrow row has a sentence; no duplicate cell names | Train/test separation |
121
+ | Required arrow columns exist | Meaning of zeros |
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,98 @@
1
+ # cellstate_c2s
2
+
3
+ Custom cell sentences for [Cell2Sentence](https://github.com/vandijklab/cell2sentence) fine-tuning and prediction.
4
+
5
+ Cell2Sentence normally writes each cell as its genes ordered by expression. This package replaces that with genes ordered by **deviation from a reference mean** (for example, the mean AT2 profile), so the sentence describes how a cell differs from the reference state rather than which genes are simply most abundant. You build the arrow dataset with Cell2Sentence as usual, then swap in the new sentences before creating `CSData`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install cellstate_c2s # core: numpy, scipy, pandas, anndata
11
+ pip install "cellstate_c2s[c2s]" # plus cell2sentence and datasets
12
+ ```
13
+
14
+ From a local clone:
15
+
16
+ ```bash
17
+ pip install -e ".[dev]"
18
+ pytest
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ import cell2sentence as cs
25
+ from cellstate_c2s import DeviationSentenceBuilder, replace_sentences
26
+
27
+ # 1. Build the arrow dataset with Cell2Sentence as usual
28
+ arrow_ds, vocabulary = cs.CSData.adata_to_arrow(
29
+ adata=adata_train, random_state=SEED, sentence_delimiter=" ",
30
+ label_col_names=["annotation_cell_states", "organism", "tissue"],
31
+ )
32
+
33
+ # 2. Fit the reference and build deviation sentences
34
+ builder = DeviationSentenceBuilder(top_k=200)
35
+ builder.fit(adata_train, mask=adata_train.obs["cell_type"] == "AT2") # optional mask
36
+ names, sentences = builder.transform(adata_train)
37
+ builder.save("reference.npz")
38
+
39
+ # 3. Swap the sentences in, matched by cell name, then continue with Cell2Sentence
40
+ arrow_ds = replace_sentences(arrow_ds, names, sentences)
41
+ csdata = cs.CSData.csdata_from_arrow(
42
+ arrow_dataset=arrow_ds, vocabulary=vocabulary,
43
+ save_dir=save_dir, save_name=save_name, dataset_backend="arrow",
44
+ )
45
+ ```
46
+
47
+ **Held-out test data (same platform):** reuse the reference fitted on training data. Do not fit on the test set.
48
+
49
+ ```python
50
+ builder = DeviationSentenceBuilder.load("reference.npz")
51
+ names, sentences = builder.transform(adata_test)
52
+ ```
53
+
54
+ **Another platform (e.g. Xenium):** fit a separate reference on that dataset, then do the Cell2Sentence vocabulary swap yourself.
55
+
56
+ ```python
57
+ names_x, sents_x = DeviationSentenceBuilder(top_k=200).fit_transform(adata_xenium)
58
+ arrow_ds_xenium = replace_sentences(arrow_ds_xenium, names_x, sents_x)
59
+ ```
60
+
61
+ ## How sentences are built
62
+
63
+ For each cell, `deviation = expression - reference_mean`. Genes are sorted by deviation, highest first, and the first `top_k` are kept. Ties keep the order of `var_names` (stable sort), so the same input always gives the same sentence.
64
+
65
+ | Option | Default | Effect |
66
+ |---|---|---|
67
+ | `top_k` | 200 | Maximum genes per sentence. Capped at the number of shared genes. |
68
+ | `mask_unexpressed` | `True` | Drops genes whose value in that cell is exactly 0, so sentences can be shorter than `top_k`. Set `False` for a pure ranking of all genes. |
69
+ | `chunk_size` | 2048 | Cells densified at a time. Lower it if memory is tight. |
70
+ | `fit(..., mask=)` | all cells | Boolean array selecting the cells that define the reference. |
71
+
72
+ `transform` uses only the genes shared by the reference and the AnnData passed in, warns about missing reference genes, and ignores extra genes.
73
+
74
+ ## Before you use this
75
+
76
+ The code cannot check most of these, so they are on you. The first four are the most likely to cause silent errors.
77
+
78
+ 1. **`adata.X` is normalized, the same way in every dataset.** Deviation is in expression units, so raw counts in one dataset and log-normalized values in another give meaningless ranks. The package never reads `layers` or `.raw`.
79
+ 2. **Zero means "not expressed."** With `mask_unexpressed=True`, any gene with a value of exactly 0 in a cell is dropped.
80
+ 3. **The cells passed to `fit` define the reference.** Pass only the cells you want (e.g. AT2), or use `mask=`.
81
+ 4. **Fit on training data only for evaluation.** `fit_transform` on a test set computes the reference from test cells.
82
+ 5. **Gene names are unique and use one naming system** (symbols or Ensembl IDs, not a mix). Duplicates raise an error; mismatched naming just shrinks the shared gene set.
83
+ 6. **Cell names (`obs_names`) are unique and match the arrow dataset's `cell_name` column.** Field names are parameters of `replace_sentences` if your Cell2Sentence version differs.
84
+ 7. **The datasets are already subset to the genes you want.** The package does not select genes.
85
+
86
+ ## What is checked
87
+
88
+ | Checked in code | Not checked |
89
+ |---|---|
90
+ | `fit` called before `transform` | Normalization |
91
+ | Unique gene names | Gene naming system |
92
+ | Shared genes exist; warns on missing ones | Whether the reference cells are the right ones |
93
+ | Every arrow row has a sentence; no duplicate cell names | Train/test separation |
94
+ | Required arrow columns exist | Meaning of zeros |
95
+
96
+ ## License
97
+
98
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cellstate_c2s"
7
+ version = "0.1.0"
8
+ description = "Deviation-from-reference gene ordering for Cell2Sentence cell-state prediction"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+
14
+ authors = [{ name = "impatient_hedgehog", email = "impatienthedgehog@duck.com" }]
15
+ keywords = ["single-cell", "cell2sentence", "llm", "scRNA-seq", "xenium"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
20
+ ]
21
+ dependencies = ["numpy", "scipy", "pandas", "anndata"]
22
+
23
+ [project.optional-dependencies]
24
+ c2s = ["cell2sentence", "datasets"]
25
+ dev = ["pytest", "datasets", "build", "twine"]
26
+
27
+
28
+
29
+ # Homepage = "https://github.com/phdbreath/cellstate_c2s"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Deviation-from-reference gene ordering for Cell2Sentence cell-state prediction."""
2
+
3
+ from .builder import DeviationSentenceBuilder
4
+ from .c2s_adapter import replace_sentences
5
+
6
+ __all__ = ["DeviationSentenceBuilder", "replace_sentences"]
7
+ __version__ = "0.1.0"
@@ -0,0 +1,154 @@
1
+ """Deviation-from-reference cell sentences."""
2
+
3
+ import warnings
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from scipy import sparse
8
+
9
+
10
+ class DeviationSentenceBuilder:
11
+ """
12
+ Build cell sentences by ranking genes on signed deviation from a reference mean.
13
+
14
+ For each cell, (deviation = expression - reference_mean). Genes are sorted
15
+ by deviation (highest first, stable on ties) and the first (top_k) are
16
+ kept. With (mask_unexpressed=True), genes whose value in that cell is
17
+ exactly 0 are dropped, so sentences can be shorter than (top_k).
18
+
19
+ Parameters
20
+ ----------
21
+ top_k : int
22
+ Maximum number of genes per sentence.
23
+ mask_unexpressed : bool
24
+ Drop genes with zero expression in the cell.
25
+ chunk_size : int
26
+ Number of cells densified at a time in ``transform``.
27
+
28
+ Notes
29
+ -----
30
+ adata.X must hold normalized expression, on the same scale for every
31
+ dataset you convert. The reference mean is taken over every cell passed to
32
+ fit (or the subset selected by mask).
33
+ """
34
+
35
+ def __init__(self, top_k=200, mask_unexpressed=True, chunk_size=2048):
36
+ if top_k < 1:
37
+ raise ValueError("top_k must be at least 1.")
38
+ if chunk_size < 1:
39
+ raise ValueError("chunk_size must be at least 1.")
40
+ self.top_k = top_k
41
+ self.mask_unexpressed = mask_unexpressed
42
+ self.chunk_size = chunk_size
43
+ self.reference_mean_ = None
44
+ self.gene_names_ = None
45
+ self.n_reference_cells_ = None
46
+
47
+ def fit(self, adata, mask=None):
48
+ """
49
+ Compute the reference mean.
50
+
51
+ Parameters
52
+ ----------
53
+ adata : anndata.AnnData
54
+ Cells x genes, normalized.
55
+ mask : array-like of bool, optional
56
+ Selects the cells that define the reference (for example AT2 cells
57
+ only). Defaults to all cells.
58
+ """
59
+ _check_unique_var_names(adata)
60
+ X = adata.X
61
+ if mask is not None:
62
+ mask = np.asarray(mask, dtype=bool)
63
+ if mask.shape != (adata.n_obs,):
64
+ raise ValueError(f"mask must have shape ({adata.n_obs},), got {mask.shape}.")
65
+ if not mask.any():
66
+ raise ValueError("mask selects no cells.")
67
+ X = X[mask]
68
+ if X.shape[0] == 0:
69
+ raise ValueError("Cannot fit a reference on zero cells.")
70
+ self.reference_mean_ = np.asarray(X.mean(axis=0)).ravel().astype(np.float64)
71
+ self.gene_names_ = np.asarray(adata.var_names).astype(str)
72
+ self.n_reference_cells_ = int(X.shape[0])
73
+ return self
74
+
75
+ def transform(self, adata):
76
+ """
77
+ Return (cell_names, sentences) for every cell in adata.
78
+
79
+ Only genes present in both the reference and adata are used. A
80
+ warning lists how many reference genes are missing.
81
+ """
82
+ self._check_fitted()
83
+ _check_unique_var_names(adata)
84
+ idx = pd.Index(adata.var_names).get_indexer(self.gene_names_)
85
+ keep = idx >= 0
86
+ if not keep.any():
87
+ raise ValueError("No genes shared between the reference and this AnnData.")
88
+ if not keep.all():
89
+ warnings.warn(
90
+ f"{int((~keep).sum())} of {len(keep)} reference genes are missing from "
91
+ "this AnnData and will be ignored.",
92
+ stacklevel=2,
93
+ )
94
+ col_idx = idx[keep]
95
+ genes = self.gene_names_[keep]
96
+ ref = self.reference_mean_[keep]
97
+ k = min(self.top_k, len(genes))
98
+
99
+ X = adata.X
100
+ sentences = []
101
+ for start in range(0, X.shape[0], self.chunk_size):
102
+ chunk = X[start:start + self.chunk_size][:, col_idx]
103
+ chunk = chunk.toarray() if sparse.issparse(chunk) else np.asarray(chunk)
104
+ chunk = chunk.astype(np.float64, copy=False)
105
+ dev = chunk - ref
106
+ if self.mask_unexpressed:
107
+ dev = np.where(chunk > 0, dev, -np.inf)
108
+ order = np.argsort(-dev, axis=1, kind="stable")[:, :k]
109
+ for row in range(order.shape[0]):
110
+ o = order[row]
111
+ if self.mask_unexpressed:
112
+ o = o[np.isfinite(dev[row, o])]
113
+ sentences.append(" ".join(genes[o]))
114
+ return [str(n) for n in adata.obs_names], sentences
115
+
116
+ def fit_transform(self, adata, mask=None):
117
+ """Fit the reference on ``adata`` and transform the same cells."""
118
+ return self.fit(adata, mask=mask).transform(adata)
119
+
120
+ def save(self, path):
121
+ """Save the reference and settings to a ``.npz`` file."""
122
+ self._check_fitted()
123
+ np.savez(
124
+ path,
125
+ reference_mean=self.reference_mean_,
126
+ gene_names=self.gene_names_,
127
+ n_reference_cells=self.n_reference_cells_,
128
+ top_k=self.top_k,
129
+ mask_unexpressed=self.mask_unexpressed,
130
+ chunk_size=self.chunk_size,
131
+ )
132
+
133
+ @classmethod
134
+ def load(cls, path):
135
+ """Load a builder saved with :meth:save."""
136
+ with np.load(path, allow_pickle=False) as d:
137
+ obj = cls(
138
+ top_k=int(d["top_k"]),
139
+ mask_unexpressed=bool(d["mask_unexpressed"]),
140
+ chunk_size=int(d["chunk_size"]),
141
+ )
142
+ obj.reference_mean_ = d["reference_mean"]
143
+ obj.gene_names_ = d["gene_names"].astype(str)
144
+ obj.n_reference_cells_ = int(d["n_reference_cells"])
145
+ return obj
146
+
147
+ def _check_fitted(self):
148
+ if self.reference_mean_ is None:
149
+ raise RuntimeError("Call fit() first.")
150
+
151
+
152
+ def _check_unique_var_names(adata):
153
+ if not adata.var_names.is_unique:
154
+ raise ValueError("var_names must be unique; call adata.var_names_make_unique().")
@@ -0,0 +1,43 @@
1
+ """
2
+ Glue for Cell2Sentence arrow datasets.
3
+
4
+ Works on Hugging Face datasets.Dataset, such as the one returned by
5
+ cell2sentence.CSData.adata_to_arrow. This module does not import
6
+ cell2sentence itself.
7
+ """
8
+
9
+
10
+ def replace_sentences(arrow_ds, cell_names, sentences,
11
+ field="cell_sentence", name_field="cell_name"):
12
+ """Swap the sentence column of an arrow dataset, matching rows by cell name.
13
+
14
+ Parameters
15
+ ----------
16
+ arrow_ds : datasets.Dataset
17
+ Dataset with (field) and (name_field) columns.
18
+ cell_names, sentences : sequence of str
19
+ Output of (DeviationSentenceBuilder.transform).
20
+ field : str
21
+ Column holding the cell sentence.
22
+ name_field : str
23
+ Column holding the cell name, matching (adata.obs_names).
24
+
25
+ Returns
26
+ -------
27
+ datasets.Dataset
28
+ A new dataset; the input is not modified.
29
+ """
30
+ if len(cell_names) != len(sentences):
31
+ raise ValueError("cell_names and sentences must have the same length.")
32
+ lookup = dict(zip(cell_names, sentences))
33
+ if len(lookup) != len(cell_names):
34
+ raise ValueError("cell_names contains duplicates.")
35
+ for col in (field, name_field):
36
+ if col not in arrow_ds.column_names:
37
+ raise KeyError(f"Column {col!r} not in dataset; columns are {arrow_ds.column_names}.")
38
+ missing = [n for n in arrow_ds[name_field] if n not in lookup]
39
+ if missing:
40
+ raise KeyError(
41
+ f"{len(missing)} cells in the arrow dataset have no sentence, e.g. {missing[:3]}"
42
+ )
43
+ return arrow_ds.map(lambda ex: {field: lookup[ex[name_field]]})
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: cellstate_c2s
3
+ Version: 0.1.0
4
+ Summary: Deviation-from-reference gene ordering for Cell2Sentence cell-state prediction
5
+ Author-email: impatient_hedgehog <impatienthedgehog@duck.com>
6
+ License-Expression: MIT
7
+ Keywords: single-cell,cell2sentence,llm,scRNA-seq,xenium
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: numpy
15
+ Requires-Dist: scipy
16
+ Requires-Dist: pandas
17
+ Requires-Dist: anndata
18
+ Provides-Extra: c2s
19
+ Requires-Dist: cell2sentence; extra == "c2s"
20
+ Requires-Dist: datasets; extra == "c2s"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest; extra == "dev"
23
+ Requires-Dist: datasets; extra == "dev"
24
+ Requires-Dist: build; extra == "dev"
25
+ Requires-Dist: twine; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # cellstate_c2s
29
+
30
+ Custom cell sentences for [Cell2Sentence](https://github.com/vandijklab/cell2sentence) fine-tuning and prediction.
31
+
32
+ Cell2Sentence normally writes each cell as its genes ordered by expression. This package replaces that with genes ordered by **deviation from a reference mean** (for example, the mean AT2 profile), so the sentence describes how a cell differs from the reference state rather than which genes are simply most abundant. You build the arrow dataset with Cell2Sentence as usual, then swap in the new sentences before creating `CSData`.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install cellstate_c2s # core: numpy, scipy, pandas, anndata
38
+ pip install "cellstate_c2s[c2s]" # plus cell2sentence and datasets
39
+ ```
40
+
41
+ From a local clone:
42
+
43
+ ```bash
44
+ pip install -e ".[dev]"
45
+ pytest
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ import cell2sentence as cs
52
+ from cellstate_c2s import DeviationSentenceBuilder, replace_sentences
53
+
54
+ # 1. Build the arrow dataset with Cell2Sentence as usual
55
+ arrow_ds, vocabulary = cs.CSData.adata_to_arrow(
56
+ adata=adata_train, random_state=SEED, sentence_delimiter=" ",
57
+ label_col_names=["annotation_cell_states", "organism", "tissue"],
58
+ )
59
+
60
+ # 2. Fit the reference and build deviation sentences
61
+ builder = DeviationSentenceBuilder(top_k=200)
62
+ builder.fit(adata_train, mask=adata_train.obs["cell_type"] == "AT2") # optional mask
63
+ names, sentences = builder.transform(adata_train)
64
+ builder.save("reference.npz")
65
+
66
+ # 3. Swap the sentences in, matched by cell name, then continue with Cell2Sentence
67
+ arrow_ds = replace_sentences(arrow_ds, names, sentences)
68
+ csdata = cs.CSData.csdata_from_arrow(
69
+ arrow_dataset=arrow_ds, vocabulary=vocabulary,
70
+ save_dir=save_dir, save_name=save_name, dataset_backend="arrow",
71
+ )
72
+ ```
73
+
74
+ **Held-out test data (same platform):** reuse the reference fitted on training data. Do not fit on the test set.
75
+
76
+ ```python
77
+ builder = DeviationSentenceBuilder.load("reference.npz")
78
+ names, sentences = builder.transform(adata_test)
79
+ ```
80
+
81
+ **Another platform (e.g. Xenium):** fit a separate reference on that dataset, then do the Cell2Sentence vocabulary swap yourself.
82
+
83
+ ```python
84
+ names_x, sents_x = DeviationSentenceBuilder(top_k=200).fit_transform(adata_xenium)
85
+ arrow_ds_xenium = replace_sentences(arrow_ds_xenium, names_x, sents_x)
86
+ ```
87
+
88
+ ## How sentences are built
89
+
90
+ For each cell, `deviation = expression - reference_mean`. Genes are sorted by deviation, highest first, and the first `top_k` are kept. Ties keep the order of `var_names` (stable sort), so the same input always gives the same sentence.
91
+
92
+ | Option | Default | Effect |
93
+ |---|---|---|
94
+ | `top_k` | 200 | Maximum genes per sentence. Capped at the number of shared genes. |
95
+ | `mask_unexpressed` | `True` | Drops genes whose value in that cell is exactly 0, so sentences can be shorter than `top_k`. Set `False` for a pure ranking of all genes. |
96
+ | `chunk_size` | 2048 | Cells densified at a time. Lower it if memory is tight. |
97
+ | `fit(..., mask=)` | all cells | Boolean array selecting the cells that define the reference. |
98
+
99
+ `transform` uses only the genes shared by the reference and the AnnData passed in, warns about missing reference genes, and ignores extra genes.
100
+
101
+ ## Before you use this
102
+
103
+ The code cannot check most of these, so they are on you. The first four are the most likely to cause silent errors.
104
+
105
+ 1. **`adata.X` is normalized, the same way in every dataset.** Deviation is in expression units, so raw counts in one dataset and log-normalized values in another give meaningless ranks. The package never reads `layers` or `.raw`.
106
+ 2. **Zero means "not expressed."** With `mask_unexpressed=True`, any gene with a value of exactly 0 in a cell is dropped.
107
+ 3. **The cells passed to `fit` define the reference.** Pass only the cells you want (e.g. AT2), or use `mask=`.
108
+ 4. **Fit on training data only for evaluation.** `fit_transform` on a test set computes the reference from test cells.
109
+ 5. **Gene names are unique and use one naming system** (symbols or Ensembl IDs, not a mix). Duplicates raise an error; mismatched naming just shrinks the shared gene set.
110
+ 6. **Cell names (`obs_names`) are unique and match the arrow dataset's `cell_name` column.** Field names are parameters of `replace_sentences` if your Cell2Sentence version differs.
111
+ 7. **The datasets are already subset to the genes you want.** The package does not select genes.
112
+
113
+ ## What is checked
114
+
115
+ | Checked in code | Not checked |
116
+ |---|---|
117
+ | `fit` called before `transform` | Normalization |
118
+ | Unique gene names | Gene naming system |
119
+ | Shared genes exist; warns on missing ones | Whether the reference cells are the right ones |
120
+ | Every arrow row has a sentence; no duplicate cell names | Train/test separation |
121
+ | Required arrow columns exist | Meaning of zeros |
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/cellstate_c2s/__init__.py
5
+ src/cellstate_c2s/builder.py
6
+ src/cellstate_c2s/c2s_adapter.py
7
+ src/cellstate_c2s.egg-info/PKG-INFO
8
+ src/cellstate_c2s.egg-info/SOURCES.txt
9
+ src/cellstate_c2s.egg-info/dependency_links.txt
10
+ src/cellstate_c2s.egg-info/requires.txt
11
+ src/cellstate_c2s.egg-info/top_level.txt
12
+ tests/test_adapter.py
13
+ tests/test_builder.py
@@ -0,0 +1,14 @@
1
+ numpy
2
+ scipy
3
+ pandas
4
+ anndata
5
+
6
+ [c2s]
7
+ cell2sentence
8
+ datasets
9
+
10
+ [dev]
11
+ pytest
12
+ datasets
13
+ build
14
+ twine
@@ -0,0 +1 @@
1
+ cellstate_c2s
@@ -0,0 +1,36 @@
1
+ import pytest
2
+
3
+ datasets = pytest.importorskip("datasets")
4
+
5
+ from cellstate_c2s import replace_sentences # noqa: E402
6
+
7
+
8
+ @pytest.fixture
9
+ def arrow_ds():
10
+ return datasets.Dataset.from_dict({
11
+ "cell_name": ["c1", "c0"],
12
+ "cell_sentence": ["old1", "old0"],
13
+ "annotation_cell_states": ["AT2_2", "AT2_1"],
14
+ })
15
+
16
+
17
+ def test_replace_matches_by_name(arrow_ds):
18
+ out = replace_sentences(arrow_ds, ["c0", "c1"], ["A C", "B D"])
19
+ assert out["cell_sentence"] == ["B D", "A C"]
20
+ assert out["annotation_cell_states"] == ["AT2_2", "AT2_1"]
21
+ assert arrow_ds["cell_sentence"] == ["old1", "old0"] # input untouched
22
+
23
+
24
+ def test_missing_cell_raises(arrow_ds):
25
+ with pytest.raises(KeyError, match="no sentence"):
26
+ replace_sentences(arrow_ds, ["c0"], ["A C"])
27
+
28
+
29
+ def test_duplicate_names_raise(arrow_ds):
30
+ with pytest.raises(ValueError, match="duplicates"):
31
+ replace_sentences(arrow_ds, ["c0", "c0", "c1"], ["a", "b", "c"])
32
+
33
+
34
+ def test_missing_column_raises(arrow_ds):
35
+ with pytest.raises(KeyError, match="not in dataset"):
36
+ replace_sentences(arrow_ds, ["c0", "c1"], ["a", "b"], name_field="barcode")
@@ -0,0 +1,103 @@
1
+ import anndata as ad
2
+ import numpy as np
3
+ import pytest
4
+ from scipy import sparse
5
+
6
+ from cellstate_c2s import DeviationSentenceBuilder
7
+
8
+ # Gene means over the 3 cells: A=2, B=2, C=1, D=2/3
9
+ X = np.array([[4, 0, 2, 0], [0, 3, 0, 1], [2, 3, 1, 1]], dtype=float)
10
+
11
+
12
+ def make_adata(dense=False):
13
+ a = ad.AnnData(X.copy() if dense else sparse.csr_matrix(X))
14
+ a.var_names = list("ABCD")
15
+ a.obs_names = ["c0", "c1", "c2"]
16
+ return a
17
+
18
+
19
+ @pytest.fixture(params=[False, True], ids=["sparse", "dense"])
20
+ def adata(request):
21
+ return make_adata(dense=request.param)
22
+
23
+
24
+ def test_reference_mean(adata):
25
+ b = DeviationSentenceBuilder().fit(adata)
26
+ np.testing.assert_allclose(b.reference_mean_, [2, 2, 1, 2 / 3])
27
+ assert b.n_reference_cells_ == 3
28
+
29
+
30
+ def test_masked(adata):
31
+ names, s = DeviationSentenceBuilder().fit_transform(adata)
32
+ assert names == ["c0", "c1", "c2"]
33
+ assert s == ["A C", "B D", "B D A C"] # last cell: A/C tie, stable order
34
+
35
+
36
+ def test_unmasked(adata):
37
+ _, s = DeviationSentenceBuilder(mask_unexpressed=False).fit_transform(adata)
38
+ assert s[0] == "A C D B"
39
+
40
+
41
+ def test_top_k(adata):
42
+ _, s = DeviationSentenceBuilder(top_k=2, mask_unexpressed=False).fit_transform(adata)
43
+ assert s[0] == "A C"
44
+
45
+
46
+ def test_chunking_matches(adata):
47
+ _, full = DeviationSentenceBuilder().fit_transform(adata)
48
+ _, chunked = DeviationSentenceBuilder(chunk_size=1).fit_transform(adata)
49
+ assert full == chunked
50
+
51
+
52
+ def test_fit_mask(adata):
53
+ # Reference from c0 only: A=4, B=0, C=2, D=0
54
+ b = DeviationSentenceBuilder(mask_unexpressed=False).fit(adata, mask=[True, False, False])
55
+ np.testing.assert_allclose(b.reference_mean_, [4, 0, 2, 0])
56
+ _, s = b.transform(adata)
57
+ assert s[1] == "B D C A" # B=3, D=1, C=-2, A=-4
58
+
59
+
60
+ def test_fit_mask_errors(adata):
61
+ with pytest.raises(ValueError):
62
+ DeviationSentenceBuilder().fit(adata, mask=[False, False, False])
63
+ with pytest.raises(ValueError):
64
+ DeviationSentenceBuilder().fit(adata, mask=[True, False])
65
+
66
+
67
+ def test_transform_on_other_dataset_uses_shared_genes(adata):
68
+ b = DeviationSentenceBuilder(mask_unexpressed=False).fit(adata)
69
+ other = ad.AnnData(np.array([[5.0, 0.0, 9.0]]))
70
+ other.var_names = ["C", "A", "Z"] # different order, B and D missing, Z extra
71
+ other.obs_names = ["x0"]
72
+ with pytest.warns(UserWarning, match="2 of 4 reference genes"):
73
+ names, s = b.transform(other)
74
+ assert names == ["x0"]
75
+ assert s == ["C A"] # C: 5-1=4, A: 0-2=-2
76
+
77
+
78
+ def test_no_shared_genes(adata):
79
+ b = DeviationSentenceBuilder().fit(adata)
80
+ other = ad.AnnData(np.ones((1, 1)))
81
+ other.var_names = ["Z"]
82
+ with pytest.raises(ValueError, match="No genes shared"):
83
+ b.transform(other)
84
+
85
+
86
+ def test_duplicate_var_names_rejected():
87
+ a = ad.AnnData(np.ones((2, 2)))
88
+ a.var_names = ["A", "A"]
89
+ with pytest.raises(ValueError, match="unique"):
90
+ DeviationSentenceBuilder().fit(a)
91
+
92
+
93
+ def test_transform_before_fit(adata):
94
+ with pytest.raises(RuntimeError):
95
+ DeviationSentenceBuilder().transform(adata)
96
+
97
+
98
+ def test_save_load_roundtrip(adata, tmp_path):
99
+ b = DeviationSentenceBuilder(top_k=3, mask_unexpressed=False, chunk_size=7).fit(adata)
100
+ b.save(tmp_path / "ref.npz")
101
+ b2 = DeviationSentenceBuilder.load(tmp_path / "ref.npz")
102
+ assert (b2.top_k, b2.mask_unexpressed, b2.chunk_size) == (3, False, 7)
103
+ assert b.transform(adata) == b2.transform(adata)