|
2 | 2 |
|
3 | 3 | [](https://github.com/YosefLab/csde/actions/workflows/test.yml) |
4 | 4 |
|
5 | | -`csde` (Corrected Spatial Differential Expression) is a Python package designed to **identify differentially expressed (DE) genes between spatially-resolved cell populations** (e.g., T-cells inside vs. outside a tumor). |
| 5 | +Automated pipelines for spatial transcriptomics produce cell quantifications (cell-by-gene expression matrices and label assignments) that contain systematic errors, e.g., due to mis-segmentation of cell boundaries. |
| 6 | +These errors can propagate into downstream analyses of differential expression, leading to false discoveries or missed signals |
6 | 7 |
|
7 | | -Standard analysis relies on cell population assignments (e.g., "infiltrating" vs. "non-infiltrating") obtained automatically from clustering/ML that are often prone to errors. `csde` corrects for these inaccuracies by leveraging a small subset of validated "ground-truth" data, providing rigorous statistical guarantees for spatially-resolved DE analyses. |
| 8 | +CSDE corrects for these errors by combining the large automated dataset with a small set of manually validated cells, using prediction-powered inference to recover unbiased estimates with valid confidence intervals. |
8 | 9 |
|
9 | | -Refer to the preprint and the [project repository](https://github.com/YosefLab/csde) for more details. |
| 10 | +The current codebase focuses on the comparison of a given cell type across two spatial regions. |
| 11 | +It allows users to |
| 12 | +1. export per-cell annotation panels for a small subset of cells (e.g. 600) |
| 13 | +2. manually validate the segmentation and type assignment for these cells |
| 14 | +3. run the CSDE model to get corrected DE estimates for all genes |
| 15 | + |
| 16 | +Refer to the [preprint](https://www.biorxiv.org/content/10.64898/2026.01.15.699786v1) for details on the method. Reproducibility code is available [here](https://github.com/PierreBoyeau/csde_experiments). |
| 17 | + |
| 18 | +### Input requirements |
| 19 | + |
| 20 | +The workflow takes a [SpatialData](https://spatialdata.scverse.org/) zarr as input. |
| 21 | + |
| 22 | +Its `"table"` AnnData must contain: |
| 23 | + |
| 24 | +- **raw expression counts** in `.X` or a named layer |
| 25 | +- **the following `obs` columns:** |
| 26 | + |
| 27 | +| obs column | content | |
| 28 | +| --- | --- | |
| 29 | +| `cell_type` (configurable) | cell-type label for each cell | |
| 30 | +| `spatial_group` (configurable) | binary spatial region label (e.g. `0` = outside tumour, `1` = inside tumour) | |
| 31 | +| `center_x`, `center_y` | cell centroid in microns | |
| 32 | + |
| 33 | +The zarr must also expose the following SpatialData elements, used to render the per-cell annotation panels (Step 1): |
| 34 | + |
| 35 | +| element | requirement | |
| 36 | +| --- | --- | |
| 37 | +| `images` | at least one image with a named **fluorescence channel** (e.g. `"DAPI"`, `"Cellbound2"`) | |
| 38 | +| `shapes` | at least one element holding the **cell-boundary polygons** | |
| 39 | +| `points` | at least one element holding **transcript locations**, with a `gene` column | |
| 40 | + |
| 41 | +The cell-boundary `shapes` must carry a transformation to the `global` coordinate system: it converts the micron `center_x`/`center_y` centroids into the image's pixel space. This conversion assumes a pure scale-and-translation transform (as produced for MERSCOPE); transforms with rotation or shear are not handled. |
10 | 42 |
|
11 | 43 | ## Installation |
12 | 44 |
|
13 | 45 | ```bash |
14 | 46 | pip install csde |
| 47 | +pip install "csde[cuda12]" # GPU (CUDA 12) |
| 48 | +pip install "csde[annotate]" # annotation UI (Step 2, requires streamlit) |
| 49 | +pip install "csde[cuda12,annotate]" # both |
15 | 50 | ``` |
16 | 51 |
|
17 | | -By default, this installs JAX with CPU support. To enable GPU support (CUDA), install with the appropriate extra (e.g., for CUDA 12): |
| 52 | +## Workflow overview |
| 53 | + |
| 54 | +CSDE runs as three scripts executed in sequence, each consuming the previous one's output: `export.py` samples a small set of cells and renders an annotation panel for each, `annotate.py` lets you manually mark those cells as correct or incorrect, and `differential_expression.py` feeds those validated labels into the CSDE model to produce corrected DE estimates. All three share a single annotation directory. |
| 55 | + |
| 56 | +``` |
| 57 | +SpatialData zarr |
| 58 | + │ |
| 59 | + ▼ |
| 60 | +1. Export annotation panels ←─ scripts/export.py |
| 61 | + (importance-sampled cells, |
| 62 | + one image per cell) |
| 63 | + │ |
| 64 | + ▼ |
| 65 | +2. Manual validation ←─ scripts/annotate.py |
| 66 | + (annotator marks each cell |
| 67 | + as correctly / incorrectly labelled) |
| 68 | + │ |
| 69 | + ▼ |
| 70 | +3. Run CSDE ←─ scripts/differential_expression.py |
| 71 | + (corrected DE estimates) |
| 72 | +``` |
| 73 | + |
| 74 | +--- |
| 75 | + |
| 76 | +## Step 1 — Export annotation panels (`scripts/export.py`) |
| 77 | + |
| 78 | +Before running the statistical model, a small subset of cells must be manually validated. `csde` provides tooling to generate the per-cell images needed for that step. |
| 79 | + |
18 | 80 | ```bash |
19 | | -pip install "csde[cuda12]" |
| 81 | +python scripts/export.py \ |
| 82 | +--sdata /path/to/region.zarr \ |
| 83 | +--out /path/to/annotation_dir \ |
| 84 | +--cell-type-key cell_type \ |
| 85 | +--cell-type-of-interest macrophages \ |
| 86 | +--target-proportion 0.4 \ |
| 87 | +--gene-colors scripts/gene_colors_file.json \ |
| 88 | +--image-channel Cellbound2 \ |
| 89 | +--n-cells 600 \ |
| 90 | +--layer counts |
20 | 91 | ``` |
21 | 92 |
|
22 | | -## Data Requirements |
23 | | - |
24 | | -`csde` requires two `AnnData` objects containing gene expression counts. Typically, these are obtained by splitting your full dataset into two groups: |
25 | | - |
26 | | -### 1. `adata_pred`: The dataset to analyze |
27 | | -This object contains the bulk of your cells (e.g., the majority of the tissue) where only standard (predicted) cell population assignments are available. |
28 | | - |
29 | | -**Requirements:** |
30 | | -* A column in `.obs` (e.g., `"cell_population"`) containing cell population labels (e.g., "T cell (infiltrating)" vs. "T cell (non-infiltrating)"). These labels can be derived from heuristics (e.g., distance to tumor) and/or computational classifiers. |
31 | | - |
32 | | -### 2. `adata_gt`: The correction set |
33 | | -This object contains a small subset of randomly sampled cells whose cell population assignments have been **validated** to serve as a ground truth. This set allows `csde` to estimate the error rate of the standard predictions. |
34 | | - |
35 | | -**Requirements:** |
36 | | -* **Prediction column:** The same column name as in `adata_pred` (e.g., `"cell_population"`), containing the automated labels. |
37 | | -* **Validation column:** A **boolean** column in `.obs` (e.g., `"is_correct"`) indicating if the automated label matches the validation ground truth (see [How to construct `adata_gt`?](#how-to-construct-adata_gt)). |
38 | | - |
39 | | -## Usage |
40 | | - |
41 | | -```python |
42 | | -from csde import run_csde |
43 | | - |
44 | | -results = run_csde( |
45 | | - # `AnnData` datasets to analyze |
46 | | - adata_pred=adata_pred, |
47 | | - adata_gt=adata_gt, |
48 | | - |
49 | | - # Column containing the predicted labels (in BOTH datasets) |
50 | | - pred_cell_pop_key="cell_population", |
51 | | - |
52 | | - # The two populations to compare |
53 | | - cell_pop_a="T-cell (infiltrating)", # Reference group |
54 | | - cell_pop_b="T-cell (non-infiltrating)", # Target group |
55 | | - |
56 | | - # Boolean column in adata_gt verifying the prediction |
57 | | - gt_key="is_correct", |
58 | | - |
59 | | - # Optional: Use a specific layer for counts (default uses .X) |
60 | | - layer_name="counts" |
61 | | -) |
62 | | - |
63 | | -# Returns a DataFrame with log_fold_change, p_value, and adjusted p_value |
64 | | -print(results.head()) |
| 93 | +`--target-proportion` controls the fraction of cells of interest in the subsample. Cells of interest are upweighted accordingly (importance sampling); the unnormalized weight for each sampled cell is stored in `metadata.csv` for downstream use. |
| 94 | + |
| 95 | +`--layer` selects which expression matrix to read: the named `.layers` entry holding the raw counts (e.g. `counts`), or `.X` when omitted. The value is saved to `config.json` and reused throughout the workflow — the same layer feeds the top-gene panels here in Step 1 and the CSDE model in Step 3, so set it once at export time. **It must point at raw counts**, since the noise model (Poisson / negative binomial) assumes integer counts; pointing it at normalised or log-transformed values will produce invalid results. |
| 96 | + |
| 97 | +The script writes: |
| 98 | + |
| 99 | +``` |
| 100 | +/path/to/annotation_dir/ |
| 101 | +├── images/ |
| 102 | +│ ├── cell_<id>.png # one panel per cell |
| 103 | +│ └── ... |
| 104 | +├── config.json # all export arguments (read by annotate.py) |
| 105 | +├── metadata.csv # cell_id, cell_type, image_path, sampling_weight, center_x, center_y |
| 106 | +└── annotations.json # {cell_id: true/false} — written by annotate.py |
65 | 107 | ``` |
66 | 108 |
|
67 | | -### Output Columns |
68 | | -The returned DataFrame is indexed by gene name and contains: |
69 | | -* `log_fold_change`: The estimated log-fold change of expression (Target vs. Reference). Positive values indicate upregulation in `cell_pop_b`. |
70 | | -* `p_value`: The raw p-value from the hypothesis test (two-sided). |
71 | | -* `p_value_adj`: The p-value adjusted for multiple testing (Benjamini-Hochberg FDR). |
| 109 | +Each panel contains: |
| 110 | +- **Left** — fluorescence image crop + cell boundaries + transcript dots for genes listed in `gene_colors` |
| 111 | +- **Right** — top expressed genes (bar chart); genes in `gene_colors` use their assigned colour, others are grey |
72 | 112 |
|
73 | | -## How to construct `adata_gt`? |
| 113 | +### Gene color file |
| 114 | + |
| 115 | +A simple JSON mapping gene names to colours: |
| 116 | + |
| 117 | +```json |
| 118 | +{ |
| 119 | + "CD68": "#e41a1c", |
| 120 | + "MRC1": "#377eb8", |
| 121 | + "C1QA": "#4daf4a", |
| 122 | + "FCGR3A": "#ff7f00" |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +## Step 2 — Manual validation (`scripts/annotate.py`) |
| 129 | + |
| 130 | +For each exported image, an annotator decides whether the cell is **correct** — meaning it is both properly **segmented** and properly **labelled**. A cell should be rejected (marked incorrect) when either check fails: |
| 131 | + |
| 132 | +- **Segmentation** — the cell boundary (left panel) is not consistent with the nuclei / membrane staining, e.g. it merges two cells or clips part of one. |
| 133 | +- **Cell-type label** — the top expressed genes (right panel) include genes unlikely to be expressed by the cell type of interest, suggesting the automated label is wrong. |
| 134 | + |
| 135 | +The result is a boolean column `is_correct` added to `metadata.csv`, which becomes `adata_gt` in Step 3. |
| 136 | + |
| 137 | +```bash |
| 138 | +streamlit run scripts/annotate.py -- --dir /path/to/annotation_dir |
| 139 | +``` |
| 140 | + |
| 141 | +The `--` is required: it tells Streamlit to pass everything after it to the script rather than interpreting it as Streamlit's own options. |
| 142 | + |
| 143 | +VS Code Remote forwards the Streamlit port automatically. Open the URL printed in the terminal, then use: |
| 144 | + |
| 145 | +- **`1`** — label as correct |
| 146 | +- **`2`** — label as incorrect |
| 147 | + |
| 148 | +Progress is saved after every keypress to `annotations.json`. Re-running the command resumes from where you left off. You can also start annotating while `export.py` is still running — the UI picks up newly exported cells automatically. |
| 149 | + |
| 150 | +--- |
| 151 | + |
| 152 | +## Step 3 — Differential expression (`scripts/differential_expression.py`) |
| 153 | + |
| 154 | +```bash |
| 155 | +python scripts/differential_expression.py --dir /path/to/annotation_dir |
| 156 | +``` |
74 | 157 |
|
75 | | -Constructing `adata_gt` requires validating the cell population labels for a small subset of cells (e.g., random sample). This involves: |
76 | | -1. **Sampling**: Select a small random subset of cells from your dataset. |
77 | | -2. **Data Access**: Extract the relevant data for these cells: their gene expression profile, their spatial coordinates, and importantly, a **high-resolution image crop** of the cell (with segmentation boundaries if available) to assess morphology. |
78 | | -3. **Validation**: Visually inspect these data points to determine the true cell identity. |
79 | | -4. **Annotation**: Create the `is_correct` boolean column based on your assessment. |
| 158 | +Reads all export settings from `config.json` and writes gene-level results to `<dir>/results.csv`. |
80 | 159 |
|
81 | | -These steps can be performed manually or using dedicated tools. |
82 | | -Our [experimental repository](https://github.com/YosefLab/csde/blob/main/csde_experiments) |
83 | | -provides an example of how these steps were performed for MERFISH data. |
| 160 | +| option | default | description | |
| 161 | +|---|---|---| |
| 162 | +| `--dir` | *(required)* | annotation directory (output of steps 1 & 2) | |
| 163 | +| `--out` | `<dir>/results.csv` | output CSV path | |
| 164 | +| `--spatial-group-key` | `spatial_group` | obs column encoding the two spatial populations | |
| 165 | +| `--n-cells-expressed-threshold` | `10` | min annotated cells expressing a gene for it to be tested | |
| 166 | +| `--noise-model` | `poisson` | `poisson` or `nb` (negative binomial) | |
84 | 167 |
|
85 | | -To streamline this process, for MERFISH or other spatial transcriptomics data, we recommend using **[SpatialData](https://spatialdata.scverse.org/)** to access the data and perform the manual validation. |
| 168 | +### Output columns |
86 | 169 |
|
| 170 | +| column | description | |
| 171 | +|---|---| |
| 172 | +| `log_fold_change` | estimated LFC (positive = upregulated in target population) | |
| 173 | +| `p_value` | raw two-sided p-value | |
| 174 | +| `p_value_adj` | Benjamini-Hochberg adjusted p-value | |
0 commit comments