Skip to content

Commit 31e1c09

Browse files
authored
Merge pull request #2 from YosefLab/importance_weights
Add support for importance weights in run_csde and InterceptRegression.
2 parents b12fa24 + a001a92 commit 31e1c09

13 files changed

Lines changed: 1695 additions & 326 deletions

README.md

Lines changed: 151 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,85 +2,173 @@
22

33
[![Tests](https://github.com/YosefLab/csde/actions/workflows/test.yml/badge.svg)](https://github.com/YosefLab/csde/actions/workflows/test.yml)
44

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
67

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.
89

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.
1042

1143
## Installation
1244

1345
```bash
1446
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
1550
```
1651

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+
1880
```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
2091
```
2192

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
65107
```
66108

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
72112

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+
```
74157

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`.
80159

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) |
84167

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
86169

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 |

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dev = [
3333
"isort",
3434
"flake8",
3535
]
36+
annotate = ["streamlit"]
3637
cuda12 = ["jax[cuda12]"]
3738
cuda13 = ["jax[cuda13]"]
3839

scripts/annotate.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""
2+
Streamlit annotation UI for per-cell panels.
3+
4+
Usage
5+
-----
6+
streamlit run scripts/annotate.py -- --dir /path/to/annotations/R2_macrophages
7+
"""
8+
9+
import argparse
10+
import json
11+
from pathlib import Path
12+
13+
import pandas as pd
14+
import streamlit as st
15+
import streamlit.components.v1 as components
16+
17+
18+
def parse_args():
19+
p = argparse.ArgumentParser()
20+
p.add_argument("--dir", required=True, help="Annotation directory (output of export.py).")
21+
return p.parse_args()
22+
23+
24+
def load_annotations(annotation_dir: Path) -> dict:
25+
ann_path = annotation_dir / "annotations.json"
26+
if ann_path.exists():
27+
with open(ann_path) as f:
28+
return json.load(f)
29+
return {}
30+
31+
32+
def save_annotations(annotations: dict, annotation_dir: Path) -> None:
33+
with open(annotation_dir / "annotations.json", "w") as f:
34+
json.dump(annotations, f, indent=2)
35+
36+
37+
def main():
38+
args = parse_args()
39+
annotation_dir = Path(args.dir)
40+
41+
cell_type_of_interest = "cell of interest"
42+
config_path = annotation_dir / "config.json"
43+
if config_path.exists():
44+
with open(config_path) as f:
45+
config = json.load(f)
46+
cell_type_of_interest = config.get("cell_type_of_interest", cell_type_of_interest)
47+
48+
st.set_page_config(layout="wide", page_title="Cell Annotator")
49+
st.title(f"Cell Annotation — {cell_type_of_interest}")
50+
51+
metadata_path = annotation_dir / "metadata.csv"
52+
if not metadata_path.exists():
53+
st.warning("metadata.csv not found — waiting for export.py to write the first cell.")
54+
st.stop()
55+
56+
metadata = pd.read_csv(metadata_path)
57+
metadata["cell_id"] = metadata["cell_id"].astype(str)
58+
n_total = len(metadata)
59+
60+
annotations = load_annotations(annotation_dir)
61+
n_done = len(annotations)
62+
63+
st.progress(n_done / n_total, text=f"{n_done} / {n_total} annotated")
64+
65+
# Initialize navigation index to first unannotated cell
66+
if "current_idx" not in st.session_state:
67+
unannotated_mask = ~metadata["cell_id"].isin(annotations)
68+
first_unannotated = unannotated_mask.idxmax() if unannotated_mask.any() else 0
69+
st.session_state.current_idx = int(first_unannotated)
70+
71+
idx = st.session_state.current_idx
72+
73+
# Jump-to input (form so Enter submits without looping)
74+
jump_col, nav_col = st.columns([2, 3])
75+
with jump_col:
76+
with st.form("jump_form", clear_on_submit=True):
77+
fc1, fc2 = st.columns([4, 1])
78+
with fc1:
79+
jump_id = st.text_input("Jump to cell ID", placeholder="paste cell_id here", label_visibility="collapsed")
80+
with fc2:
81+
submitted = st.form_submit_button("Go")
82+
if submitted and jump_id.strip():
83+
matches = metadata.index[metadata["cell_id"] == jump_id.strip()].tolist()
84+
if matches:
85+
st.session_state.current_idx = matches[0]
86+
idx = matches[0]
87+
else:
88+
st.warning(f"Cell ID `{jump_id.strip()}` not found.")
89+
90+
with nav_col:
91+
nav1, nav2, nav3 = st.columns([1, 3, 1])
92+
with nav1:
93+
if st.button("← Prev", use_container_width=True, disabled=(idx == 0)):
94+
st.session_state.current_idx = idx - 1
95+
st.rerun()
96+
with nav2:
97+
st.markdown(f"<div style='text-align:center; padding-top:6px'>{idx + 1} / {n_total}</div>", unsafe_allow_html=True)
98+
with nav3:
99+
if st.button("Next →", use_container_width=True, disabled=(idx == n_total - 1)):
100+
st.session_state.current_idx = idx + 1
101+
st.rerun()
102+
103+
row = metadata.iloc[idx]
104+
cell_id = row["cell_id"]
105+
existing = annotations.get(cell_id)
106+
status = "✓ correct" if existing is True else ("✗ incorrect" if existing is False else "not annotated")
107+
108+
st.subheader(f"Cell `{cell_id}` — predicted: **{row['cell_type']}** — {status}")
109+
st.image(str(row["image_path"]), use_container_width=True)
110+
111+
def annotate(is_correct: bool) -> None:
112+
annotations[cell_id] = is_correct
113+
save_annotations(annotations, annotation_dir)
114+
# Advance to next unannotated after annotating
115+
remaining = metadata.index[~metadata["cell_id"].isin(annotations)]
116+
next_idx = next((i for i in remaining if i > idx), None)
117+
if next_idx is not None:
118+
st.session_state.current_idx = int(next_idx)
119+
elif remaining.any():
120+
st.session_state.current_idx = int(remaining[0])
121+
122+
col1, col2, _ = st.columns([1, 1, 4])
123+
with col1:
124+
if st.button("✓ Correct [1]", type="primary", use_container_width=True):
125+
annotate(True)
126+
st.rerun()
127+
with col2:
128+
if st.button("✗ Incorrect [2]", use_container_width=True):
129+
annotate(False)
130+
st.rerun()
131+
132+
# Keyboard shortcuts: 1/2 annotate, ←/→ navigate
133+
components.html("""
134+
<script>
135+
(function() {
136+
const doc = window.parent.document;
137+
doc.addEventListener('keydown', function(e) {
138+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
139+
doc.querySelectorAll('button').forEach(function(btn) {
140+
const t = btn.textContent.trim();
141+
if (e.key === '1' && t.startsWith('✓')) btn.click();
142+
if (e.key === '2' && t.startsWith('✗')) btn.click();
143+
if (e.key === 'ArrowLeft' && t.startsWith('←')) btn.click();
144+
if (e.key === 'ArrowRight' && t.startsWith('Next')) btn.click();
145+
});
146+
}, true);
147+
})();
148+
</script>
149+
""", height=0)
150+
151+
152+
if __name__ == "__main__":
153+
main()

0 commit comments

Comments
 (0)