Skip to content

Repository files navigation

CSDE: Corrected Spatial Differential Expression

Tests

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. These errors can propagate into downstream analyses of differential expression, leading to false discoveries or missed signals

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.

The current codebase focuses on the comparison of a given cell type across two spatial regions. It allows users to

  1. export per-cell annotation panels for a small subset of cells (e.g. 600)
  2. manually validate the segmentation and type assignment for these cells
  3. run the CSDE model to get corrected DE estimates for all genes

Refer to the preprint for details on the method. Reproducibility code is available here.

Input requirements

The workflow takes a SpatialData zarr as input.

Its "table" AnnData must contain:

  • raw expression counts in .X or a named layer
  • the following obs columns:
obs columncontent
cell_type (configurable)cell-type label for each cell
spatial_group (configurable)spatial region label with two values to compare (e.g. 0/1, or "out_of_tumor"/"in_tumor"). Which value is the target is chosen in Step 3 with --spatial-group-target / --spatial-group-reference, defaulting to 1 / 0
center_x, center_ycell centroid in microns

The zarr must also expose the following SpatialData elements, used to render the per-cell annotation panels (Step 1):

elementrequirement
imagesat least one image with a named fluorescence channel (e.g. "DAPI", "Cellbound2")
shapesat least one element holding the cell-boundary polygons
pointsat least one element holding transcript locations, with a gene column

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.

Installation

pip install csde
pip install "csde[cuda12]"# GPU (CUDA 12)
pip install "csde[annotate]"# annotation UI (Step 2, requires streamlit)
pip install "csde[cuda12,annotate]"# both

Workflow overview

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.

SpatialData zarr
│
▼
1. Export annotation panels ←─ scripts/export.py
(importance-sampled cells,
one image per cell)
│
▼
2. Manual validation ←─ scripts/annotate.py
(annotator marks each cell
as correctly / incorrectly labelled)
│
▼
3. Run CSDE ←─ scripts/differential_expression.py
(corrected DE estimates)

Step 1 — Export annotation panels (scripts/export.py)

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.

python scripts/export.py \
--sdata /path/to/region.zarr \
--out /path/to/annotation_dir \
--cell-type-key cell_type \
--cell-type-of-interest macrophages \
--target-proportion 0.4 \
--gene-colors scripts/gene_colors_file.json \
--image-channel Cellbound2 \
--n-cells 600 \
--layer counts

--annotation-mode selects the actions offered in Step 2, and defaults to accept_correct_reject. Use --annotation-mode accept_reject to drop the relabelling option. The value is saved to config.json; because the cell-type vocabulary is written there too (always, whatever the mode), you can switch modes afterwards by editing config.json, without re-exporting the panels.

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

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

The script writes:

/path/to/annotation_dir/
├── images/
│ ├── cell_<id>.png # one panel per cell
│ └── ...
├── config.json # export arguments + cell_type_vocabulary (read by annotate.py)
├── metadata.csv # cell_id, cell_type, image_path, sampling_weight, center_x, center_y
└── annotations.json # {cell_id: {action, label}} — written by annotate.py

Each panel contains:

  • Left — fluorescence image crop + cell boundaries + transcript dots for genes listed in gene_colors
  • Right — top expressed genes (bar chart); genes in gene_colors use their assigned colour, others are grey

Gene color file

A simple JSON mapping gene names to colours:

{
"CD68": "#e41a1c",
"MRC1": "#377eb8",
"C1QA": "#4daf4a",
"FCGR3A": "#ff7f00"
}

Step 2 — Manual validation (scripts/annotate.py)

For each exported image, the annotator runs two checks in order:

  1. Segmentation — is the cell boundary (left panel) consistent with the nuclei / membrane staining, or does it merge two cells or clip part of one?
  2. Cell-type label — are the top expressed genes (right panel) consistent with the assigned label?

which lead to one of three actions:

actionwheneffect
acceptsegmentation fine, label finethe cell keeps its automated label
correctsegmentation fine, label wrongthe annotator picks the right cell type
rejectsegmentation inadequatethe cell is excluded from both compared groups

Correcting a cell revises only its cell type; its spatial region is treated as reliable and is always taken from the automated pipeline. So correcting a cell into the cell type of interest is what places it in the target or reference group, according to the region it already sits in — this is the case an accept/reject workflow cannot express.

Segmentation is never edited: an accepted or corrected cell keeps the automated expression counts. Rejection therefore doubles as a quality-control filter for cells whose quantification cannot be trusted at all.

streamlit run scripts/annotate.py -- --dir /path/to/annotation_dir

The -- is required: it tells Streamlit to pass everything after it to the script rather than interpreting it as Streamlit's own options.

VS Code Remote forwards the Streamlit port automatically. Open the URL printed in the terminal, then use:

keyaccept_correct_reject (default)accept_reject
1acceptaccept
2correctreject
3reject

Pressing 2 in accept_correct_reject mode opens a cell-type selector below the panel — type a few characters to filter, then pick the label. Nothing is written until you choose one, so pressing 2 by mistake is harmless: hit Cancel and the cell stays unannotated.

Progress is saved after every keypress to annotations.json, as {cell_id: {"action": ..., "label": ...}} (label is set only for corrections). 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.


Step 3 — Differential expression (scripts/differential_expression.py)

python scripts/differential_expression.py --dir /path/to/annotation_dir

Reads all export settings from config.json and writes gene-level results to <dir>/results.csv.

The three-way comparison is built here: cells of interest in spatial group 0 (reference) and group 1 (target) form the two compared populations, and everything else — including rejected cells — is collapsed into a third group. Both the automated labels and the manual ones are built the same way; only the cell type differs between them. The script prints a summary of the annotations first (counts per action, plus how many cells the curation moved into and out of the compared groups), which is the quickest check that the annotations were read as intended.

If your region column is not encoded as 1 / 0, set --spatial-group-target and --spatial-group-reference to the two values you want to compare; the script reports the values it found if they don't match. The target region is the one positive log-fold changes refer to, so swapping the two flips the sign of every result — this is deliberately not inferred for you, even when the column has exactly two values.

optiondefaultdescription
--dir(required)annotation directory (output of steps 1 & 2)
--out<dir>/results.csvoutput CSV path
--spatial-group-keyspatial_groupobs column encoding the two spatial populations
--spatial-group-target1value of that column identifying the target region (group 1)
--spatial-group-reference0value of that column identifying the reference region (group 0)
--n-cells-expressed-threshold10min annotated cells expressing a gene for it to be tested
--noise-modelpoissonpoisson or nb (negative binomial)

Output columns

columndescription
log_fold_changeestimated LFC (positive = upregulated in target population)
p_valueraw two-sided p-value
p_value_adjBenjamini-Hochberg adjusted p-value

About

No description, website, or topics provided.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
GitHub - YosefLab/csde · GitHub
Skip to content

Repository files navigation

CSDE: Corrected Spatial Differential Expression

Tests

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. These errors can propagate into downstream analyses of differential expression, leading to false discoveries or missed signals

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.

The current codebase focuses on the comparison of a given cell type across two spatial regions. It allows users to

  1. export per-cell annotation panels for a small subset of cells (e.g. 600)
  2. manually validate the segmentation and type assignment for these cells
  3. run the CSDE model to get corrected DE estimates for all genes

Refer to the preprint for details on the method. Reproducibility code is available here.

Input requirements

The workflow takes a SpatialData zarr as input.

Its "table" AnnData must contain:

  • raw expression counts in .X or a named layer
  • the following obs columns:
obs columncontent
cell_type (configurable)cell-type label for each cell
spatial_group (configurable)spatial region label with two values to compare (e.g. 0/1, or "out_of_tumor"/"in_tumor"). Which value is the target is chosen in Step 3 with --spatial-group-target / --spatial-group-reference, defaulting to 1 / 0
center_x, center_ycell centroid in microns

The zarr must also expose the following SpatialData elements, used to render the per-cell annotation panels (Step 1):

elementrequirement
imagesat least one image with a named fluorescence channel (e.g. "DAPI", "Cellbound2")
shapesat least one element holding the cell-boundary polygons
pointsat least one element holding transcript locations, with a gene column

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.

Installation

pip install csde
pip install "csde[cuda12]"# GPU (CUDA 12)
pip install "csde[annotate]"# annotation UI (Step 2, requires streamlit)
pip install "csde[cuda12,annotate]"# both

Workflow overview

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.

SpatialData zarr
│
▼
1. Export annotation panels ←─ scripts/export.py
(importance-sampled cells,
one image per cell)
│
▼
2. Manual validation ←─ scripts/annotate.py
(annotator marks each cell
as correctly / incorrectly labelled)
│
▼
3. Run CSDE ←─ scripts/differential_expression.py
(corrected DE estimates)

Step 1 — Export annotation panels (scripts/export.py)

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.

python scripts/export.py \
--sdata /path/to/region.zarr \
--out /path/to/annotation_dir \
--cell-type-key cell_type \
--cell-type-of-interest macrophages \
--target-proportion 0.4 \
--gene-colors scripts/gene_colors_file.json \
--image-channel Cellbound2 \
--n-cells 600 \
--layer counts

--annotation-mode selects the actions offered in Step 2, and defaults to accept_correct_reject. Use --annotation-mode accept_reject to drop the relabelling option. The value is saved to config.json; because the cell-type vocabulary is written there too (always, whatever the mode), you can switch modes afterwards by editing config.json, without re-exporting the panels.

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

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

The script writes:

/path/to/annotation_dir/
├── images/
│ ├── cell_<id>.png # one panel per cell
│ └── ...
├── config.json # export arguments + cell_type_vocabulary (read by annotate.py)
├── metadata.csv # cell_id, cell_type, image_path, sampling_weight, center_x, center_y
└── annotations.json # {cell_id: {action, label}} — written by annotate.py

Each panel contains:

  • Left — fluorescence image crop + cell boundaries + transcript dots for genes listed in gene_colors
  • Right — top expressed genes (bar chart); genes in gene_colors use their assigned colour, others are grey

Gene color file

A simple JSON mapping gene names to colours:

{
"CD68": "#e41a1c",
"MRC1": "#377eb8",
"C1QA": "#4daf4a",
"FCGR3A": "#ff7f00"
}

Step 2 — Manual validation (scripts/annotate.py)

For each exported image, the annotator runs two checks in order:

  1. Segmentation — is the cell boundary (left panel) consistent with the nuclei / membrane staining, or does it merge two cells or clip part of one?
  2. Cell-type label — are the top expressed genes (right panel) consistent with the assigned label?

which lead to one of three actions:

actionwheneffect
acceptsegmentation fine, label finethe cell keeps its automated label
correctsegmentation fine, label wrongthe annotator picks the right cell type
rejectsegmentation inadequatethe cell is excluded from both compared groups

Correcting a cell revises only its cell type; its spatial region is treated as reliable and is always taken from the automated pipeline. So correcting a cell into the cell type of interest is what places it in the target or reference group, according to the region it already sits in — this is the case an accept/reject workflow cannot express.

Segmentation is never edited: an accepted or corrected cell keeps the automated expression counts. Rejection therefore doubles as a quality-control filter for cells whose quantification cannot be trusted at all.

streamlit run scripts/annotate.py -- --dir /path/to/annotation_dir

The -- is required: it tells Streamlit to pass everything after it to the script rather than interpreting it as Streamlit's own options.

VS Code Remote forwards the Streamlit port automatically. Open the URL printed in the terminal, then use:

keyaccept_correct_reject (default)accept_reject
1acceptaccept
2correctreject
3reject

Pressing 2 in accept_correct_reject mode opens a cell-type selector below the panel — type a few characters to filter, then pick the label. Nothing is written until you choose one, so pressing 2 by mistake is harmless: hit Cancel and the cell stays unannotated.

Progress is saved after every keypress to annotations.json, as {cell_id: {"action": ..., "label": ...}} (label is set only for corrections). 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.


Step 3 — Differential expression (scripts/differential_expression.py)

python scripts/differential_expression.py --dir /path/to/annotation_dir

Reads all export settings from config.json and writes gene-level results to <dir>/results.csv.

The three-way comparison is built here: cells of interest in spatial group 0 (reference) and group 1 (target) form the two compared populations, and everything else — including rejected cells — is collapsed into a third group. Both the automated labels and the manual ones are built the same way; only the cell type differs between them. The script prints a summary of the annotations first (counts per action, plus how many cells the curation moved into and out of the compared groups), which is the quickest check that the annotations were read as intended.

If your region column is not encoded as 1 / 0, set --spatial-group-target and --spatial-group-reference to the two values you want to compare; the script reports the values it found if they don't match. The target region is the one positive log-fold changes refer to, so swapping the two flips the sign of every result — this is deliberately not inferred for you, even when the column has exactly two values.

optiondefaultdescription
--dir(required)annotation directory (output of steps 1 & 2)
--out<dir>/results.csvoutput CSV path
--spatial-group-keyspatial_groupobs column encoding the two spatial populations
--spatial-group-target1value of that column identifying the target region (group 1)
--spatial-group-reference0value of that column identifying the reference region (group 0)
--n-cells-expressed-threshold10min annotated cells expressing a gene for it to be tested
--noise-modelpoissonpoisson or nb (negative binomial)

Output columns

columndescription
log_fold_changeestimated LFC (positive = upregulated in target population)
p_valueraw two-sided p-value
p_value_adjBenjamini-Hochberg adjusted p-value

About

No description, website, or topics provided.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - YosefLab/csde · GitHub
Skip to content

Repository files navigation

CSDE: Corrected Spatial Differential Expression

Tests

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. These errors can propagate into downstream analyses of differential expression, leading to false discoveries or missed signals

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.

The current codebase focuses on the comparison of a given cell type across two spatial regions. It allows users to

  1. export per-cell annotation panels for a small subset of cells (e.g. 600)
  2. manually validate the segmentation and type assignment for these cells
  3. run the CSDE model to get corrected DE estimates for all genes

Refer to the preprint for details on the method. Reproducibility code is available here.

Input requirements

The workflow takes a SpatialData zarr as input.

Its "table" AnnData must contain:

  • raw expression counts in .X or a named layer
  • the following obs columns:
obs columncontent
cell_type (configurable)cell-type label for each cell
spatial_group (configurable)spatial region label with two values to compare (e.g. 0/1, or "out_of_tumor"/"in_tumor"). Which value is the target is chosen in Step 3 with --spatial-group-target / --spatial-group-reference, defaulting to 1 / 0
center_x, center_ycell centroid in microns

The zarr must also expose the following SpatialData elements, used to render the per-cell annotation panels (Step 1):

elementrequirement
imagesat least one image with a named fluorescence channel (e.g. "DAPI", "Cellbound2")
shapesat least one element holding the cell-boundary polygons
pointsat least one element holding transcript locations, with a gene column

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.

Installation

pip install csde
pip install "csde[cuda12]"# GPU (CUDA 12)
pip install "csde[annotate]"# annotation UI (Step 2, requires streamlit)
pip install "csde[cuda12,annotate]"# both

Workflow overview

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.

SpatialData zarr
│
▼
1. Export annotation panels ←─ scripts/export.py
(importance-sampled cells,
one image per cell)
│
▼
2. Manual validation ←─ scripts/annotate.py
(annotator marks each cell
as correctly / incorrectly labelled)
│
▼
3. Run CSDE ←─ scripts/differential_expression.py
(corrected DE estimates)

Step 1 — Export annotation panels (scripts/export.py)

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.

python scripts/export.py \
--sdata /path/to/region.zarr \
--out /path/to/annotation_dir \
--cell-type-key cell_type \
--cell-type-of-interest macrophages \
--target-proportion 0.4 \
--gene-colors scripts/gene_colors_file.json \
--image-channel Cellbound2 \
--n-cells 600 \
--layer counts

--annotation-mode selects the actions offered in Step 2, and defaults to accept_correct_reject. Use --annotation-mode accept_reject to drop the relabelling option. The value is saved to config.json; because the cell-type vocabulary is written there too (always, whatever the mode), you can switch modes afterwards by editing config.json, without re-exporting the panels.

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

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

The script writes:

/path/to/annotation_dir/
├── images/
│ ├── cell_<id>.png # one panel per cell
│ └── ...
├── config.json # export arguments + cell_type_vocabulary (read by annotate.py)
├── metadata.csv # cell_id, cell_type, image_path, sampling_weight, center_x, center_y
└── annotations.json # {cell_id: {action, label}} — written by annotate.py

Each panel contains:

  • Left — fluorescence image crop + cell boundaries + transcript dots for genes listed in gene_colors
  • Right — top expressed genes (bar chart); genes in gene_colors use their assigned colour, others are grey

Gene color file

A simple JSON mapping gene names to colours:

{
"CD68": "#e41a1c",
"MRC1": "#377eb8",
"C1QA": "#4daf4a",
"FCGR3A": "#ff7f00"
}

Step 2 — Manual validation (scripts/annotate.py)

For each exported image, the annotator runs two checks in order:

  1. Segmentation — is the cell boundary (left panel) consistent with the nuclei / membrane staining, or does it merge two cells or clip part of one?
  2. Cell-type label — are the top expressed genes (right panel) consistent with the assigned label?

which lead to one of three actions:

actionwheneffect
acceptsegmentation fine, label finethe cell keeps its automated label
correctsegmentation fine, label wrongthe annotator picks the right cell type
rejectsegmentation inadequatethe cell is excluded from both compared groups

Correcting a cell revises only its cell type; its spatial region is treated as reliable and is always taken from the automated pipeline. So correcting a cell into the cell type of interest is what places it in the target or reference group, according to the region it already sits in — this is the case an accept/reject workflow cannot express.

Segmentation is never edited: an accepted or corrected cell keeps the automated expression counts. Rejection therefore doubles as a quality-control filter for cells whose quantification cannot be trusted at all.

streamlit run scripts/annotate.py -- --dir /path/to/annotation_dir

The -- is required: it tells Streamlit to pass everything after it to the script rather than interpreting it as Streamlit's own options.

VS Code Remote forwards the Streamlit port automatically. Open the URL printed in the terminal, then use:

keyaccept_correct_reject (default)accept_reject
1acceptaccept
2correctreject
3reject

Pressing 2 in accept_correct_reject mode opens a cell-type selector below the panel — type a few characters to filter, then pick the label. Nothing is written until you choose one, so pressing 2 by mistake is harmless: hit Cancel and the cell stays unannotated.

Progress is saved after every keypress to annotations.json, as {cell_id: {"action": ..., "label": ...}} (label is set only for corrections). 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.


Step 3 — Differential expression (scripts/differential_expression.py)

python scripts/differential_expression.py --dir /path/to/annotation_dir

Reads all export settings from config.json and writes gene-level results to <dir>/results.csv.

The three-way comparison is built here: cells of interest in spatial group 0 (reference) and group 1 (target) form the two compared populations, and everything else — including rejected cells — is collapsed into a third group. Both the automated labels and the manual ones are built the same way; only the cell type differs between them. The script prints a summary of the annotations first (counts per action, plus how many cells the curation moved into and out of the compared groups), which is the quickest check that the annotations were read as intended.

If your region column is not encoded as 1 / 0, set --spatial-group-target and --spatial-group-reference to the two values you want to compare; the script reports the values it found if they don't match. The target region is the one positive log-fold changes refer to, so swapping the two flips the sign of every result — this is deliberately not inferred for you, even when the column has exactly two values.

optiondefaultdescription
--dir(required)annotation directory (output of steps 1 & 2)
--out<dir>/results.csvoutput CSV path
--spatial-group-keyspatial_groupobs column encoding the two spatial populations
--spatial-group-target1value of that column identifying the target region (group 1)
--spatial-group-reference0value of that column identifying the reference region (group 0)
--n-cells-expressed-threshold10min annotated cells expressing a gene for it to be tested
--noise-modelpoissonpoisson or nb (negative binomial)

Output columns

columndescription
log_fold_changeestimated LFC (positive = upregulated in target population)
p_valueraw two-sided p-value
p_value_adjBenjamini-Hochberg adjusted p-value

About

No description, website, or topics provided.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - YosefLab/csde · GitHub
Skip to content

Repository files navigation

CSDE: Corrected Spatial Differential Expression

Tests

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. These errors can propagate into downstream analyses of differential expression, leading to false discoveries or missed signals

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.

The current codebase focuses on the comparison of a given cell type across two spatial regions. It allows users to

  1. export per-cell annotation panels for a small subset of cells (e.g. 600)
  2. manually validate the segmentation and type assignment for these cells
  3. run the CSDE model to get corrected DE estimates for all genes

Refer to the preprint for details on the method. Reproducibility code is available here.

Input requirements

The workflow takes a SpatialData zarr as input.

Its "table" AnnData must contain:

  • raw expression counts in .X or a named layer
  • the following obs columns:
obs columncontent
cell_type (configurable)cell-type label for each cell
spatial_group (configurable)spatial region label with two values to compare (e.g. 0/1, or "out_of_tumor"/"in_tumor"). Which value is the target is chosen in Step 3 with --spatial-group-target / --spatial-group-reference, defaulting to 1 / 0
center_x, center_ycell centroid in microns

The zarr must also expose the following SpatialData elements, used to render the per-cell annotation panels (Step 1):

elementrequirement
imagesat least one image with a named fluorescence channel (e.g. "DAPI", "Cellbound2")
shapesat least one element holding the cell-boundary polygons
pointsat least one element holding transcript locations, with a gene column

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.

Installation

pip install csde
pip install "csde[cuda12]"# GPU (CUDA 12)
pip install "csde[annotate]"# annotation UI (Step 2, requires streamlit)
pip install "csde[cuda12,annotate]"# both

Workflow overview

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.

SpatialData zarr
│
▼
1. Export annotation panels ←─ scripts/export.py
(importance-sampled cells,
one image per cell)
│
▼
2. Manual validation ←─ scripts/annotate.py
(annotator marks each cell
as correctly / incorrectly labelled)
│
▼
3. Run CSDE ←─ scripts/differential_expression.py
(corrected DE estimates)

Step 1 — Export annotation panels (scripts/export.py)

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.

python scripts/export.py \
--sdata /path/to/region.zarr \
--out /path/to/annotation_dir \
--cell-type-key cell_type \
--cell-type-of-interest macrophages \
--target-proportion 0.4 \
--gene-colors scripts/gene_colors_file.json \
--image-channel Cellbound2 \
--n-cells 600 \
--layer counts

--annotation-mode selects the actions offered in Step 2, and defaults to accept_correct_reject. Use --annotation-mode accept_reject to drop the relabelling option. The value is saved to config.json; because the cell-type vocabulary is written there too (always, whatever the mode), you can switch modes afterwards by editing config.json, without re-exporting the panels.

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

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

The script writes:

/path/to/annotation_dir/
├── images/
│ ├── cell_<id>.png # one panel per cell
│ └── ...
├── config.json # export arguments + cell_type_vocabulary (read by annotate.py)
├── metadata.csv # cell_id, cell_type, image_path, sampling_weight, center_x, center_y
└── annotations.json # {cell_id: {action, label}} — written by annotate.py

Each panel contains:

  • Left — fluorescence image crop + cell boundaries + transcript dots for genes listed in gene_colors
  • Right — top expressed genes (bar chart); genes in gene_colors use their assigned colour, others are grey

Gene color file

A simple JSON mapping gene names to colours:

{
"CD68": "#e41a1c",
"MRC1": "#377eb8",
"C1QA": "#4daf4a",
"FCGR3A": "#ff7f00"
}

Step 2 — Manual validation (scripts/annotate.py)

For each exported image, the annotator runs two checks in order:

  1. Segmentation — is the cell boundary (left panel) consistent with the nuclei / membrane staining, or does it merge two cells or clip part of one?
  2. Cell-type label — are the top expressed genes (right panel) consistent with the assigned label?

which lead to one of three actions:

actionwheneffect
acceptsegmentation fine, label finethe cell keeps its automated label
correctsegmentation fine, label wrongthe annotator picks the right cell type
rejectsegmentation inadequatethe cell is excluded from both compared groups

Correcting a cell revises only its cell type; its spatial region is treated as reliable and is always taken from the automated pipeline. So correcting a cell into the cell type of interest is what places it in the target or reference group, according to the region it already sits in — this is the case an accept/reject workflow cannot express.

Segmentation is never edited: an accepted or corrected cell keeps the automated expression counts. Rejection therefore doubles as a quality-control filter for cells whose quantification cannot be trusted at all.

streamlit run scripts/annotate.py -- --dir /path/to/annotation_dir

The -- is required: it tells Streamlit to pass everything after it to the script rather than interpreting it as Streamlit's own options.

VS Code Remote forwards the Streamlit port automatically. Open the URL printed in the terminal, then use:

keyaccept_correct_reject (default)accept_reject
1acceptaccept
2correctreject
3reject

Pressing 2 in accept_correct_reject mode opens a cell-type selector below the panel — type a few characters to filter, then pick the label. Nothing is written until you choose one, so pressing 2 by mistake is harmless: hit Cancel and the cell stays unannotated.

Progress is saved after every keypress to annotations.json, as {cell_id: {"action": ..., "label": ...}} (label is set only for corrections). 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.


Step 3 — Differential expression (scripts/differential_expression.py)

python scripts/differential_expression.py --dir /path/to/annotation_dir

Reads all export settings from config.json and writes gene-level results to <dir>/results.csv.

The three-way comparison is built here: cells of interest in spatial group 0 (reference) and group 1 (target) form the two compared populations, and everything else — including rejected cells — is collapsed into a third group. Both the automated labels and the manual ones are built the same way; only the cell type differs between them. The script prints a summary of the annotations first (counts per action, plus how many cells the curation moved into and out of the compared groups), which is the quickest check that the annotations were read as intended.

If your region column is not encoded as 1 / 0, set --spatial-group-target and --spatial-group-reference to the two values you want to compare; the script reports the values it found if they don't match. The target region is the one positive log-fold changes refer to, so swapping the two flips the sign of every result — this is deliberately not inferred for you, even when the column has exactly two values.

optiondefaultdescription
--dir(required)annotation directory (output of steps 1 & 2)
--out<dir>/results.csvoutput CSV path
--spatial-group-keyspatial_groupobs column encoding the two spatial populations
--spatial-group-target1value of that column identifying the target region (group 1)
--spatial-group-reference0value of that column identifying the reference region (group 0)
--n-cells-expressed-threshold10min annotated cells expressing a gene for it to be tested
--noise-modelpoissonpoisson or nb (negative binomial)

Output columns

columndescription
log_fold_changeestimated LFC (positive = upregulated in target population)
p_valueraw two-sided p-value
p_value_adjBenjamini-Hochberg adjusted p-value

About

No description, website, or topics provided.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' GitHub - YosefLab/csde · GitHub
Skip to content

Repository files navigation

CSDE: Corrected Spatial Differential Expression

Tests

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. These errors can propagate into downstream analyses of differential expression, leading to false discoveries or missed signals

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.

The current codebase focuses on the comparison of a given cell type across two spatial regions. It allows users to

  1. export per-cell annotation panels for a small subset of cells (e.g. 600)
  2. manually validate the segmentation and type assignment for these cells
  3. run the CSDE model to get corrected DE estimates for all genes

Refer to the preprint for details on the method. Reproducibility code is available here.

Input requirements

The workflow takes a SpatialData zarr as input.

Its "table" AnnData must contain:

  • raw expression counts in .X or a named layer
  • the following obs columns:
obs columncontent
cell_type (configurable)cell-type label for each cell
spatial_group (configurable)spatial region label with two values to compare (e.g. 0/1, or "out_of_tumor"/"in_tumor"). Which value is the target is chosen in Step 3 with --spatial-group-target / --spatial-group-reference, defaulting to 1 / 0
center_x, center_ycell centroid in microns

The zarr must also expose the following SpatialData elements, used to render the per-cell annotation panels (Step 1):

elementrequirement
imagesat least one image with a named fluorescence channel (e.g. "DAPI", "Cellbound2")
shapesat least one element holding the cell-boundary polygons
pointsat least one element holding transcript locations, with a gene column

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.

Installation

pip install csde
pip install "csde[cuda12]"# GPU (CUDA 12)
pip install "csde[annotate]"# annotation UI (Step 2, requires streamlit)
pip install "csde[cuda12,annotate]"# both

Workflow overview

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.

SpatialData zarr
│
▼
1. Export annotation panels ←─ scripts/export.py
(importance-sampled cells,
one image per cell)
│
▼
2. Manual validation ←─ scripts/annotate.py
(annotator marks each cell
as correctly / incorrectly labelled)
│
▼
3. Run CSDE ←─ scripts/differential_expression.py
(corrected DE estimates)

Step 1 — Export annotation panels (scripts/export.py)

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.

python scripts/export.py \
--sdata /path/to/region.zarr \
--out /path/to/annotation_dir \
--cell-type-key cell_type \
--cell-type-of-interest macrophages \
--target-proportion 0.4 \
--gene-colors scripts/gene_colors_file.json \
--image-channel Cellbound2 \
--n-cells 600 \
--layer counts

--annotation-mode selects the actions offered in Step 2, and defaults to accept_correct_reject. Use --annotation-mode accept_reject to drop the relabelling option. The value is saved to config.json; because the cell-type vocabulary is written there too (always, whatever the mode), you can switch modes afterwards by editing config.json, without re-exporting the panels.

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

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

The script writes:

/path/to/annotation_dir/
├── images/
│ ├── cell_<id>.png # one panel per cell
│ └── ...
├── config.json # export arguments + cell_type_vocabulary (read by annotate.py)
├── metadata.csv # cell_id, cell_type, image_path, sampling_weight, center_x, center_y
└── annotations.json # {cell_id: {action, label}} — written by annotate.py

Each panel contains:

  • Left — fluorescence image crop + cell boundaries + transcript dots for genes listed in gene_colors
  • Right — top expressed genes (bar chart); genes in gene_colors use their assigned colour, others are grey

Gene color file

A simple JSON mapping gene names to colours:

{
"CD68": "#e41a1c",
"MRC1": "#377eb8",
"C1QA": "#4daf4a",
"FCGR3A": "#ff7f00"
}

Step 2 — Manual validation (scripts/annotate.py)

For each exported image, the annotator runs two checks in order:

  1. Segmentation — is the cell boundary (left panel) consistent with the nuclei / membrane staining, or does it merge two cells or clip part of one?
  2. Cell-type label — are the top expressed genes (right panel) consistent with the assigned label?

which lead to one of three actions:

actionwheneffect
acceptsegmentation fine, label finethe cell keeps its automated label
correctsegmentation fine, label wrongthe annotator picks the right cell type
rejectsegmentation inadequatethe cell is excluded from both compared groups

Correcting a cell revises only its cell type; its spatial region is treated as reliable and is always taken from the automated pipeline. So correcting a cell into the cell type of interest is what places it in the target or reference group, according to the region it already sits in — this is the case an accept/reject workflow cannot express.

Segmentation is never edited: an accepted or corrected cell keeps the automated expression counts. Rejection therefore doubles as a quality-control filter for cells whose quantification cannot be trusted at all.

streamlit run scripts/annotate.py -- --dir /path/to/annotation_dir

The -- is required: it tells Streamlit to pass everything after it to the script rather than interpreting it as Streamlit's own options.

VS Code Remote forwards the Streamlit port automatically. Open the URL printed in the terminal, then use:

keyaccept_correct_reject (default)accept_reject
1acceptaccept
2correctreject
3reject

Pressing 2 in accept_correct_reject mode opens a cell-type selector below the panel — type a few characters to filter, then pick the label. Nothing is written until you choose one, so pressing 2 by mistake is harmless: hit Cancel and the cell stays unannotated.

Progress is saved after every keypress to annotations.json, as {cell_id: {"action": ..., "label": ...}} (label is set only for corrections). 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.


Step 3 — Differential expression (scripts/differential_expression.py)

python scripts/differential_expression.py --dir /path/to/annotation_dir

Reads all export settings from config.json and writes gene-level results to <dir>/results.csv.

The three-way comparison is built here: cells of interest in spatial group 0 (reference) and group 1 (target) form the two compared populations, and everything else — including rejected cells — is collapsed into a third group. Both the automated labels and the manual ones are built the same way; only the cell type differs between them. The script prints a summary of the annotations first (counts per action, plus how many cells the curation moved into and out of the compared groups), which is the quickest check that the annotations were read as intended.

If your region column is not encoded as 1 / 0, set --spatial-group-target and --spatial-group-reference to the two values you want to compare; the script reports the values it found if they don't match. The target region is the one positive log-fold changes refer to, so swapping the two flips the sign of every result — this is deliberately not inferred for you, even when the column has exactly two values.

optiondefaultdescription
--dir(required)annotation directory (output of steps 1 & 2)
--out<dir>/results.csvoutput CSV path
--spatial-group-keyspatial_groupobs column encoding the two spatial populations
--spatial-group-target1value of that column identifying the target region (group 1)
--spatial-group-reference0value of that column identifying the reference region (group 0)
--n-cells-expressed-threshold10min annotated cells expressing a gene for it to be tested
--noise-modelpoissonpoisson or nb (negative binomial)

Output columns

columndescription
log_fold_changeestimated LFC (positive = upregulated in target population)
p_valueraw two-sided p-value
p_value_adjBenjamini-Hochberg adjusted p-value

About

No description, website, or topics provided.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - YosefLab/csde · GitHub
Skip to content

Repository files navigation

CSDE: Corrected Spatial Differential Expression

Tests

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. These errors can propagate into downstream analyses of differential expression, leading to false discoveries or missed signals

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.

The current codebase focuses on the comparison of a given cell type across two spatial regions. It allows users to

  1. export per-cell annotation panels for a small subset of cells (e.g. 600)
  2. manually validate the segmentation and type assignment for these cells
  3. run the CSDE model to get corrected DE estimates for all genes

Refer to the preprint for details on the method. Reproducibility code is available here.

Input requirements

The workflow takes a SpatialData zarr as input.

Its "table" AnnData must contain:

  • raw expression counts in .X or a named layer
  • the following obs columns:
obs columncontent
cell_type (configurable)cell-type label for each cell
spatial_group (configurable)spatial region label with two values to compare (e.g. 0/1, or "out_of_tumor"/"in_tumor"). Which value is the target is chosen in Step 3 with --spatial-group-target / --spatial-group-reference, defaulting to 1 / 0
center_x, center_ycell centroid in microns

The zarr must also expose the following SpatialData elements, used to render the per-cell annotation panels (Step 1):

elementrequirement
imagesat least one image with a named fluorescence channel (e.g. "DAPI", "Cellbound2")
shapesat least one element holding the cell-boundary polygons
pointsat least one element holding transcript locations, with a gene column

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.

Installation

pip install csde
pip install "csde[cuda12]"# GPU (CUDA 12)
pip install "csde[annotate]"# annotation UI (Step 2, requires streamlit)
pip install "csde[cuda12,annotate]"# both

Workflow overview

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.

SpatialData zarr
│
▼
1. Export annotation panels ←─ scripts/export.py
(importance-sampled cells,
one image per cell)
│
▼
2. Manual validation ←─ scripts/annotate.py
(annotator marks each cell
as correctly / incorrectly labelled)
│
▼
3. Run CSDE ←─ scripts/differential_expression.py
(corrected DE estimates)

Step 1 — Export annotation panels (scripts/export.py)

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.

python scripts/export.py \
--sdata /path/to/region.zarr \
--out /path/to/annotation_dir \
--cell-type-key cell_type \
--cell-type-of-interest macrophages \
--target-proportion 0.4 \
--gene-colors scripts/gene_colors_file.json \
--image-channel Cellbound2 \
--n-cells 600 \
--layer counts

--annotation-mode selects the actions offered in Step 2, and defaults to accept_correct_reject. Use --annotation-mode accept_reject to drop the relabelling option. The value is saved to config.json; because the cell-type vocabulary is written there too (always, whatever the mode), you can switch modes afterwards by editing config.json, without re-exporting the panels.

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

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

The script writes:

/path/to/annotation_dir/
├── images/
│ ├── cell_<id>.png # one panel per cell
│ └── ...
├── config.json # export arguments + cell_type_vocabulary (read by annotate.py)
├── metadata.csv # cell_id, cell_type, image_path, sampling_weight, center_x, center_y
└── annotations.json # {cell_id: {action, label}} — written by annotate.py

Each panel contains:

  • Left — fluorescence image crop + cell boundaries + transcript dots for genes listed in gene_colors
  • Right — top expressed genes (bar chart); genes in gene_colors use their assigned colour, others are grey

Gene color file

A simple JSON mapping gene names to colours:

{
"CD68": "#e41a1c",
"MRC1": "#377eb8",
"C1QA": "#4daf4a",
"FCGR3A": "#ff7f00"
}

Step 2 — Manual validation (scripts/annotate.py)

For each exported image, the annotator runs two checks in order:

  1. Segmentation — is the cell boundary (left panel) consistent with the nuclei / membrane staining, or does it merge two cells or clip part of one?
  2. Cell-type label — are the top expressed genes (right panel) consistent with the assigned label?

which lead to one of three actions:

actionwheneffect
acceptsegmentation fine, label finethe cell keeps its automated label
correctsegmentation fine, label wrongthe annotator picks the right cell type
rejectsegmentation inadequatethe cell is excluded from both compared groups

Correcting a cell revises only its cell type; its spatial region is treated as reliable and is always taken from the automated pipeline. So correcting a cell into the cell type of interest is what places it in the target or reference group, according to the region it already sits in — this is the case an accept/reject workflow cannot express.

Segmentation is never edited: an accepted or corrected cell keeps the automated expression counts. Rejection therefore doubles as a quality-control filter for cells whose quantification cannot be trusted at all.

streamlit run scripts/annotate.py -- --dir /path/to/annotation_dir

The -- is required: it tells Streamlit to pass everything after it to the script rather than interpreting it as Streamlit's own options.

VS Code Remote forwards the Streamlit port automatically. Open the URL printed in the terminal, then use:

keyaccept_correct_reject (default)accept_reject
1acceptaccept
2correctreject
3reject

Pressing 2 in accept_correct_reject mode opens a cell-type selector below the panel — type a few characters to filter, then pick the label. Nothing is written until you choose one, so pressing 2 by mistake is harmless: hit Cancel and the cell stays unannotated.

Progress is saved after every keypress to annotations.json, as {cell_id: {"action": ..., "label": ...}} (label is set only for corrections). 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.


Step 3 — Differential expression (scripts/differential_expression.py)

python scripts/differential_expression.py --dir /path/to/annotation_dir

Reads all export settings from config.json and writes gene-level results to <dir>/results.csv.

The three-way comparison is built here: cells of interest in spatial group 0 (reference) and group 1 (target) form the two compared populations, and everything else — including rejected cells — is collapsed into a third group. Both the automated labels and the manual ones are built the same way; only the cell type differs between them. The script prints a summary of the annotations first (counts per action, plus how many cells the curation moved into and out of the compared groups), which is the quickest check that the annotations were read as intended.

If your region column is not encoded as 1 / 0, set --spatial-group-target and --spatial-group-reference to the two values you want to compare; the script reports the values it found if they don't match. The target region is the one positive log-fold changes refer to, so swapping the two flips the sign of every result — this is deliberately not inferred for you, even when the column has exactly two values.

optiondefaultdescription
--dir(required)annotation directory (output of steps 1 & 2)
--out<dir>/results.csvoutput CSV path
--spatial-group-keyspatial_groupobs column encoding the two spatial populations
--spatial-group-target1value of that column identifying the target region (group 1)
--spatial-group-reference0value of that column identifying the reference region (group 0)
--n-cells-expressed-threshold10min annotated cells expressing a gene for it to be tested
--noise-modelpoissonpoisson or nb (negative binomial)

Output columns

columndescription
log_fold_changeestimated LFC (positive = upregulated in target population)
p_valueraw two-sided p-value
p_value_adjBenjamini-Hochberg adjusted p-value

About

No description, website, or topics provided.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); GitHub - YosefLab/csde · GitHub
Skip to content

Repository files navigation

CSDE: Corrected Spatial Differential Expression

Tests

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. These errors can propagate into downstream analyses of differential expression, leading to false discoveries or missed signals

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.

The current codebase focuses on the comparison of a given cell type across two spatial regions. It allows users to

  1. export per-cell annotation panels for a small subset of cells (e.g. 600)
  2. manually validate the segmentation and type assignment for these cells
  3. run the CSDE model to get corrected DE estimates for all genes

Refer to the preprint for details on the method. Reproducibility code is available here.

Input requirements

The workflow takes a SpatialData zarr as input.

Its "table" AnnData must contain:

  • raw expression counts in .X or a named layer
  • the following obs columns:
obs columncontent
cell_type (configurable)cell-type label for each cell
spatial_group (configurable)spatial region label with two values to compare (e.g. 0/1, or "out_of_tumor"/"in_tumor"). Which value is the target is chosen in Step 3 with --spatial-group-target / --spatial-group-reference, defaulting to 1 / 0
center_x, center_ycell centroid in microns

The zarr must also expose the following SpatialData elements, used to render the per-cell annotation panels (Step 1):

elementrequirement
imagesat least one image with a named fluorescence channel (e.g. "DAPI", "Cellbound2")
shapesat least one element holding the cell-boundary polygons
pointsat least one element holding transcript locations, with a gene column

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.

Installation

pip install csde
pip install "csde[cuda12]"# GPU (CUDA 12)
pip install "csde[annotate]"# annotation UI (Step 2, requires streamlit)
pip install "csde[cuda12,annotate]"# both

Workflow overview

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.

SpatialData zarr
│
▼
1. Export annotation panels ←─ scripts/export.py
(importance-sampled cells,
one image per cell)
│
▼
2. Manual validation ←─ scripts/annotate.py
(annotator marks each cell
as correctly / incorrectly labelled)
│
▼
3. Run CSDE ←─ scripts/differential_expression.py
(corrected DE estimates)

Step 1 — Export annotation panels (scripts/export.py)

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.

python scripts/export.py \
--sdata /path/to/region.zarr \
--out /path/to/annotation_dir \
--cell-type-key cell_type \
--cell-type-of-interest macrophages \
--target-proportion 0.4 \
--gene-colors scripts/gene_colors_file.json \
--image-channel Cellbound2 \
--n-cells 600 \
--layer counts

--annotation-mode selects the actions offered in Step 2, and defaults to accept_correct_reject. Use --annotation-mode accept_reject to drop the relabelling option. The value is saved to config.json; because the cell-type vocabulary is written there too (always, whatever the mode), you can switch modes afterwards by editing config.json, without re-exporting the panels.

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

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

The script writes:

/path/to/annotation_dir/
├── images/
│ ├── cell_<id>.png # one panel per cell
│ └── ...
├── config.json # export arguments + cell_type_vocabulary (read by annotate.py)
├── metadata.csv # cell_id, cell_type, image_path, sampling_weight, center_x, center_y
└── annotations.json # {cell_id: {action, label}} — written by annotate.py

Each panel contains:

  • Left — fluorescence image crop + cell boundaries + transcript dots for genes listed in gene_colors
  • Right — top expressed genes (bar chart); genes in gene_colors use their assigned colour, others are grey

Gene color file

A simple JSON mapping gene names to colours:

{
"CD68": "#e41a1c",
"MRC1": "#377eb8",
"C1QA": "#4daf4a",
"FCGR3A": "#ff7f00"
}

Step 2 — Manual validation (scripts/annotate.py)

For each exported image, the annotator runs two checks in order:

  1. Segmentation — is the cell boundary (left panel) consistent with the nuclei / membrane staining, or does it merge two cells or clip part of one?
  2. Cell-type label — are the top expressed genes (right panel) consistent with the assigned label?

which lead to one of three actions:

actionwheneffect
acceptsegmentation fine, label finethe cell keeps its automated label
correctsegmentation fine, label wrongthe annotator picks the right cell type
rejectsegmentation inadequatethe cell is excluded from both compared groups

Correcting a cell revises only its cell type; its spatial region is treated as reliable and is always taken from the automated pipeline. So correcting a cell into the cell type of interest is what places it in the target or reference group, according to the region it already sits in — this is the case an accept/reject workflow cannot express.

Segmentation is never edited: an accepted or corrected cell keeps the automated expression counts. Rejection therefore doubles as a quality-control filter for cells whose quantification cannot be trusted at all.

streamlit run scripts/annotate.py -- --dir /path/to/annotation_dir

The -- is required: it tells Streamlit to pass everything after it to the script rather than interpreting it as Streamlit's own options.

VS Code Remote forwards the Streamlit port automatically. Open the URL printed in the terminal, then use:

keyaccept_correct_reject (default)accept_reject
1acceptaccept
2correctreject
3reject

Pressing 2 in accept_correct_reject mode opens a cell-type selector below the panel — type a few characters to filter, then pick the label. Nothing is written until you choose one, so pressing 2 by mistake is harmless: hit Cancel and the cell stays unannotated.

Progress is saved after every keypress to annotations.json, as {cell_id: {"action": ..., "label": ...}} (label is set only for corrections). 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.


Step 3 — Differential expression (scripts/differential_expression.py)

python scripts/differential_expression.py --dir /path/to/annotation_dir

Reads all export settings from config.json and writes gene-level results to <dir>/results.csv.

The three-way comparison is built here: cells of interest in spatial group 0 (reference) and group 1 (target) form the two compared populations, and everything else — including rejected cells — is collapsed into a third group. Both the automated labels and the manual ones are built the same way; only the cell type differs between them. The script prints a summary of the annotations first (counts per action, plus how many cells the curation moved into and out of the compared groups), which is the quickest check that the annotations were read as intended.

If your region column is not encoded as 1 / 0, set --spatial-group-target and --spatial-group-reference to the two values you want to compare; the script reports the values it found if they don't match. The target region is the one positive log-fold changes refer to, so swapping the two flips the sign of every result — this is deliberately not inferred for you, even when the column has exactly two values.

optiondefaultdescription
--dir(required)annotation directory (output of steps 1 & 2)
--out<dir>/results.csvoutput CSV path
--spatial-group-keyspatial_groupobs column encoding the two spatial populations
--spatial-group-target1value of that column identifying the target region (group 1)
--spatial-group-reference0value of that column identifying the reference region (group 0)
--n-cells-expressed-threshold10min annotated cells expressing a gene for it to be tested
--noise-modelpoissonpoisson or nb (negative binomial)

Output columns

columndescription
log_fold_changeestimated LFC (positive = upregulated in target population)
p_valueraw two-sided p-value
p_value_adjBenjamini-Hochberg adjusted p-value

About

No description, website, or topics provided.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages