Skip to content

feat: handle Ensembl/symbol gene identifiers in each model's process_data - #418

Merged
dmiv-helical merged 8 commits into
mainfrom
1122-per-model-gene-ids
Aug 6, 2026
Merged

feat: handle Ensembl/symbol gene identifiers in each model's process_data#418
dmiv-helical merged 8 commits into
mainfrom
1122-per-model-gene-ids

Conversation

@dmiv-helical

@dmiv-helical dmiv-helical commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reconciles gene identifiers inside each model's process_data, so an AnnData indexed by Ensembl gene IDs works with the symbol-keyed models and vice versa. Stacked on #417 (GenePT's inverted guard), which is now merged.

Approach

process_data is the single choke point shared by embed, fit, evaluate, eval/ and run_isp, so handling identifiers there covers all five at once. Per-model knowledge also stays in per-model code, rather than in a table that has to be kept in step by hand.

One shared set of primitives, five inlined copies removed

startswith("ENS") appeared inline in Geneformer, Tahoe, Nicheformer, GenePT and Transcriptformer's dataloader. It had three live bugs, each now covered by a test:

Bug Consequence
Matches real gene symbols (ENSA) and transcript/protein IDs (ENST…, ENSP…) Real genes dropped or misrouted
.all()/.any() can't express a mostly-Ensembl index Conversion skipped entirely, or symbols mapped to NaN
Version suffixes never stripped Every ENSG….17 — the GENCODE/CellRanger default — resolves to None and is dropped

Replaced by ENSEMBL_GENE_ID_PATTERN (anchored), is_ensembl_gene_id, ensembl_id_mask (per entry), strip_ensembl_version (Ensembl-matching values only — real symbols contain dots, AC000068.10).

Per model, by what the vocabulary is keyed on

  • scGPT, UCE, GenePT (symbols) → ensure_gene_symbols. Translates Ensembl IDs, leaves existing symbols alone so a mixed index keeps both, and collapses symbols claimed by more than one gene. That collapse is not optional: 10,616 of the 48,698 symbol-bearing rows in the bundled table share a gene_name, and a non-unique var index desyncs scGPT's count_matrix from its gene_ids (scGPT.process_data desyncs gene_ids from count_matrix when gene names are non-unique #377) and raises InvalidIndexError in GenePT. Of a colliding set the copy carrying the most counts wins — choosing positionally lets an all-zero alt-scaffold copy displace the expressed one, after which the gene reads as unexpressed with no error anywhere.
  • Geneformer, Tahoe (Ensembl) → ensure_ensembl_ids. Ensembl input is taken directly instead of raising, and deliberately not round-tripped through symbols: their vocabularies contain genes with no gene symbol at all, so a round trip drops them. ENSG00000159239 — in Geneformer's vocabulary, blank symbol in the table — is now tokenized rather than lost. var_names are left untouched, so reverse lookups and caller-supplied gene lists keep addressing the caller's own identifiers.
  • Nicheformer, Transcriptformer — already branched on the identifier system correctly; only the detector changed.

The two helpers are deliberately asymmetric — one writes a column, the other the index — because their consumers are. Both docstrings now state the rule and the evidence.

Two protections restored explicitly

The guards being removed caught two things by accident, and an existing test encoded them. Rather than re-baseline that test, both are now checked directly — so test_ensembl_data_is_caught passes unmodified:

  • require_vocabulary_overlap — rejects well-formed identifiers from another annotation (mouse IDs against a human vocabulary). Membership rather than shape, so usable Ensembl input is accepted while unusable input still fails loudly instead of tokenizing to nothing. Neither Geneformer nor Tahoe had a zero-overlap check before.
  • reject_null_identifiers — refuses literal "None"/"nan"/empty placeholders, which mean an earlier mapping step already failed. Matched exactly, not by prefix, so real genes like NANOS1 and NAT1 are unaffected (the old check used startswith("None")).

Verification

Against real cached weights on CPU:

Case Before After
scGPT, Ensembl index (incl. versioned + symbol-less) ValueError: No matching genes found 3 genes
scGPT, plain symbols 3 genes 3 genes (untouched, no conversion)
scGPT, mixed index 0 genes 3 genes
scGPT, two IDs colliding on one symbol IndexError/desync risk 2 genes
Geneformer, Ensembl incl. symbol-less in-vocab gene raised outright tokenized, gene kept
Geneformer, mouse IDs raised (by accident) rejected (deliberately)

42 new tests for the primitives, including mixed indexes at several ratios, ENSA, transcript/protein IDs, versioned IDs, and collapse-by-expression parametrised over which copy is expressed — positional selection passes one arrangement and fails the other, so a single fixture would prove nothing.

Suite: 250 passed, 7 skipped, 1 failure (test_transcriptformer … gene_mode, Torch not compiled with CUDA enabled, identical on the base commit). test_helix_mrna and test_mamba2_mrna cannot be collected on the dev host (mamba-ssm absent); test_tahoe needs flash_attn and is verified by CI, which caught two Tahoe tests encoding the previous contract — both since updated.

No repo-wide reformatting: the diff is confined to the twelve files changed.

Comment thread helical/utils/mapping.py Outdated
dmiv-helical added a commit that referenced this pull request Aug 6, 2026
Review feedback on #418 (oriolpetithelical, mapping.py:275): no reason for it to
be function-scoped. numpy is a hard dependency (`numpy>=2.1.3,<2.3` in
pyproject) and is imported at module scope across the rest of helical, so the
local import bought nothing -- it was an artefact of where the helper was first
written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread helical/utils/mapping.py Outdated
Comment thread helical/utils/mapping.py

def ensure_gene_symbols(
adata: AnnData,
gene_names: str = "index",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gene_names_locator maybe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not sound like a verb. We can do locate_gene_names. I do not have a hard opinion on this, WDYT?

@oriolpetithelical oriolpetithelical Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why does it need to have a verb in it? This is just an identifier/locator of where to look for the genes in the dataset. But looking further in the code base, I see that gene_names is used accordingly. As such, keep it as it is

dmiv-helical and others added 4 commits August 6, 2026 13:43
…data

Reconcile gene identifiers at each model's `process_data` -- the single choke
point shared by embed, fit, evaluate, eval and run_isp -- rather than in a
downstream consumer, which reached only two of those and needed a
hand-maintained model->namespace table kept in step by hand.

One shared set of primitives in `helical/utils/mapping.py`, replacing five inlined
copies of `startswith("ENS")`. That expression had three live bugs, each now
covered by a test:

- it matches real gene symbols (`ENSA`) and transcript/protein IDs (`ENST..`,
  `ENSP..`), so anchored `^ENS[A-Z]{0,4}G\d{11}(\.\d+)?$` is used instead;
- `.all()`/`.any()` over the column cannot express a var index that is only
  *mostly* Ensembl IDs, so detection is per entry;
- version suffixes were never stripped, and the mapping tables are keyed on bare
  IDs, so every `ENSG..\.17` -- the GENCODE/CellRanger default -- resolved to None
  and was dropped as "unmapped".

Per model, by what its vocabulary is keyed on:

- **scGPT, UCE, GenePT** (symbols): `ensure_gene_symbols` translates Ensembl IDs,
  leaves existing symbols untouched, and collapses symbols claimed by more than
  one gene -- 10616 of the 48698 symbol-bearing rows in the bundled table share a
  gene_name, and a non-unique var index desyncs scGPT's count_matrix from its
  gene_ids (#377) and raises InvalidIndexError in GenePT. Of a colliding set the
  copy carrying the most counts wins; choosing positionally lets an all-zero
  alt-scaffold copy displace the expressed one, after which the gene reads as
  unexpressed with no error anywhere.
- **Geneformer, Tahoe** (Ensembl): `ensure_ensembl_ids` takes Ensembl input
  **directly** instead of raising. Deliberately no symbol round trip: their
  vocabularies contain genes with no gene symbol at all, so a round trip would
  drop them. ENSG00000159239 -- in Geneformer's vocabulary, blank symbol in the
  table -- is now tokenized rather than lost.
- **Nicheformer, Transcriptformer**: already branched on the identifier system
  correctly; only their detector is swapped.

Two protections the removed guards provided by accident are restored explicitly,
and better, so the existing `test_ensembl_data_is_caught` passes **unmodified**:

- `require_vocabulary_overlap` rejects well-formed identifiers that are simply
  from another annotation (mouse IDs against a human vocabulary) -- membership
  rather than shape, so usable Ensembl input is accepted while unusable input
  still fails loudly instead of tokenizing to nothing.
- `reject_null_identifiers` refuses literal "None"/"nan"/empty placeholders, which
  mean an earlier mapping already failed. Matched exactly rather than by prefix,
  so real genes like NANOS1 and NAT1 are unaffected.

Verified against real cached weights on CPU: an Ensembl-indexed AnnData that
previously failed with "No matching genes found between input data and scGPT gene
vocabulary" now tokenizes, for a plain Ensembl index, a versioned one, a mixed one
and a colliding one; Geneformer keeps the symbol-less in-vocabulary gene; mouse IDs
are rejected. 40 new tests for the primitives. Suite: 250 passed, 7 skipped, 1
pre-existing failure (transcriptformer gene mode, "Torch not compiled with CUDA
enabled", identical on the base commit); test_tahoe/helix_mrna/mamba2_mrna cannot
be collected on this host (flash_attn / mamba-ssm absent).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught these; they cannot run on a host without flash_attn, so the Tahoe path
was flagged as read-only-verified in the PR. Both tests asserted the behaviour
this change deliberately replaces.

- `test_process_data_raises_on_no_mapped_genes` patched
  `helical.models.tahoe.model.map_gene_symbols_to_ensembl_ids`, which no longer
  exists, to force an all-None ensembl_id column. Its *intent* -- nothing maps, so
  raise -- still holds, and UNKNOWN1/UNKNOWN2 are genuinely unmappable, so it now
  asserts that directly with no mock of an internal. That also stops it breaking
  the next time the helper changes.
- `test_gene_mapping_ensembl_warning` asserted `match="ensemble ids"`: a column of
  Ensembl IDs with gene_names != "ensembl_id" was refused outright. That is exactly
  what this change does -- Tahoe's vocabulary *is* Ensembl-keyed, so those identifiers
  are now used as they are, with no symbol round trip. Rewritten to assert the new
  contract and renamed accordingly. It asserts on the reconciliation rather than
  the whole pipeline because the old test never reached the rest of `process_data`
  either (it always raised first) and the hand-built config in the fixture has none
  of what tokenization needs -- driving it further only produced an unrelated
  `KeyError: 'max_length'`.

Unlike Geneformer's `test_ensembl_data_is_caught`, which passes unmodified because
the protections it really guarded (vocabulary overlap, null sentinels) were restored
explicitly, this one asserted the guard's *message* rather than a behaviour worth
keeping, so re-baselining it is the honest fix.

Both asserted behaviours were verified directly against `ensure_ensembl_ids` on the
same data shape; the tests themselves can only be executed by CI.


Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #418 (oriolpetithelical, mapping.py:275): no reason for it to
be function-scoped. numpy is a hard dependency (`numpy>=2.1.3,<2.3` in
pyproject) and is imported at module scope across the rest of helical, so the
local import bought nothing -- it was an artefact of where the helper was first
written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New capability (every model's process_data accepts either gene-identifier
system) plus new public helpers in helical/utils/mapping, so MINOR rather than
PATCH. 3.0.4 was claimed by #417's squash merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dmiv-helical added a commit that referenced this pull request Aug 6, 2026
Review feedback on #418 (oriolpetithelical, mapping.py:275): no reason for it to
be function-scoped. numpy is a hard dependency (`numpy>=2.1.3,<2.3` in
pyproject) and is imported at module scope across the rest of helical, so the
local import bought nothing -- it was an artefact of where the helper was first
written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dmiv-helical
dmiv-helical force-pushed the 1122-per-model-gene-ids branch from 0c8a9d7 to e1a41b0 Compare August 6, 2026 11:47
Comment thread helical/utils/mapping.py
out.var["original_gene_id"] = [
value for value, kept in zip(identifiers, keep) if kept
]
out.var_names = kept_names

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of surprise to me in the sense that even though the gene names are in a column like in line 374-375, we are also modifying the index. At least let's also document it in the docstring

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also tied to this, I see an asymmetry between ensure_ensembl_ids versus ensure_gene_symbols. The one is always putting the outputs in a column (even though the values may be coming from an index) while the other one is putting the outputs in the index and optionally in the column

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. This asymmetry is tributary of the asymmetry in consumers.
Added more information in the docstring.

Review feedback on #418 (oriolpetithelical, mapping.py:324). It was threaded
through five functions purely for string interpolation, and it is redundant even
for that: every caller's `process_data` logs "Processing data for <model>."
immediately before calling these helpers, so the model already sits directly above
the accounting line in the log stream, and an exception's traceback names the
calling module. Messages are now model-agnostic.

Removed from `ensure_gene_symbols`, `ensure_ensembl_ids`, `reject_null_identifiers`,
`require_vocabulary_overlap` and `_log_accounting`; six call sites and two tests
updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dmiv-helical added a commit that referenced this pull request Aug 6, 2026
Review feedback on #418 (oriolpetithelical, mapping.py:324). It was threaded
through five functions purely for string interpolation, and it is redundant even
for that: every caller's `process_data` logs "Processing data for <model>."
immediately before calling these helpers, so the model already sits directly above
the accounting line in the log stream, and an exception's traceback names the
calling module. Messages are now model-agnostic.

Removed from `ensure_gene_symbols`, `ensure_ensembl_ids`, `reject_null_identifiers`,
`require_vocabulary_overlap` and `_log_accounting`; six call sites and two tests
updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #418 (oriolpetithelical, mapping.py:369): surprising that the
index is modified even when the gene names live in a column.

It is deliberate, and the docstring now says why: symbol-keyed models do not agree
on which identifiers they read. scGPT reads `var[gene_names]`, but GenePT looks its
embeddings up on `var_names` (`get_text_embeddings`) regardless of what
`gene_names` was -- so leaving the index alone would silently match nothing there.
Normalising both is what makes one result usable by any of them.

The docstring now also states the rest of what the function writes: the named
column is kept in step (callers run `ensure_rna_data_validity` first, which
materialises a `var["index"]` from the pre-conversion index, and a lookup reading
that stale column would match nothing), the pre-conversion identifiers are kept in
`var["original_gene_id"]`, genes can be dropped so `n_vars` shrinks with `X` subset
alongside, and the input is returned uncopied only when nothing needs converting.

Every claim was verified by execution before being written down, and the two that
were unpinned now have tests -- reverting the column sync turns both red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dmiv-helical added a commit that referenced this pull request Aug 6, 2026
Review feedback on #418 (oriolpetithelical, mapping.py:369): surprising that the
index is modified even when the gene names live in a column.

It is deliberate, and the docstring now says why: symbol-keyed models do not agree
on which identifiers they read. scGPT reads `var[gene_names]`, but GenePT looks its
embeddings up on `var_names` (`get_text_embeddings`) regardless of what
`gene_names` was -- so leaving the index alone would silently match nothing there.
Normalising both is what makes one result usable by any of them.

The docstring now also states the rest of what the function writes: the named
column is kept in step (callers run `ensure_rna_data_validity` first, which
materialises a `var["index"]` from the pre-conversion index, and a lookup reading
that stale column would match nothing), the pre-conversion identifiers are kept in
`var["original_gene_id"]`, genes can be dropped so `n_vars` shrinks with `X` subset
alongside, and the input is returned uncopied only when nothing needs converting.

Every claim was verified by execution before being written down, and the two that
were unpinned now have tests -- reverting the column sync turns both red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iffer

Review feedback on #418 (oriolpetithelical): "I see an asymmetry between
ensure_ensembl_ids versus ensure_gene_symbols. The one is always putting the
outputs in a column (even though the values may be coming from an index) while the
other one is putting the outputs in the index and optionally in the column."

Correct observation; the asymmetry is deliberate, and the code said nothing about
it. Each helper performs the **minimum mutation its consumers require**, and the
consumers themselves are asymmetric:

- Ensembl-keyed models read the column -- Geneformer's tokenizer reads
  `data.var.ensembl_id`, Tahoe reads `var[gene_id_key]` -- and neither uses
  `var_names` to identify a gene, so writing the column suffices.
- Symbol-keyed models disagree with each other and between them cover both
  surfaces: GenePT and UCE read `var_names`, scGPT reads `var[gene_names]`. So
  `ensure_gene_symbols` has to normalise both.

Leaving `var_names` alone on the Ensembl side is load-bearing rather than
incidental: it keeps the caller's identifiers addressable for `id_to_gene` reverse
lookups and caller-supplied gene lists, and
rewriting the index would silently change the identifier system of anything
reported downstream -- an ISP run would come back keyed on Ensembl IDs even when
the caller supplied symbols.

Documented on both functions so the pairing is discoverable from either. Every
claim re-verified by execution: the column/index write behaviour, and each of the
five consumer read-sites cited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dmiv-helical added a commit that referenced this pull request Aug 6, 2026
…iffer

Review feedback on #418 (oriolpetithelical): "I see an asymmetry between
ensure_ensembl_ids versus ensure_gene_symbols. The one is always putting the
outputs in a column (even though the values may be coming from an index) while the
other one is putting the outputs in the index and optionally in the column."

Correct observation; the asymmetry is deliberate, and the code said nothing about
it. Each helper performs the **minimum mutation its consumers require**, and the
consumers themselves are asymmetric:

- Ensembl-keyed models read the column -- Geneformer's tokenizer reads
  `data.var.ensembl_id`, Tahoe reads `var[gene_id_key]` -- and neither uses
  `var_names` to identify a gene, so writing the column suffices.
- Symbol-keyed models disagree with each other and between them cover both
  surfaces: GenePT and UCE read `var_names`, scGPT reads `var[gene_names]`. So
  `ensure_gene_symbols` has to normalise both.

Leaving `var_names` alone on the Ensembl side is load-bearing rather than
incidental: it keeps the caller's identifiers addressable for `id_to_gene` reverse
lookups and caller-supplied gene lists (helicalAI/bio-agent#1128, #1120), and
rewriting the index would silently change the identifier system of anything
reported downstream -- an ISP run would come back keyed on Ensembl IDs even when
the caller supplied symbols.

Documented on both functions so the pairing is discoverable from either. Every
claim re-verified by execution: the column/index write behaviour, and each of the
five consumer read-sites cited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The helical repo should not carry references to another repository's tracker:
they are unresolvable for anyone reading this code, and the bare numbers would
render as links to unrelated helical issues. The reasoning each reference
accompanied is kept in full -- only the identifiers are removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dmiv-helical
dmiv-helical force-pushed the 1122-per-model-gene-ids branch from 2b00102 to b81f25c Compare August 6, 2026 13:09
@dmiv-helical
dmiv-helical merged commit 5f0e6b6 into main Aug 6, 2026
7 checks passed
@dmiv-helical
dmiv-helical deleted the 1122-per-model-gene-ids branch August 6, 2026 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants