Skip to content

Add predict-table command to annotate CSVs with predictor scores (#231) - #234

Merged
iskandr merged 2 commits into
masterfrom
predict-table
Jul 9, 2026
Merged

Add predict-table command to annotate CSVs with predictor scores (#231)#234
iskandr merged 2 commits into
masterfrom
predict-table

Conversation

@iskandr

@iskandr iskandr commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Closes #231.

Adds a general, additive way to annotate an existing table with external predictor scores — no schema change for current users, no MHCflurry-specific assumptions.

Motivation

Downstream evaluation workflows (e.g. MHCflurry paper-figure generation) often start from an annotated benchmark table with columns like sample_id, hit, sample_group, peptide, and per-row genotype/allele info, and just need external predictor outputs (NetMHCpan, MixMHCpred, ...) appended as score columns. Until now callers needed a bespoke adapter to join mhctools' long CSV back to their table. This makes it a first-class feature.

What it does

mhctools predict-table reads a CSV, runs each requested predictor once, and appends one score column per predictor — choosing the best allele per row — while preserving every input column unchanged:

mhctools predict-table \
    --input benchmark.multiallelic.csv.bz2 \
    --peptide-column peptide \
    --alleles-column hla \
    --predictor netmhcpan42-ba:netmhcpan4.2.ba:affinity \
    --predictor netmhcpan42-el:netmhcpan4.2.el:score \
    --out benchmark.with_external.csv.bz2

Each --predictor spec is NAME[:OUTPUT_COLUMN[:FIELD]] (column defaults to NAME_FIELD, field defaults to affinity). FIELD is affinity, score, or percentile_rank — matching the issue's requested direction rules:

  • lower is better for affinity and percentile_rank
  • higher is better for score

For multi-allele rows (several alleles per cell, whitespace/comma/semicolon-separated) the best allele per peptide is chosen and recorded in a <OUTPUT_COLUMN>_best_allele provenance column. --predictor-info info.csv writes the optional sidecar the issue suggested (predictor, output_column, score_field, higher_is_better).

Design

  • Library-first, I/O-free. The core is annotate_table(df, specs, peptide_column, allele_column, ...) -> DataFrame, unit-testable with no binaries; the CLI is a thin CSV read/write wrapper. Matches mhctools' "there's a DataFrame variant of everything" convention.
  • One predictor run per spec, batched over the union of (peptide, allele) pairs across all rows (leverages the predictors' allele batching from Batch alleles per netMHCpan process for large speedups #218), then a per-row lookup reduces to the best allele. Predictors are not re-invoked per row.
  • Reuses existing primitives rather than re-deriving direction logic: "best" comes from mhctools.pred.best_direction, and row alleles are normalized with normalize_allele_name_or_raw (the Some non-human alleles netMHCpan lists (e.g. H-2-Qa1, BoLA-amani.1) can't be requested #220 helper), so a cell written A0201 matches a prediction emitted as HLA-A*02:01, and exotic un-normalizable alleles round-trip.
  • AnnotationSpec accepts either a built predictor or an alleles -> predictor factory, so commandline predictors get constructed with exactly the alleles the table needs.
  • Additive: the existing mhctools --output-csv long schema is untouched; predict-table is a new subcommand dispatched from cli/script.main.

Behavior notes / limitations

  • Every allele in the table must be supported by each predictor (commandline predictors raise UnsupportedAllele at construction otherwise — consistent with mhctools elsewhere). Rows whose (peptide, allele) a predictor simply drops get NaN.
  • affinity pins pMHC_affinity.value; score/percentile_rank are kind-agnostic. If a single predictor emitted multiple kinds that both carry score, the kind-agnostic fields don't disambiguate between them — the kind-pinned aliases (presentation, stability, ...) are the escape hatch. The documented predictors (BA→affinity, EL→score) are single-kind, so this doesn't arise in practice.

Tests

tests/test_annotate_table.py (added to the CI public subset) is binary-free: a deterministic fixture predictor lets it assert exact best-allele selection and direction handling (a case where the best allele by affinity differs from the best by score), plus column preservation/order, no-input-mutation, multi-allele cell splitting + normalization, allele-free path, collision/overwrite, NaN fallbacks, custom provenance column, and spec parsing. A RandomBindingPredictor smoke test covers the real predict path; the CLI round-trip (dispatch, multi-predictor, sidecar) was verified end-to-end with the random predictor.

Local: 413 passed, 3 skipped (TULIP e2e). Version 3.20.1 → 3.21.0.

https://claude.ai/code/session_01LZahFhBSCiehXTESCYQ7wG

iskandr added 2 commits July 9, 2026 13:56
Downstream evaluation workflows often start from an annotated benchmark table
(sample_id, hit, peptide, genotype/allele columns) and just need external
predictor scores appended. Add a general, additive way to do that.

Library core (mhctools/annotate.py):
  * annotate_table(df, specs, peptide_column, allele_column, ...) -> DataFrame.
    Runs each predictor ONCE over the union of (peptide, allele) pairs, then a
    per-row lookup picks the best allele. Preserves all input columns; appends
    one score column per predictor plus a <col>_best_allele provenance column.
  * "Best" reuses mhctools.pred.best_direction: score higher-better, affinity
    and percentile_rank lower-better.
  * Row alleles are normalized with normalize_allele_name_or_raw, so "A0201"
    matches a prediction emitted as "HLA-A*02:01" and exotic un-normalizable
    alleles round-trip (#220). Multiple alleles per cell (whitespace/comma/
    semicolon) are supported.
  * AnnotationSpec accepts a built predictor or an alleles->predictor factory,
    so commandline predictors are constructed with exactly the table's alleles.
  * parse_annotation_spec("NAME:COLUMN:FIELD") builds a spec from the CLI
    registry; COLUMN and FIELD default to NAME_FIELD and "affinity".

CLI (mhctools/cli/annotate_table.py): thin I/O wrapper exposed as the
`mhctools predict-table` subcommand (dispatched from cli/script.main). Reads
CSV/CSV.bz2, writes the annotated table, and optionally a --predictor-info
sidecar (predictor, output_column, score_field, higher_is_better).

Exports annotate_table / AnnotationSpec / parse_annotation_spec from the
package. Binary-free tests use a deterministic fixture predictor to assert
direction handling, best-allele selection, column preservation, collision/
overwrite, NaN fallbacks, and spec parsing (added to the CI public subset).

Version 3.20.1 -> 3.21.0.

Claude-Session: https://claude.ai/code/session_01LZahFhBSCiehXTESCYQ7wG
…ew fixes)

Fixes two issues from the #234 review:

  * Cross-spec output-column collision: the pre-run check only compared each
    spec against the *existing* input columns, so two specs targeting the same
    output (or best-allele) column passed and then silently clobbered each
    other. Now also reject a column planned by more than one spec, regardless
    of `overwrite` (overwrite replaces an existing column; it can't make two
    specs coexist in one column).

  * Peptide join was exact-string: a predictor that upper-cases/strips the
    peptides it echoes, against a table written lower-case or with stray
    whitespace, produced silent all-NaN. Peptides are now stripped +
    upper-cased on both the union sent to the predictor and the per-row lookup
    key (amino-acid sequences are canonically upper-case, so nothing is lost;
    the input peptide column is preserved verbatim).

Also make the allele-free (by-peptide) fallback fire whenever the
(peptide, allele) lookup misses, not only for rows with no alleles, so a
processing/allele-free predictor still fills in scores when the table happens
to carry an allele column. Only allele-free predictors populate the by-peptide
index, so this never rescues a binding predictor's unsupported-allele miss
(covered by a regression test).

Adds 9 tests: cross-spec duplicate output/best-allele/cross-name collisions
(incl. under overwrite), case- and whitespace-insensitive peptide matching
with the input column preserved, the allele-free by-peptide path (with and
without an allele column), and the binding-predictor unsupported-allele NaN
regression guard. 33 passing.

Claude-Session: https://claude.ai/code/session_01LZahFhBSCiehXTESCYQ7wG
@iskandr
iskandr merged commit f2694e3 into master Jul 9, 2026
5 checks passed
@iskandr
iskandr deleted the predict-table branch July 9, 2026 18:23
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.

Add a table-annotation command for external predictor outputs

1 participant