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-04Rank 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})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",
)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:
score0msRun it again with nothing touched, and there is nothing to do:
scoreNow reorder the ranking — the same four rows, the same four scores, moved around — and change nothing else:
score0msIt ran. Then reorder the set the same way:
scoreIt 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 scoreSee 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 itThere 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 have | Load it as |
|---|---|
| A ranking: DEGs by score, targets by predicted affinity, a leaderboard | lg.ranked_list |
| A set: pathway membership, a curated panel, GWAS hits, a truth set | lg.entity_set |
| A ranking you want in another identifier space | lg.ranked_list, then lg.map_ranked_identifiers |
| A set you want in another identifier space | lg.entity_set, then lg.map_identifiers |