From 4d4ddc0806f99d73606d8cffc992114d8d1f46cf Mon Sep 17 00:00:00 2001 From: Jonathan Carse <10391916+joncarse@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:05:36 +0300 Subject: [PATCH 1/2] fix: keep FlyBase gene nan when reading 10x mtx --- docs/release-notes/1259.fix.md | 1 + src/scanpy/io/_read.py | 31 +++++++++++++++++++ tests/test_read_10x.py | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 docs/release-notes/1259.fix.md diff --git a/docs/release-notes/1259.fix.md b/docs/release-notes/1259.fix.md new file mode 100644 index 0000000000..330b3674e8 --- /dev/null +++ b/docs/release-notes/1259.fix.md @@ -0,0 +1 @@ +{func}`~scanpy.io.read_10x_mtx` no longer treats the gene symbol `nan` as missing. diff --git a/src/scanpy/io/_read.py b/src/scanpy/io/_read.py index 8ec0873324..d8d9479184 100644 --- a/src/scanpy/io/_read.py +++ b/src/scanpy/io/_read.py @@ -608,6 +608,35 @@ def _read_mtx( return AnnData(x) +# Copy of pandas._libs.parsers.STR_NA_VALUES (pandas 2.3 / 3.0). Kept here so +# production does not import pandas._libs. If +# test_pandas_str_na_values_unchanged fails, pandas changed this set: update it, +# then _10X_FEATURE_NA_VALUES follows automatically. +_PANDAS_STR_NA_VALUES = frozenset({ + "", + "#N/A", + "#N/A N/A", + "#NA", + "-1.#IND", + "-1.#QNAN", + "-NaN", + "-nan", + "1.#IND", + "1.#QNAN", + "", + "N/A", + "NA", + "NULL", + "NaN", + "None", + "n/a", + "nan", + "null", +}) +# FlyBase gene symbol `nan` is a real identifier; treat other pandas NA tokens as missing. +_10X_FEATURE_NA_VALUES = _PANDAS_STR_NA_VALUES - {"nan"} + + def _read_10x_mtx( path: Path, *, @@ -634,6 +663,8 @@ def _read_10x_mtx( path / f"{prefix}{'genes' if is_legacy else 'features'}.tsv{suffix}", header=None, sep="\t", + keep_default_na=False, + na_values=_10X_FEATURE_NA_VALUES, ) if var_names == "gene_symbols": var_names_idx = pd.Index(genes[1].array) diff --git a/tests/test_read_10x.py b/tests/test_read_10x.py index e8ab00d63f..01360b4b71 100644 --- a/tests/test_read_10x.py +++ b/tests/test_read_10x.py @@ -11,6 +11,7 @@ import scanpy as sc from scanpy._compat import CSRBase +from scanpy.io._read import _PANDAS_STR_NA_VALUES if TYPE_CHECKING: from pathlib import Path @@ -111,6 +112,60 @@ def test_read_10x_mtx_int( assert dict(adata.var.dtypes) == dict(feature_types=str_dt, **col_dtypes) +def _mtx_dir_with_symbol(tmp_path: Path, data_10x: Path, symbol: str) -> Path: + dest = tmp_path / "mtx" + shutil.copytree(data_10x / "int-ids", dest) + lines = (dest / "features.tsv").read_text().splitlines() + cols = lines[0].split("\t") + cols[0] = "FBgn0036414" + cols[1] = symbol + lines[0] = "\t".join(cols) + (dest / "features.tsv").write_text("\n".join(lines) + "\n") + return dest + + +@pytest.mark.parametrize("var_names", ["gene_symbols", "gene_ids"]) +def test_read_10x_mtx_gene_symbol_nan( + tmp_path: Path, data_10x: Path, var_names: Literal["gene_symbols", "gene_ids"] +) -> None: + mtx_path = _mtx_dir_with_symbol(tmp_path, data_10x, "nan") + adata = sc.io.read_10x_mtx(mtx_path, var_names=var_names, compressed=False) + + if var_names == "gene_symbols": + assert adata.var_names[0] == "nan" + assert not pd.isna(adata.var_names[0]) + adata.var["mt"] = adata.var_names.str.startswith("mt:") + assert not adata.var["mt"].isna().any() + sc.pp.calculate_qc_metrics( + adata, qc_vars=["mt"], percent_top=None, log1p=False, inplace=True + ) + else: + assert adata.var["gene_symbols"].iloc[0] == "nan" + assert not adata.var["gene_symbols"].isna().iloc[0] + + +@pytest.mark.parametrize("symbol", ["NaN", "NA"]) +def test_read_10x_mtx_other_na_gene_symbols( + tmp_path: Path, data_10x: Path, symbol: str +) -> None: + mtx_path = _mtx_dir_with_symbol(tmp_path, data_10x, symbol) + adata = sc.io.read_10x_mtx(mtx_path, var_names="gene_ids", compressed=False) + assert adata.var["gene_symbols"].isna().iloc[0] + + +def test_pandas_str_na_values_unchanged() -> None: + """Fail if pandas changes its default NA tokens. + + Production copies ``STR_NA_VALUES`` as ``_PANDAS_STR_NA_VALUES`` and derives + ``_10X_FEATURE_NA_VALUES`` by dropping ``nan``. Importing pandas._libs only + here avoids a private import in ``read_10x_mtx``. On failure, update + ``_PANDAS_STR_NA_VALUES`` in ``scanpy.io._read``. + """ + from pandas._libs.parsers import STR_NA_VALUES + + assert frozenset(STR_NA_VALUES) == _PANDAS_STR_NA_VALUES + + def test_read_10x_h5_v1(data_10x: Path) -> None: spec_genome_v1 = sc.io.read_10x_h5( data_10x / "1.2.0" / "filtered_gene_bc_matrices_h5.h5", From f0819dadeb1c3bf080b0d5fb97bad67e7f78460a Mon Sep 17 00:00:00 2001 From: Jonathan Carse <10391916+joncarse@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:54:24 +0300 Subject: [PATCH 2/2] docs: name the 10x nan towncrier fragment after the PR --- docs/release-notes/{1259.fix.md => 4329.fix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/release-notes/{1259.fix.md => 4329.fix.md} (100%) diff --git a/docs/release-notes/1259.fix.md b/docs/release-notes/4329.fix.md similarity index 100% rename from docs/release-notes/1259.fix.md rename to docs/release-notes/4329.fix.md