Skip to Content
GuidesComparing ranked lists

Comparing ranked lists

You have the top 200 differentially expressed genes, in order, and a curated set of genes known to matter. This is how you score one against the other — and how Ligase stops you scoring a ranking against something named in a different space or by a different release.

Load the ranking

A ranked list is a RankedList: a TSV with a header, best row first, one column of identifiers, and as many attribute columns as you like.

import ligase as lg ranked = lg.ranked_list( "results/degs_ranked.tsv", organism="Homo sapiens", identifier_namespace="HGNC symbol", )

The file behind it is an ordinary ranked table:

id score padj TP53 12.4 1.2e-14 BRCA1 9.1 4.4e-11 EGFR 7.7 8.1e-09 NKX2-1 3.2 2.0e-04

Rank is the row position. There is no rank column to declare, and a file that happens to number its rows carries that number as an ordinary attribute column like any other. If your identifiers are not under id — and a ranked table often has a column of its own in the way — name the column:

ranked = lg.ranked_list( "results/degs_ranked.tsv", id_column="gene", organism="Homo sapiens", identifier_namespace="HGNC symbol", )

Everything a gene list carries, a ranking carries — including the rule about which spaces a file can state for itself. ENSG and CHEMBL carry markers, so a ranking of those reads its own space off the identifiers; bare gene symbols carry none and are declared, as above (D56):

>>> ranked.dimension("identifier_namespace").value 'HGNC symbol' >>> ranked.dimension("identifier_namespace").source 'declared' >>> targets = lg.ranked_list("results/targets.tsv", organism="Homo sapiens") >>> targets.dimension("identifier_namespace").source "from the identifiers' own pattern: results/targets.tsv"

Score it against a truth set

truth = lg.entity_set( "truth/known_drivers.tsv", organism="Homo sapiens", identifier_namespace="HGNC symbol", subject="breast carcinoma", ) @lg.step(tool="python3") def score(ranked: lg.RankedList, truth: lg.EntitySet) -> lg.RankedList: """Mark each ranked gene as a hit or a miss against the truth set.""" return lg.sh("python3 scripts/score.py {ranked} {truth} > {out}")

The step takes one of each type and returns a ranking, and nothing about it runs until you ask it to. What the check compares is the two inputs’ dimensions.

The two refusals

Different identifier spaces

A ranking of symbols scored against a truth set of Ensembl IDs gives a precision of nearly zero. Nothing errors, nothing is empty, and the number reads as a verdict on your method rather than on your identifiers.

lg.check_step("score", {"ranked": ranked, "truth": truth})
DimensionErrorin stepscoreligase check — nothing ran
ranked.identifier_namespace=HGNC symboldeclared
truth.identifier_namespace=Ensembl genefrom the identifiers' own pattern: crates/ligase-core/tests/fixtures/entities_ensembl.tsv
These must match. Add a `map_identifiers` step; identifiers are never renamed implicitly.

There is no flag that makes this join work. The only way across is a map_identifiers step, because mapping between spaces is a lookup with failures rather than a rename (D38).

Different releases

The subtler one. Both files are symbols, both look right, and they were named by HGNC downloads six months apart — over which a symbol can be retired, renamed, or moved to another gene.

ranked = lg.ranked_list( "results/degs_ranked.tsv", organism="Homo sapiens", identifier_namespace="HGNC symbol", release="HGNC 2026-09", ) truth = lg.entity_set( "truth/known_drivers.tsv", organism="Homo sapiens", identifier_namespace="HGNC symbol", release="HGNC 2026-03", )
DimensionErrorin stepscoreligase check — nothing ran
ranked.release=HGNC 2026-09declared
truth.release=HGNC 2026-03declared
These must match. Did one input come from a different release?

A release is compared by equality, never by containment, which is why it is a dimension of its own rather than part of reference (D36). It is also never demanded: a file that records no release still loads, and the comparison happens wherever both sides state one. So declaring it on both sides is how you get this check — and the half-minute it takes is the whole cost of not publishing a concordance figure that measures your naming.

Reordering the rows changes the result

This is the one way a RankedList behaves unlike everything else in Ligase, and it is worth seeing rather than being told. What follows is a real one-step pipeline over one file of each type. Run it once:

running 1 step in pipeline.py
 
ranscore0ms
 
1 step: 1 ran.

Run it again with nothing touched, and there is nothing to do:

running 1 step in pipeline.py
 
cachedscore
 
1 step: 1 from cache.

Now reorder the ranking — the same four rows, the same four scores, moved around — and change nothing else:

running 1 step in pipeline.py
 
ranscore0ms
 
1 step: 1 ran.

It ran. Then reorder the set the same way:

running 1 step in pipeline.py
 
cachedscore
 
1 step: 1 from cache.

It did not. The engine read the changed file, canonicalised it, and found it had seen this set before: the rows of a set arrive in whatever order they came off whatever produced them, and that is not a fact about the genes.

Both answers are right, and that is the point. The twelfth-ranked gene and the first-ranked gene are different claims about the same two genes, so for a ranking the order is the answer and the answer is hashed (D41). If you find yourself wanting the set behaviour, you want an EntitySet — the choice of type is the choice of claim.

A score is compared exactly

A score column is an ordinary attribute column, and it is compared to the last digit. Ligase declares a numeric tolerance in exactly one place — QUAL in a VCF, where a Phred-scaled floating-point accumulation can move its last digits with the order the terms were added in — and nowhere else, because a tolerance nobody has measured would be a guess baked into every cache key. See what a file means.

So a rerun whose scores differ in the ninth decimal place is a rerun whose output differs, and downstream steps rerun. If that turns out to cost you real time, ligase audit is how you measure the drift before anyone argues about a tolerance for it:

$ ligase audit score

See checking that a step repeats.

Mapping a ranking into another identifier space

A ranking crosses identifier spaces with lg.map_ranked_identifiers, which is lg.map_identifiers with one difference that matters:

ensembl = lg.map_ranked_identifiers( ranked, table=lg.entity_set( "hgnc_to_ensembl.tsv", organism="Homo sapiens", identifier_namespace="HGNC symbol", release="HGNC 2026-09", ), column="ensembl_gene_id", to="Ensembl gene", )

Everything crossing identifier spaces says applies: the table is typed so its contents join the cache key and its own space is checked against the ranking’s, and an identifier that does not cross is named rather than dropped in silence or carried into a space it is not in.

The difference is what happens to the rows that do cross. A set that loses members is still an honest set of what remains. A ranking that loses rows is not simply shorter — rank is the row position, so every row below a dropped one moves up. If the top-ranked identifier had no mapping, the second is now first.

The output is a perfectly well-formed ranking with ranks 1..n and nothing in the file to show that n is smaller than it was, so the step says it:

noticed the ranks were renumbered by the mapping: rank 1 of this ranking was rank 2 of the list that was mapped, and every rank below a dropped row has moved up by as many rows as were dropped above it

There is a separate step for this rather than an argument to lg.map_identifiers because @lg.step reads the output kind off the return annotation, once, when the step is decorated — so one function cannot produce both an EntitySet and a RankedList.

What to reach for

You haveLoad it as
A ranking: DEGs by score, targets by predicted affinity, a leaderboardlg.ranked_list
A set: pathway membership, a curated panel, GWAS hits, a truth setlg.entity_set
A ranking you want in another identifier spacelg.ranked_list, then lg.map_ranked_identifiers
A set you want in another identifier spacelg.entity_set, then lg.map_identifiers
Last updated on