Skip to Content
GuidesCrossing identifier spaces

Crossing identifier spaces

You have a list of differentially expressed genes as HGNC symbols, and a published truth set as Ensembl IDs. This is how you compare them without quietly losing half of each.

Load each list

A gene list is an EntitySet: a TSV with a header, one column of identifiers, and as many attribute columns as you like. The identifier column is id unless you say otherwise.

import ligase as lg mine = lg.entity_set( "results/degs.tsv", organism="Homo sapiens", identifier_namespace="HGNC symbol", ) published = lg.entity_set( "truth/published_set.tsv", organism="Homo sapiens", subject="breast carcinoma", )

One call declares an identifier space and the other does not, and the difference is the whole of what the loader can and cannot read. Ask what was loaded:

>>> mine.dimension("identifier_namespace").value 'HGNC symbol' >>> mine.dimension("identifier_namespace").source 'declared' >>> published.dimension("identifier_namespace").value 'Ensembl gene' >>> published.dimension("identifier_namespace").source "from the identifiers' own pattern: truth/published_set.tsv"

ENSG00000141510 carries a marker no other space has, so the published set states its own space and the loader says where it read it. TP53 carries none: it is the shape of an HGNC symbol, of a ChEMBL component synonym and of most in-house panels at once, so a symbol list has to say which it is (D56). Leave it off and the dimension is unknown with a reason, which the join then refuses rather than guesses past. See identifier spaces on all four cases where inference declines.

Watch the join be refused

lg.check_step("score", {"truth": published, "found": mine})
DimensionError in step `score`: truth.identifier_namespace = Ensembl gene (from the identifiers' own pattern: truth/published_set.tsv) found.identifier_namespace = HGNC symbol (from the identifiers' own pattern: results/degs.tsv) These must match. Add a `map_identifiers` step; identifiers are never renamed implicitly.

Nothing has run. check_step read the two identifier columns and compared dimensions. There is no flag that makes this join work, and that is the design: see D38.

Load the mapping table

The mapping is itself an EntitySet, not a path, and the reason is worth a moment. Its identifiers are the source side of the mapping, so they are in the same space as the list you are converting; the targets live in an attribute column.

hgnc_to_ensembl = lg.entity_set( "refs/hgnc_to_ensembl.tsv", organism="Homo sapiens", identifier_namespace="HGNC symbol", release="HGNC 2026-09", )

A table like that looks like this, and the target column can be called anything:

id ensembl_gene_id TP53 ENSG00000141510 BRCA1 ENSG00000012048

Two things follow from the table being typed. Its contents join the cache key, so editing the table reruns the mapping — which a path would not do, because a plain parameter is hashed by its value and a path’s value does not change when the file behind it does. And its own space is checked against the list being mapped, so a table of Entrez IDs handed to a list of symbols is a DimensionError before anything runs, rather than a mapping that matches nothing.

Declaring the table’s release is optional and worth doing. It is compared by equality wherever both sides state it, and it is what a methods paragraph cites when a reviewer asks which HGNC download you used.

Convert

in_ensembl = lg.map_identifiers( mine, hgnc_to_ensembl, column="ensembl_gene_id", to="Ensembl gene", )

column= names the table column holding the identifiers to convert to; to= declares the space the output is in. Both are explicit on purpose: what the output is must not depend on which identifiers happened to cross.

The output declares Ensembl gene and carries everything else unchanged — the organism, the subject, the release — because a conversion crosses one axis and is not a new list. So the join now passes:

@lg.step def score(truth: lg.EntitySet, found: lg.EntitySet) -> lg.EntitySet: """Score a result set against a truth set.""" return lg.sh("comm -12 <(cut -f1 {truth} | sort) <(cut -f1 {found} | sort) > {out}") pipe = lg.Pipeline(score(published, in_ensembl)) pipe.check() # passes; nothing has run report = pipe.run()

Read what did not cross

This is the part that makes the step worth having over a merge.

for run in report.steps: for note in run.notes: print(note)
the identifier `SEPT7` is in no row of the mapping table, so it is not in the output the identifier `NKX2-1` maps to more than one target, so it is not in the output
2 identifiers did not cross into the target space, so the output is 2 entities smaller than the input

Three kinds of identifier do not cross, and none is dropped in silence:

  • No row in the table. Usually a symbol renamed or retired since the table was downloaded. Check the table’s release.
  • Several rows in the table. One symbol legitimately covering more than one gene. It is left out rather than resolved, because emitting a row for each would make the output larger than the input without saying so, and choosing one would be a guess about which you meant. Decide, and add a one-to-one row — or, where the identifier really does denote two things, ask for a fan-out; see when an identifier means two things below.
  • A target another of your identifiers also reaches. Ordinary for a table holding both a deprecated symbol and its replacement, when your list holds both. Emitting both rows would make the output name one gene twice with two sets of attributes, which is not a set; keeping one would be a guess about whose attributes to carry. Both are left out and named.

So the output is smaller than the input by exactly the identifiers the notes name, and the last note says how many that is. The named ones stop at twenty — enough to recognise a pattern, and short of putting four thousand rows in your provenance — while the count covers the rest. Compare it against the size of your input before reading the result: a large share usually means the mapping table is from a different release than the list.

The notes travel into the run report, provenance, a reproducibility bundle and the methods paragraph. Nothing about the conversion is invisible later. See fixing a noticed line for what to do with one.

What a spreadsheet did to your file

If the list has been through Excel, some symbols are now dates:

>>> genes = lg.entity_set("results/degs_from_excel.tsv", organism="Homo sapiens") # undeclared, on purpose >>> for note in genes.notes: ... print(note) the identifier `2-Sep` is a date, not a name; a spreadsheet does this to SEPT7, MARCH1 and DEC1 the identifier `1-Mar` is a date, not a name; a spreadsheet does this to SEPT7, MARCH1 and DEC1 >>> genes.dimension("identifier_namespace") # declined to infer None >>> genes.unknown_reason("identifier_namespace") "not declared, and some identifiers are dates rather than names -- see this dataset's notes, and re-export the file with the identifier column typed as text"

The file loads — it is valid TSV, and refusing it would be turning a format problem into an engine failure. What the loader does instead is say what it saw and decline to infer a space for a file it cannot fully read.

The reason names the cause rather than giving the generic advice, and that is deliberate. “Declare identifier_namespace” is sound for a file whose identifiers are merely unrecognised, and it is the worst possible advice for this one: declare it and the file is accepted from then on with SEPT7 missing from every join, which is the corruption made permanent.

The fix is to re-export with the identifier column typed as text. There is no option to have Ligase repair it: 2-Sep could have been SEPT7 or SEPT2, and the engine will not guess which gene you meant.

When an identifier really means two things

“Decide which target you mean” assumes the ambiguity is a data-quality problem. Often it is. Sometimes the identifier genuinely denotes more than one entity, and then there is no one-to-one row to add:

  • P. falciparum DHFR-TS is one polypeptide with two catalytic domains, answering to human DHFR and TYMS. PPPK-DHPS is the same story. Both targets are right; this is the organism, not a curation error.
  • A protein complex named as a unit maps to its subunit genes.
  • A paralogue family has no one-to-one human orthologue but a defensible set of them.

ambiguous="fan-out" asks for a row at each target:

in_ensembl = lg.map_identifiers( mine, hgnc_to_ensembl, column="ensembl_gene_id", to="Ensembl gene", ambiguous="fan-out", )
the identifier `NKX2-1` maps to more than one target, and this step asked for a fan-out, so the output holds a row for each 1 identifier fanned out into 2 rows, so the output is 1 entity larger than the input

The size change is on the record, in the one direction nothing else in this conversion moves — so anything counting entities before and after will disagree, and the second count is the right one for the space the output is in.

It is declared at the call site and never inferred, because the engine cannot tell an untidy mapping table from a bifunctional enzyme. It is an ordinary step parameter, so changing it reruns the step: the output is different. And a target two of your identifiers reach is still left out, because the output is a set (D60).

A ranking cannot fan out

Rank is the row position (D41), and one row becoming two has no rank: tie ranks would invent an order nothing produced, and renumbering would make rank n mean something no computation assigned. So map_ranked_identifiers has no ambiguous argument, and the step that fans a ranking returns an EntitySet:

targets = lg.fan_out_ranked_identifiers( ranking, hgnc_to_ensembl, column="ensembl_gene_id", to="Ensembl gene", ) # -> EntitySet, because the ranking did not survive

Declaring a space of your own

An in-house panel, a consortium’s numbering, a lab’s accessions: declare the space and the engine will compare it like any other.

panel = lg.entity_set( "refs/resistance_panel.tsv", organism="HIV-1", identifier_namespace="NeoRx panel v3", subject="HIV-1 drug resistance", )

A declared space is recorded rather than checked against the identifiers — the engine knows seven spaces and yours is not one of them, so it takes your word. It still refuses to join this panel to anything declaring a different space, which is the protection you wanted.

Next

Last updated on