Skip to content

Commit e5751f0

Browse files
authored
from_cellranger_multi_to_h5mu: support converting cellranger filtered h5 to h5mu (#1170)
1 parent 190fd5b commit e5751f0

9 files changed

Lines changed: 234 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
* `workflows/rna/rna_multisample`, `workflows/multiomics/process_samples`, `workflows/multiomics/process_batches`: the RNA scaling zero-center argument is now a regular `boolean` (default behaviour remains unaltered and is set explicitly to `true`) instead of `boolean_false` (PR #1216).
88

9+
## NEW FEATURES
10+
11+
* `convert/from_cellranger_multi_to_h5mu`: add `--output_filtered_data` flag to convert the per-sample filtered count matrices instead of the aggregated raw count matrix (PR #1170).
12+
13+
* `workflows/ingestion/cellranger_multi`: surface the `--output_filtered_data` flag to convert the per-sample filtered count matrices instead of the aggregated raw count matrix (PR #1170).
14+
915
# openpipelines 4.2.0
1016

1117
## NEW FEATURES

src/convert/from_cellranger_multi_to_h5mu/config.vsh.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ arguments:
4646
type: string
4747
description: Name of the .uns slot under which to QC metrics (if any).
4848
default: "metrics_cellranger"
49+
- name: "--output_filtered_data"
50+
type: boolean_true
51+
description: |
52+
If enabled, read the per-sample filtered count matrices
53+
(per_sample_outs/{sample}/count/sample_filtered_feature_bc_matrix.h5)
54+
instead of the aggregated raw count matrix
55+
(multi/count/raw_feature_bc_matrix.h5).
4956
__merge__: [., /src/base/h5_compression_argument.yaml]
5057

5158
resources:

src/convert/from_cellranger_multi_to_h5mu/script.py

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"uns_metrics": "metrics_cellranger",
2121
"output_compression": "gzip",
2222
"sample_csv": "samples.csv",
23+
"output_filtered_data": False,
2324
}
2425
meta = {"resources_dir": "./src/utils/"}
2526
## VIASH END
@@ -101,6 +102,7 @@ def gather_input_data(dir: Path):
101102
# | +-- per_barcode.csv
102103
# | +-- antigen_specificity_scores.csv
103104
# +-- count
105+
# | +-- sample_filtered_feature_bc_matrix.h5
104106
# | +-- antibody_analysis
105107
# | +-- crispr_analysis
106108
# | +-- perturbation_efficiencies_by_feature.csv
@@ -174,6 +176,26 @@ def gather_input_data(dir: Path):
174176
file_name = found_file.name.removesuffix(".csv")
175177
found_input.setdefault(file_name, {})[samples_dir.name] = found_file
176178

179+
if par["output_filtered_data"]:
180+
for samples_dir in samples_dirs:
181+
for file_part in (
182+
"count/sample_filtered_feature_bc_matrix.h5",
183+
"sample_filtered_feature_bc_matrix.h5", # Cell Ranger v10
184+
):
185+
found_file = samples_dir / file_part
186+
if found_file.exists():
187+
found_input.setdefault("filtered_counts", {})[samples_dir.name] = (
188+
found_file
189+
)
190+
break
191+
else:
192+
raise ValueError(
193+
f"Expected a filtered count matrix under {samples_dir}, "
194+
"but none was found. Make sure the input directory is a "
195+
"valid cellranger multi output that contains per-sample "
196+
"filtered feature-barcode matrices."
197+
)
198+
177199
return found_input
178200

179201

@@ -219,22 +241,21 @@ def process_feature_reference(
219241
return mudatas
220242

221243

222-
def process_counts(counts_folder: Path, multiplexing_info, metrics_files):
223-
counts_matrix_file = counts_folder / "raw_feature_bc_matrix.h5"
224-
logger.info("Reading %s.", counts_matrix_file)
225-
adata = scanpy.read_10x_h5(counts_matrix_file, gex_only=False)
244+
def _modality_name_factory(library_type):
245+
return ("".join(library_type.replace("-", "_").split())).lower()
226246

227-
# set the gene ids as var_names
228-
logger.info("Renaming var columns")
229-
adata.var = adata.var.rename_axis("gene_symbol").reset_index().set_index("gene_ids")
230247

231-
# generate output
232-
logger.info("Convert to mudata")
248+
def _rename_var_to_gene_ids(adata: anndata.AnnData):
249+
# set the gene ids as var_names (unique Ensembl IDs); gene symbols, which
250+
# scanpy.read_10x_h5 uses as var_names by default, are not unique
251+
adata.var = adata.var.rename_axis("gene_symbol").reset_index().set_index("gene_ids")
233252

234-
def modality_name_factory(library_type):
235-
return ("".join(library_type.replace("-", "_").split())).lower()
236253

237-
feature_types = defaultdict(modality_name_factory, FEATURE_TYPES_NAMES)
254+
def _aggregated_counts_to_per_sample_mudatas(
255+
adata: anndata.AnnData, multiplexing_info, metrics_files
256+
):
257+
logger.info("Convert to mudata")
258+
feature_types = defaultdict(_modality_name_factory, FEATURE_TYPES_NAMES)
238259
mudata_all_samples = mudata.MuData(adata, feature_types_names=feature_types)
239260
if multiplexing_info:
240261
# Get the mapping between the barcode and the sample ID from one of the metrics files
@@ -267,6 +288,29 @@ def modality_name_factory(library_type):
267288
return {"run": mudata_all_samples}
268289

269290

291+
def process_counts_filtered(filtered_counts: dict[str, Path]):
292+
# Unlike the raw matrix, per-sample filtered matrices are already
293+
# demultiplexed by cellranger, so each h5 maps 1:1 to an output mudata.
294+
feature_types = defaultdict(_modality_name_factory, FEATURE_TYPES_NAMES)
295+
mudatas = {}
296+
for sample_name, filtered_h5 in filtered_counts.items():
297+
logger.info("Reading %s.", filtered_h5)
298+
adata = scanpy.read_10x_h5(filtered_h5, gex_only=False)
299+
_rename_var_to_gene_ids(adata)
300+
mudatas[sample_name] = mudata.MuData(adata, feature_types_names=feature_types)
301+
return mudatas
302+
303+
304+
def process_counts(counts_folder: Path, multiplexing_info, metrics_files):
305+
counts_matrix_file = counts_folder / "raw_feature_bc_matrix.h5"
306+
logger.info("Reading %s.", counts_matrix_file)
307+
adata = scanpy.read_10x_h5(counts_matrix_file, gex_only=False)
308+
_rename_var_to_gene_ids(adata)
309+
return _aggregated_counts_to_per_sample_mudatas(
310+
adata, multiplexing_info, metrics_files
311+
)
312+
313+
270314
def split_samples(mudata_obj, multiplexing_analysis_folder, barcode_sample_mapping):
271315
result = {}
272316
cells_per_tag_file = multiplexing_analysis_folder / "cells_per_tag.json"
@@ -410,14 +454,19 @@ def get_modalities(input_data):
410454
),
411455
"antigen_analysis": process_antigen_analysis,
412456
}
413-
mudata_per_sample = process_counts(
414-
input_data["count"],
415-
input_data["multiplexing_analysis"],
416-
input_data["metrics_summary"],
417-
)
457+
if input_data.get("filtered_counts"):
458+
mudata_per_sample = process_counts_filtered(
459+
input_data["filtered_counts"],
460+
)
461+
else:
462+
mudata_per_sample = process_counts(
463+
input_data["count"],
464+
input_data["multiplexing_analysis"],
465+
input_data["metrics_summary"],
466+
)
418467
for modality_name, modality_data_path in input_data.items():
419468
if (
420-
modality_name in ("count", "multiplexing_analysis")
469+
modality_name in ("count", "multiplexing_analysis", "filtered_counts")
421470
or not modality_data_path
422471
):
423472
continue

src/convert/from_cellranger_multi_to_h5mu/test.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,85 @@ def test_no_feature_reference(run_component, tmp_path, input_4plex_dtc):
340340
assert list(converted_data.mod.keys()) == ["rna", "prot"]
341341

342342

343+
def test_cellranger_multi_output_filtered_data(run_component, tmp_path, input_anticmv):
344+
output_dir = tmp_path / "converted"
345+
output_path_template = output_dir / "*.h5mu"
346+
samples_csv = tmp_path / "samples.csv"
347+
run_component(
348+
[
349+
"--input",
350+
input_anticmv,
351+
"--output",
352+
str(output_path_template),
353+
"--output_compression",
354+
"gzip",
355+
"--sample_csv",
356+
samples_csv,
357+
"--output_filtered_data",
358+
]
359+
)
360+
assert output_dir.is_dir()
361+
samples = [item for item in output_dir.iterdir() if item.is_file()]
362+
assert len(samples) == 1
363+
filtered_data = read_h5mu(samples[0])
364+
assert list(filtered_data.mod.keys()) == ["rna", "prot", "vdj_t"]
365+
assert filtered_data.mod["rna"].n_obs == 3798
366+
367+
368+
def test_cellranger_multi_output_filtered_data_multiplexed(
369+
run_component, tmp_path, input_fixed_rna
370+
):
371+
output_dir = tmp_path / "converted"
372+
output_path_template = output_dir / "*.h5mu"
373+
samples_csv = tmp_path / "samples.csv"
374+
run_component(
375+
[
376+
"--input",
377+
input_fixed_rna,
378+
"--output",
379+
str(output_path_template),
380+
"--output_compression",
381+
"gzip",
382+
"--sample_csv",
383+
samples_csv,
384+
"--output_filtered_data",
385+
]
386+
)
387+
assert output_dir.is_dir()
388+
samples = [item for item in output_dir.iterdir() if item.is_file()]
389+
sample_names = {item.name.removesuffix(".h5mu") for item in samples}
390+
assert sample_names == {
391+
"Colorectal_BC3",
392+
"Liver_BC1",
393+
"Ovarian_BC2",
394+
"Pancreas_BC4",
395+
}
396+
expected_n_obs_by_version = {
397+
"v9": {
398+
"Colorectal_BC3": 1910,
399+
"Liver_BC1": 1779,
400+
"Ovarian_BC2": 1941,
401+
"Pancreas_BC4": 862,
402+
},
403+
"v10": {
404+
"Colorectal_BC3": 1915,
405+
"Liver_BC1": 1784,
406+
"Ovarian_BC2": 1946,
407+
"Pancreas_BC4": 866,
408+
},
409+
}
410+
expected_n_obs = expected_n_obs_by_version[
411+
"v10" if "_v10" in input_fixed_rna else "v9"
412+
]
413+
actual_n_obs = {}
414+
for output_path in samples:
415+
converted_data = read_h5mu(output_path)
416+
assert list(converted_data.mod.keys()) == ["rna", "prot"]
417+
sample_name = output_path.name.removesuffix(".h5mu")
418+
actual_n_obs[sample_name] = converted_data.mod["rna"].n_obs
419+
assert actual_n_obs == expected_n_obs
420+
421+
343422
def test_vdj_no_cells(run_component, tmp_path, input_no_vdj_cells):
344423
"""
345424
Test what happens when a VDJ analysis was performed by Cell Ranger,

src/workflows/ingestion/cellranger_multi/config.vsh.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ argument_groups:
3232
type: string
3333
description: Name of the .uns slot under which to QC metrics (if any).
3434
default: "metrics_cellranger"
35+
- name: "--output_filtered_data"
36+
type: boolean_true
37+
description: |
38+
If enabled, read the per-sample filtered count matrices
39+
(per_sample_outs/{sample}/count/sample_filtered_feature_bc_matrix.h5)
40+
instead of the aggregated raw count matrix
41+
(multi/count/raw_feature_bc_matrix.h5).
3542
dependencies:
3643
- name: mapping/cellranger_multi
3744
alias: cellranger_multi_component

src/workflows/ingestion/cellranger_multi/main.nf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ workflow run_wf {
9393
[
9494
"input": state.input,
9595
"uns_metrics": state.uns_metrics,
96+
"output_filtered_data": state.output_filtered_data,
9697
"output_compression": "gzip"
9798
]
9899
},

src/workflows/ingestion/cellranger_multi/test.nf

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,44 @@ workflow test_wf {
99

1010
resources_test = file(params.resources_test)
1111

12-
output_ch = Channel.fromList([
13-
[
14-
id: "foo",
15-
input:[
16-
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_GEX_1_subset_S1_L001_R1_001.fastq.gz"),
17-
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_GEX_1_subset_S1_L001_R2_001.fastq.gz"),
18-
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_AB_subset_S2_L004_R1_001.fastq.gz"),
19-
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_AB_subset_S2_L004_R2_001.fastq.gz"),
20-
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_VDJ_subset_S1_L001_R1_001.fastq.gz"),
21-
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_VDJ_subset_S1_L001_R2_001.fastq.gz")
22-
],
23-
gex_reference: resources_test.resolve("reference_gencodev41_chr1/reference_cellranger.tar.gz"),
24-
vdj_reference: resources_test.resolve("10x_5k_anticmv/raw/refdata-cellranger-vdj-GRCh38-alts-ensembl-7.0.0.tar.gz"),
25-
feature_reference: resources_test.resolve("10x_5k_anticmv/raw/feature_reference.csv"),
26-
library_id: [
27-
"5k_human_antiCMV_T_TBNK_connect_GEX_1_subset",
28-
"5k_human_antiCMV_T_TBNK_connect_AB_subset",
29-
"5k_human_antiCMV_T_TBNK_connect_VDJ_subset"
30-
],
31-
library_type: [
32-
"Gene Expression",
33-
"Antibody Capture",
34-
"VDJ"
35-
]
36-
]
37-
])
12+
base_state = [
13+
id: "foo",
14+
input:[
15+
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_GEX_1_subset_S1_L001_R1_001.fastq.gz"),
16+
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_GEX_1_subset_S1_L001_R2_001.fastq.gz"),
17+
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_AB_subset_S2_L004_R1_001.fastq.gz"),
18+
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_AB_subset_S2_L004_R2_001.fastq.gz"),
19+
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_VDJ_subset_S1_L001_R1_001.fastq.gz"),
20+
resources_test.resolve("10x_5k_anticmv/raw/5k_human_antiCMV_T_TBNK_connect_VDJ_subset_S1_L001_R2_001.fastq.gz")
21+
],
22+
gex_reference: resources_test.resolve("reference_gencodev41_chr1/reference_cellranger.tar.gz"),
23+
vdj_reference: resources_test.resolve("10x_5k_anticmv/raw/refdata-cellranger-vdj-GRCh38-alts-ensembl-7.0.0.tar.gz"),
24+
feature_reference: resources_test.resolve("10x_5k_anticmv/raw/feature_reference.csv"),
25+
library_id: [
26+
"5k_human_antiCMV_T_TBNK_connect_GEX_1_subset",
27+
"5k_human_antiCMV_T_TBNK_connect_AB_subset",
28+
"5k_human_antiCMV_T_TBNK_connect_VDJ_subset"
29+
],
30+
library_type: [
31+
"Gene Expression",
32+
"Antibody Capture",
33+
"VDJ"
34+
]
35+
]
36+
37+
// Convert the aggregated raw count matrix (default behaviour).
38+
raw_ch = Channel.fromList([base_state])
39+
| map{ state -> [state.id, state] }
40+
| cellranger_multi
41+
| view { output ->
42+
assert output.size() == 2 : "outputs should contain two elements; [id, out]"
43+
assert output[1] instanceof Map : "Output should be a Map."
44+
// todo: check whether output dir contains fastq files
45+
"Output: $output"
46+
}
47+
48+
// Convert the per-sample filtered count matrices instead.
49+
filtered_ch = Channel.fromList([base_state + [output_filtered_data: true]])
3850
| map{ state -> [state.id, state] }
3951
| cellranger_multi
4052
| view { output ->
@@ -44,8 +56,13 @@ workflow test_wf {
4456
"Output: $output"
4557
}
4658

59+
raw_ch
60+
| join(filtered_ch)
61+
| map { id, raw_state, filtered_state ->
62+
[id, ["input": raw_state.output_h5mu, "input_filtered": filtered_state.output_h5mu]]
63+
}
4764
| cellranger_multi_test.run(
48-
fromState: ["input": "output_h5mu"]
65+
fromState: ["input": "input", "input_filtered": "input_filtered"]
4966
)
5067

5168
| toSortedList()

src/workflows/test_workflows/ingestion/cellranger_multi/config.vsh.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ argument_groups:
1313
multiple: true
1414
description: Path to h5mu output.
1515
example: foo.final.h5mu
16+
- name: "--input_filtered"
17+
type: file
18+
required: false
19+
multiple: true
20+
description: |
21+
Path to h5mu output generated with --output_filtered_data enabled.
22+
When provided, the per-sample filtered count matrices are checked to
23+
contain fewer cells than the corresponding raw count matrices.
24+
example: foo.filtered.h5mu
1625
resources:
1726
- type: python_script
1827
path: script.py

src/workflows/test_workflows/ingestion/cellranger_multi/script.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44

55
##VIASH START
6-
par = {"input": "input.h5mu"}
6+
par = {"input": "input.h5mu", "input_filtered": None}
77

88
meta = {"resources_dir": "resources_test"}
99
##VIASH END
@@ -28,5 +28,18 @@ def test_run():
2828
)
2929

3030

31+
def test_filtered_data_has_fewer_cells():
32+
if not par.get("input_filtered"):
33+
pytest.skip("No filtered input provided.")
34+
for raw_path, filtered_path in zip(par["input"], par["input_filtered"]):
35+
raw_mudata = read_h5mu(raw_path)
36+
filtered_mudata = read_h5mu(filtered_path)
37+
38+
assert filtered_mudata.mod["rna"].n_obs < raw_mudata.mod["rna"].n_obs, (
39+
"Expected the filtered count matrix to contain fewer cells than the "
40+
"raw count matrix when --output_filtered_data is enabled."
41+
)
42+
43+
3144
if __name__ == "__main__":
3245
sys.exit(pytest.main([__file__, "--import-mode=importlib"]))

0 commit comments

Comments
 (0)