Ranking DSL

May 18, 2026 · View on GitHub

Topiary's ranking system uses composable expressions — a single DSLNode tree — to filter and rank MHC binding predictions. Every expression evaluates vectorized across peptide-allele groups.

Applying expressions

Two top-level entry points operate on a predictions DataFrame:

from topiary import apply_filter, apply_sort, Affinity, Presentation

df = apply_filter(df, (Affinity <= 500) | (Presentation.rank <= 2.0))
df = apply_sort(df, [Presentation.score, Affinity.score])

apply_filter expects a boolean-valued expression (a Comparison or BoolOp). It errors if the evaluated Series contains values outside {True, False, 0, 1, NaN} — e.g. passing Affinity.score directly — pointing you at <= / >=.

apply_sort accepts a list of expressions as lexicographic tiebreakers. NaN values fall through to the next key instead of forcing an order.

TopiaryPredictor(filter_by=..., sort_by=[...]) applies them automatically during prediction.

Prediction kinds

Each MHC prediction model produces one or more kinds of output. The built-in accessors are:

AccessorKindDescription
AffinitypMHC_affinityBinding affinity (IC50 nM)
PresentationpMHC_presentationPresentation score (EL)
StabilitypMHC_stabilitypMHC complex stability
Processingantigen_processingAntigen processing score

Each accessor has three fields:

FieldDescriptionExample
.valueRaw value (e.g. IC50 nM)Affinity.value
.rankPercentile rank (lower = better)Affinity.rank
.scoreNormalized score (higher = better)Affinity.score

The default field is .value, so Affinity <= 500 means Affinity.value <= 500.

Filters

Create filters with comparison operators on fields:

from topiary import Affinity, Presentation, Stability

# Simple filters
Affinity <= 500               # IC50 <= 500 nM
Affinity.rank <= 2.0          # percentile rank <= 2%
Presentation.score >= 0.5     # presentation score >= 0.5

# Combine with | (OR) and & (AND)
(Affinity <= 500) | (Presentation.rank <= 2.0)
(Affinity <= 500) & (Presentation.score >= 0.5)

Boolean-as-number composition

Comparisons return a boolean Series and still participate in arithmetic — True acts as 1, False as 0. This makes piecewise scoring natural:

# Full score when strong binder, half score otherwise
(Affinity <= 500) * Affinity.score + (Affinity > 500) * 0.5 * Affinity.score

# Penalty for low expression
Affinity.score - 0.3 * (Column("gene_tpm") < 1)

Because the result is numeric (not boolean), don't pass it straight to apply_filter — wrap it with a comparison, e.g. (... composite ...) >= 0.5.

Transforms

Expressions support arithmetic and mathematical transforms:

# Arithmetic
0.5 * Affinity.score + 0.5 * Presentation.score

# Gaussian CDF normalization -> maps to ~[0, 1]
Affinity.value.descending_cdf(mean=500, std=200)    # lower IC50 → higher output
Presentation.score.ascending_cdf(mean=0.5, std=0.3)  # higher score → higher output

# Logistic sigmoid (Vaxrank-compatible IC50 scoring)
# 1 / (1 + exp((x - midpoint) / width))
Affinity.logistic(midpoint=350, width=150)

# Other transforms
Affinity.value.clip(lo=1, hi=50000)    # clamp to range
Affinity.value.log()                    # natural log
Affinity.value.log10()                  # base-10 log
Affinity.value.sqrt()                   # square root
abs(Affinity.value)                     # absolute value
Affinity.value ** 2                     # power

Multi-model disambiguation

When using multiple prediction models that produce the same kind (e.g. both NetMHCpan and MHCflurry produce pMHC_affinity), qualify with bracket syntax:

from topiary import Affinity, Presentation

# Qualify by prediction method
Affinity["netmhcpan"] <= 500
Affinity["mhcflurry"].score
Presentation["mhcflurry"].rank <= 2

# Cross-model composite
score = (
    0.5 * Affinity["netmhcpan"].logistic(350, 150)
    + 0.5 * Affinity["mhcflurry"].logistic(350, 150)
)

When only one model produces a kind, no bracket is needed. Affinity <= 500 works automatically. If you use it with multiple models producing the same kind, you get a clear error:

ValueError: Ambiguous: multiple models produce pMHC_affinity
(mhcflurry, netmhcpan). Use Affinity["modelname"] to disambiguate.

A typo in the method name also gives a helpful error:

ValueError: No pMHC_affinity predictions from method matching 'netmhcapn'.
Available: ['mhcflurry', 'netmhcpan']. Did you mean: ['netmhcpan']?

Column() — arbitrary DataFrame columns

Column() reads any column from the predictions DataFrame, enabling peptide properties, variant metadata, or custom annotations as ranking signals:

from topiary.ranking import Column

Column("cysteine_count")
Column("hydrophobicity") >= -0.5
Column("n_alt_reads").sqrt()

# In a composite score
score = (
    0.5 * Affinity.logistic(350, 150)
    - 0.2 * Column("cysteine_count")
    + 0.1 * Column("tcr_aromaticity")
)

If the column doesn't exist, you get a clear error with close-match suggestions:

ValueError: Column 'hydrophobicty' not found in DataFrame.
Did you mean: ['hydrophobicity']?

Arithmetic and < / <= / > / >= comparisons require numeric values; using a non-numeric column in those contexts raises:

TypeError: Column 'gene_name' contains non-numeric value 'BRAF' (str).
Only numeric columns can be used in ranking expressions.

For categorical filtering on string columns (mhc_class, gene, source, ...) use the equality / membership methods described below.

Equality / membership on any dtype

Column("...").eq(value), Column("...").ne(value), and Column("...").isin(values) produce an IsIn node that reads the column raw — no float cast — so string, boolean, and mixed-dtype columns work:

from topiary import apply_filter, Affinity, Column

# Categorical equality
apply_filter(df, Column("mhc_class").eq("I"))
apply_filter(df, Column("gene").ne("HLA-A"))

# Membership
apply_filter(df, Column("mhc_class").isin(["I", "II"]))

# Compose with numeric clauses
apply_filter(
    df,
    (Affinity.value <= 500) & Column("mhc_class").eq("I"),
)

# Negate with ~
apply_filter(df, ~Column("source").isin(["control", "blacklist"]))

DSLNode.__eq__ is intentionally not overridden — Column("x") == "y" still does Python identity equality and won't compose. Always use .eq() / .ne() / .isin().

NaN handling matches pandas, not SQL: missing values evaluate to False for .eq() / .isin() and to True for .ne() / ~.eq() (the inverse). To exclude NaN explicitly, compose with the source-of-truth column — e.g. Column("mhc_class").ne("II") & Column("mhc_class").isin(["I", "II"]).

The string parser accepts string literals on the right-hand side of == and != (rejected with < / <= / > / >= since ordering on arbitrary strings isn't meaningful):

from topiary import parse

parse('mhc_class == "I"')
parse('affinity.value <= 500 & mhc_class != "II"')

For the two most common categorical filters, class_i and class_ii are pre-built shortcuts:

from topiary import apply_filter, Affinity, class_i, class_ii

apply_filter(df, class_i & (Affinity.value <= 500))
apply_filter(df, class_i | class_ii)   # both classes; identity filter

Both reference the mhc_class column. That column is present after topiary.read_pvacseq() (and any future loader that derives it); fresh TopiaryPredictor output doesn't ship a per-row mhc_class (class lives in kind_support at the model level), so derive it first if you need these on a freshly predicted DataFrame:

from topiary import derive_mhc_class
df["mhc_class"] = derive_mhc_class(df["allele"])

wt. — wildtype comparison

The wt. scope prefix reads wildtype prediction columns (wt_value, wt_score, wt_percentile_rank). For predict_from_fragments() and the variant-based APIs that build fragments internally, pass predict_wt=True to score each populated wt_peptide with the same MHC model(s):

from topiary import Affinity, TopiaryPredictor, wt

predictor = TopiaryPredictor(
    models=[...],
    alleles=[...],
    predict_wt=True,
)
df = predictor.predict_from_variants(variants)

# Read WT binding values (Python API — capitalized kind names)
wt.Affinity.value                         # wt_value column
wt.Affinity.score                         # wt_score column
wt.Affinity["netmhcpan"].score            # qualified WT

# Differential binding (mutant vs wildtype)
Affinity.score - wt.Affinity.score

# Logistic differential
Affinity.logistic(350, 150) - wt.Affinity.logistic(350, 150)

The string DSL uses lowercase kind names:

wt.affinity.value
wt.affinity.score
wt.affinity["netmhcpan"].score
affinity.score - wt.affinity.score

In the CLI, pass --predict-wt with variant-derived inputs before using wt.* in --sort-by:

topiary ... --predict-wt --sort-by "affinity.score - wt.affinity.score"

!!! note wt. is for sorting expressions only, not filters. Use it in sort_by, not in filter. When WT columns don't exist, expressions evaluate to NaN. Rows without a length-compatible WT peptide also keep NaN WT prediction values.

len and count() — peptide-level expressions

len reads the peptide length; count('C') counts amino acid occurrences in the peptide. Both compose with scope prefixes:

# String DSL
len                           # peptide length
count('C')                    # cysteine count
wt.len                        # wildtype peptide length
wt.count('C')                 # wildtype cysteine count
count('C') - wt.count('C')   # gained/lost cysteines vs wildtype
count('KR') >= 2              # filter: at least 2 basic residues

Method + version qualification

Affinity["netmhcpan"] filters to rows whose prediction_method_name contains the substring (case-insensitive). To disambiguate further, pass a tuple with an exact predictor_version:

Affinity["netmhcpan", "4.1b"].value    # only NetMHCpan v4.1b rows

In the string DSL, prefer these forms:

affinity[netmhcpan, 4.1b].value <= 500
affinity[netmhcpan, release-2.2.0].value <= 500
netmhcpan[4.1b]:affinity.value <= 500
netmhcpan-4.1b:affinity.value <= 500
wt.netmhcpan[4.1b]:ba.score

Use brackets for arbitrary version labels. The dash form is a compact shortcut for numeric-leading versions; the required :kind suffix keeps it distinct from ordinary subtraction.

Parsing strings

A single parse() function takes a DSL string and returns a DSLNode:

from topiary import parse, apply_filter

node = parse("affinity <= 500 | el.rank <= 2")
df = apply_filter(df, node)

parse handles the full grammar — arithmetic, comparisons, boolean combinators, transforms, aggregations, scoped fields.

String form (CLI)

The --filter-by flag and --sort-by flag accept string expressions:

Python DSLString form
Affinity <= 500affinity <= 500 or ba <= 500
Affinity.rank <= 2affinity.rank <= 2
Affinity.score >= 0.5affinity.score >= 0.5
Affinity["netmhcpan"] <= 500netmhcpan:affinity <= 500
Affinity["netmhcpan", "4.1b"].scorenetmhcpan[4.1b]:affinity.score or netmhcpan-4.1b:affinity.score
Presentation["mhcflurry"].rank <= 2mhcflurry:el.rank <= 2 or mhcflurry_el.rank <= 2
Column("cysteine_count") <= 2column(cysteine_count) <= 2
(A <= 500) | (B.rank <= 2)affinity <= 500 | presentation.rank <= 2
(A <= 500) & (B.rank <= 2)affinity <= 500 & presentation.rank <= 2

Recommended model-qualified syntax:

MeaningPrefer
Method-specific kindnetmhcpan:affinity.score
Method-specific aliasmhcflurry:ba.score, mhcflurry:el.rank
Method + arbitrary versionnetmhcpan[release-2.2.0]:affinity.score
Method + numeric-leading versionnetmhcpan-4.1b:affinity.score
Wildtype scopewt.netmhcpan[4.1b]:ba.score

Accepted aliases: ba / aff / ic50 = Affinity, el = Presentation. Canonical serialization still uses bracket form (affinity[netmhcpan]). The parser also accepts compatibility forms such as affinity:netmhcpan, netmhcpan.affinity, affinity[netmhcpan], and netmhcpan_ba, but new docs and configs should use the recommended forms above.

All features work in both Python and CLI string form (--sort-by):

  • Arithmetic: 0.5 * affinity.score + 0.5 * presentation.score
  • Transforms: .logistic(), .ascending_cdf(), .descending_cdf(), .clip(), .hinge(), .log()
  • Aggregations: mean(), geomean(), minimum(), maximum(), median()
  • column(x) in arithmetic, filters, and ranking
  • Scope prefixes: wt.affinity.score, wt.len, wt.count('C')

--sort-direction controls whether lower or higher values rank first. The default is auto: raw affinity values and percentile ranks sort ascending, while all other sort expressions sort descending.

Combining Separate Predictor Runs

Run predictors together when that is convenient:

from mhctools import NetMHCpan, MHCflurry
from topiary import TopiaryPredictor

combined = TopiaryPredictor(
    models=[NetMHCpan, MHCflurry],
    alleles=["HLA-A*02:01", "HLA-B*07:02"],
).predict_from_named_peptides(peptides)

When predictors need to run separately, use combine_predictions to turn their complementary prediction rows back into the same long-form shape:

from mhctools import NetMHCpan, MHCflurry
from topiary import TopiaryPredictor, combine_predictions

netmhcpan_rows = TopiaryPredictor(
    models=NetMHCpan,
    alleles=["HLA-A*02:01", "HLA-B*07:02"],
).predict_from_named_peptides(peptides)

mhcflurry_rows = TopiaryPredictor(
    models=MHCflurry,
    alleles=["HLA-A*02:01", "HLA-B*07:02"],
).predict_from_named_peptides(peptides)

combined = combine_predictions([netmhcpan_rows, mhcflurry_rows])

TopiaryResult owns the long/wide representation. Loaders may naturally produce wide results (for example LENS) or long results (for example pVACseq and fresh predictor outputs), but callers can use result.long_df, result.wide_df, result.to_long(), or result.to_wide() on demand. Topiary merge functions normalize those forms internally instead of making callers choose a representation before combining results.

You can also shard the same predictor over allele or peptide-length batches and combine the shards. Use TopiaryPredictor(name=...) when you want to keep track of which batch produced each row:

shards = []
for allele in ["HLA-A*02:01", "HLA-B*07:02"]:
    for length in [8, 9, 10, 11]:
        length_peptides = {
            name: peptide
            for name, peptide in peptides.items()
            if len(peptide) == length
        }
        shards.append(
            TopiaryPredictor(
                models=NetMHCpan,
                alleles=[allele],
                name=f"netmhcpan_{allele}_len{length}",
            ).predict_from_named_peptides(length_peptides)
        )

combined = combine_predictions(shards)

prediction_method_name is still the logical predictor name (netmhcpan in the example above). The optional prediction_run_name column is only provenance for a particular run or shard. That distinction lets distinct NetMHCpan allele/length shards combine into one logical NetMHCpan result, while overlapping shards with the same (prediction_method_name, kind, peptide, allele, sample/source context) still fail as duplicates. to_wide() drops prediction_run_name from the grouping keys, so a named split run has the same wide shape as a single unsplit run.

The helper is intentionally strict. It rejects duplicate (prediction_method_name, kind, identity) rows, and by default requires every emitted (prediction_method_name, kind) group to cover the same peptide/allele identity grid. This catches incomplete split runs before to_wide() can produce half-populated rows. If you intentionally want a sparse union, pass coverage="partial"; duplicate predictions are still rejected.

The combined result preserves the original rows: use each row's prediction_method_name, predictor_version, kind, and value/rank columns to inspect which predictor produced which quantity. Use prediction_run_name only to audit the batch that produced a row, not as a DSL selector.

Allele aggregation remains part of the ranking DSL: for example, Affinity["netmhcpan"].best_value_allele and Presentation["netmhcpan"].best_score_allele report the allele associated with the best BA or EL value across the combined allele grid. For predictors that emit one row per allele, such as NetMHCpan or MHCflurry in single-allele mode, this is the best per-allele row after all shards are combined. For MHCflurry presentation in haplotype mode, MHCflurry itself sees the allele set together and may emit one deconvolved best-allele row; combining independent single-allele MHCflurry shards is therefore not the same calculation as a direct haplotype-mode MHCflurry run. If you intentionally combine haplotype presentation rows with per-allele rows, use coverage="partial" because those kinds have different identity grids by construction. Processing-only quantities that do not depend on allele should be read directly rather than through best_*.

Putting it together

from topiary import (
    TopiaryPredictor, Affinity, Presentation, Column,
    apply_filter, apply_sort,
)
from topiary.properties import add_peptide_properties
from mhctools import NetMHCpan, MHCflurry

predictor = TopiaryPredictor(
    models=[NetMHCpan, MHCflurry],
    alleles=["HLA-A*02:01", "HLA-B*07:02"],
    only_novel_epitopes=True,
)

# Predict
from varcode import load_vcf
df = predictor.predict_from_variants(load_vcf("somatic.vcf"))

# Add peptide properties
df = add_peptide_properties(df, groups=["manufacturability", "immunogenicity"])

# Composite score
score = (
    # Binding (average across models)
    0.25 * Affinity["netmhcpan"].logistic(350, 150)
    + 0.25 * Affinity["mhcflurry"].logistic(350, 150)
    # Presentation
    + 0.2 * Presentation["mhcflurry"].score
    # Manufacturability
    - 0.05 * Column("cysteine_count")
    - 0.05 * Column("instability_index").clip(lo=0, hi=100).ascending_cdf(50, 20)
    # Immunogenicity
    + 0.05 * Column("tcr_aromaticity")
)

# Filter to plausible binders, then sort by composite score
df = apply_filter(df, (Affinity <= 500) | (Presentation.rank <= 2.0))
df = apply_sort(df, [score])