Skip to main content

Polars expression plugin for MHC Class I profile-HMM classification, reproducing histo_hmm's outputs

Project description

polars-mhci-hmm

CI PyPI Python Licence

Classify MHC Class I protein sequences inside Polars.

A native Polars expression plugin that reproduces histo_hmm's classifier — the same 251 trained profile HMMs, the same Viterbi recursion, the same outputs — with the scoring kernel in Rust so it runs inside the query engine, across every core, without the GIL.

import polars as pl
import polars_mhci_hmm  # registers the .mhci namespace

df.with_columns(result=pl.col("sequence").mhci.classify())

Install

uv add polars-mhci-hmm
# or
pip install polars-mhci-hmm

The 251 trained models ship inside the wheel (0.5 MB); there is nothing to download or train.

Usage

classify() returns a single struct column, mirroring histo_hmm's ClassificationResult:

>>> df = pl.DataFrame({"sequence": [hla_a_seq]})
>>> df.select(pl.col("sequence").mhci.classify(n_top=3)).to_series()[0]
{'is_class_i': True,
 'confidence': 1.0,
 'best_score': 740.8816401083320,
 'region_start': 0,
 'region_end': 273,
 'top_loci': [{'locus': 'hla_a', 'probability': 0.8783894...},
              {'locus': 'mafa_b', 'probability': 0.0179271...},
              {'locus': 'mamu_b', 'probability': 0.0176760...}]}

Unpack it with .struct.field(), or unnest it:

df.with_columns(
    is_class_i=pl.col("sequence").mhci.classify().struct.field("is_class_i"),
    locus=pl.col("sequence").mhci.classify(n_top=1).struct.field("top_loci"),
)

# One row per (sequence, locus) prediction:
(
    df.select("id", pl.col("sequence").mhci.classify(n_top=5).struct.field("top_loci"))
      .explode("top_loci")
      .unnest("top_loci")
)
field dtype meaning
is_class_i Boolean best_score >= threshold
confidence Float64 sigmoid of the best log-odds, clamped to ±50
best_score Float64 raw log-odds of the top-scoring locus
region_start UInt32 start of the detected MHC region
region_end UInt32 end of the region, exclusive
top_loci List(Struct{locus, probability}) the best n_top loci, best first

Arguments

pl.col("sequence").mhci.classify(
    n_top=10,               # how many loci to return
    scan_constructs=True,   # scan long sequences for an embedded MHC region
    threshold=0.0,          # log-odds threshold for the is_class_i call
    model_dir=None,         # a custom directory of models, in histo_hmm's format
)

probability values are temperature-scaled softmax over all 251 loci, so they sum to 1.0 across the full set — not across top_loci. Ask for n_top=251 and they sum to one.

Single-locus scores

score() gives the raw log-odds against one locus, mirroring ProfileHMM.log_odds_score:

df.with_columns(
    hla_a=pl.col("sequence").mhci.score("hla_a"),
    h2_k=pl.col("sequence").mhci.score("h2_k"),
)

Positive means the sequence is more likely under that locus' model than under the background. Unlike classify(), there is no sliding-window scan — the whole sequence is scored.

Which loci?

>>> polars_mhci_hmm.loci()
shape: (251, 2)
┌────────┬────────┐
 locus   length 
 ---     ---    
 str     u32    
╞════════╪════════╡
 aole_f  275    
 aotr_g  275    
              
└────────┴────────┘

Human (hla_*), mouse (h2_*), macaque (mamu_*, mafa_*), cow, pig, chicken, salmon and more — 251 loci across the vertebrates.


Things worth knowing

region_start/region_end index the cleaned sequence

Both this plugin and histo_hmm clean an input before scoring it: uppercase, then keep only the 20 amino acids and X. Everything else — gaps, *, and the ambiguity codes B/Z/J — is dropped, which shortens the sequence. The region offsets refer to that cleaned string, so if your input has gaps or punctuation they will not line up with it. Clean your sequences yourself if you need offsets into the original.

X is the exception: it survives cleaning and is scored with a uniform emission, so it shortens nothing but does change the score.

Long sequences are scanned, and it is expensive

A sequence longer than ~370 residues is assumed to be a construct with MHC embedded in it, and is scanned with sliding windows of 200–320 residues. That means scoring ~104 windows against all 251 loci — around 26,000 Viterbi passes for one sequence. It is the right answer for a fusion protein and a waste for a long non-MHC sequence. Pass scan_constructs=False to always score the whole sequence.

Nulls and empty sequences

A null sequence classifies to a null struct — the absence of a sequence is not a classification. A sequence with no scorable residues ("", "---", "123") reproduces the reference's degenerate result: is_class_i=False, confidence=0.0, top_loci=[], best_score=-inf, region=[0, 0].

Numerical fidelity

Model scores reproduce histo_hmm exactly: Viterbi is max-plus, so there is no summation to reorder.

Two steps do sum — the null score and the softmax denominator — and histo_hmm sums them with numpy's pairwise np.sum where this plugin sums sequentially. That, plus an occasional 1-ULP difference in exp, puts the two implementations about 1e-12 apart on log-odds of magnitude ~700, and about 1e-16 apart on probabilities. The test suite asserts is_class_i, the region and the ordering of loci exactly, and the floats to 1e-9 / 1e-12.

Reimplementing numpy's internal pairwise summation would close the last few ULPs, at the cost of pinning this package to an implementation detail that is not part of numpy's API. That trade wasn't worth it. See PLAN.md §5.1.


Performance

Each sequence is scored against all 251 profile HMMs — about 190 MFLOP of Viterbi. histo_hmm's kernel is already numba-JIT compiled, so this is not a compiled-versus-interpreted story: the win is parallelism, plus a cache-friendly parameter layout, plus not paying Python's per-row cost.

Measured on a 16-core x86_64 machine, classifying real MHC Class I sequences against all 251 loci. The machine was under other load, and repeat runs varied between roughly 14× and 17×, so treat these as a floor rather than a headline:

case histo_hmm polars-mhci-hmm
full-length (~275 aa) 160 ms/seq (6.3 seq/s) 11 ms/seq (90 seq/s) ~14×
construct, 415 aa (scan path) 19.1 s 1.4 s ~14×
score() over a column, one locus ~24,000 seq/s

The single-model Viterbi kernel is ~0.68 ms against numba's ~0.80 ms, so only a small part of the win is the kernel itself — most of it is using more than one core.

Reproduce with uv run python tools/validate.py, which writes the numbers and a full parity comparison into tmp/.

The two things that mattered most, in case they are useful elsewhere:

  • Transposing the model parameters. numpy stores emissions as (L+1) × 20. A Viterbi row sweeps model positions with the residue fixed, so that layout strides by 20 f64 and touches a fresh cache line every step. Stored 20 × (L+1), each sweep is one contiguous run.
  • Letting the inner loops vectorise. The match and insert recursions depend only on the previous row, so they have no loop-carried dependency — but LLVM only vectorises them once every operand is sliced to a common length and the bounds checks fall away. (The delete chain is inherently sequential; it stays scalar.)

Building from source

Needs a Rust toolchain.

git clone https://github.com/drchristhorpe/polars_mhci_hmm
cd polars_mhci_hmm
uv sync
uv run maturin develop --release
uv run pytest

uv sync installs histo_hmm from git as a dev dependency, which is what the parity tests compare against. uv run pytest -m "not slow" skips the construct-scan parity test, the only slow one — it is slow because it runs the reference's scanner.

histo_hmm needs Python ≥ 3.12, while this package supports ≥ 3.10. On 3.10 or 3.11 it is simply left out of the resolution and the parity tests skip; the golden tests still run, so the suite stays meaningful. Use 3.12+ if you want parity.

Releases are automated — push a v* tag. See RELEASING.md.

To re-sync the models after retraining them in histo_hmm:

uv run python codegen/vendor_models.py --histo-hmm ../histo_hmm
uv run python tools/make_golden.py   # regenerate the checked-in expectations

Please cite histo_hmm

This package is a fast reimplementation of histo_hmm's classifier, not an independent piece of work. The models it ships were trained by histo_hmm, the semantics it implements are histo_hmm's, its behaviour is what the test suite asserts against, and every subtlety polars-mhci-hmm gets right is right because histo_hmm worked it out first.

If you use this package, please cite histo_hmm. polars-mhci-hmm deliberately does not offer itself as an alternative citation.

See also

  • histo_hmm — the reference implementation, plus the training pipeline that produces the models. Train there; classify here.
  • polars-seq — DNA/RNA → protein translation in Polars, whose layout this project follows.

Licence

MIT. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

polars_mhci_hmm-0.1.1.tar.gz (391.3 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

polars_mhci_hmm-0.1.1-cp310-abi3-win_amd64.whl (4.8 MB view details)

Uploaded CPython 3.10+Windows x86-64

polars_mhci_hmm-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (4.3 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ x86-64

polars_mhci_hmm-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (4.0 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ ARM64

polars_mhci_hmm-0.1.1-cp310-abi3-macosx_11_0_arm64.whl (3.9 MB view details)

Uploaded CPython 3.10+macOS 11.0+ ARM64

polars_mhci_hmm-0.1.1-cp310-abi3-macosx_10_12_x86_64.whl (4.2 MB view details)

Uploaded CPython 3.10+macOS 10.12+ x86-64

File details

Details for the file polars_mhci_hmm-0.1.1.tar.gz.

File metadata

  • Download URL: polars_mhci_hmm-0.1.1.tar.gz
  • Upload date:
  • Size: 391.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for polars_mhci_hmm-0.1.1.tar.gz
Algorithm Hash digest
SHA256 3970fdc9801508352caee2cb9d6b9bf1e2115fad6d96adfa9795c740309d62f6
MD5 79d4c46eabb9b6a45b972aac4b02ec2f
BLAKE2b-256 81a1f462ad45676149522e9b5742dcb9c38b5e11479df1d57f5ff8ca7be4025d

See more details on using hashes here.

Provenance

The following attestation bundles were made for polars_mhci_hmm-0.1.1.tar.gz:

Publisher: release.yml on drchristhorpe/polars_mhci_hmm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file polars_mhci_hmm-0.1.1-cp310-abi3-win_amd64.whl.

File metadata

File hashes

Hashes for polars_mhci_hmm-0.1.1-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 a5fc47be8101cec723bd4d1b8cb5b0d299ea413320e588ff7a0c86b87a028199
MD5 d641569d4234a0b4996e69fc5fcc5f8b
BLAKE2b-256 2c8e9f62b03ce3248b289999fbbe51752658f07b09c647d63e1975da22575b6c

See more details on using hashes here.

Provenance

The following attestation bundles were made for polars_mhci_hmm-0.1.1-cp310-abi3-win_amd64.whl:

Publisher: release.yml on drchristhorpe/polars_mhci_hmm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file polars_mhci_hmm-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for polars_mhci_hmm-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 41e60cd7b62d344870614e99ab6b1d35134beb6ad24f6301224c285a6aedb209
MD5 fb01afeff5d33cd413799f8e65c63532
BLAKE2b-256 bdffee190f4e326cb7cc840964eae3c5fc3ed1f3efbe98aa3c8f006ef1b0dece

See more details on using hashes here.

Provenance

The following attestation bundles were made for polars_mhci_hmm-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: release.yml on drchristhorpe/polars_mhci_hmm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file polars_mhci_hmm-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for polars_mhci_hmm-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 d1f693bc9553e30a4e27e6500c12e1cd82a6c7f63a18b27ab18cc82a8322820b
MD5 07e847bd49b3f83334e5b2d748bac443
BLAKE2b-256 6c9872e8fead69385f818ea5b226b985cf3e603ca9bd7d561395ba86c17a8f61

See more details on using hashes here.

Provenance

The following attestation bundles were made for polars_mhci_hmm-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: release.yml on drchristhorpe/polars_mhci_hmm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file polars_mhci_hmm-0.1.1-cp310-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for polars_mhci_hmm-0.1.1-cp310-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 559bb3dcf7835ce19ce188ff7da9133bca29c9f313ec9397e495e24a698dd34d
MD5 69a6c41c7fa8ddc5b9dbba9c072030d1
BLAKE2b-256 a01382c2250ff9a04618042f579627eb3c040afd4d5c4954d46e6969ccc540f4

See more details on using hashes here.

Provenance

The following attestation bundles were made for polars_mhci_hmm-0.1.1-cp310-abi3-macosx_11_0_arm64.whl:

Publisher: release.yml on drchristhorpe/polars_mhci_hmm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file polars_mhci_hmm-0.1.1-cp310-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for polars_mhci_hmm-0.1.1-cp310-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 cdcef3d64884f74a382cd606ce863d11516f573f689e5f56528c5ef232cccf60
MD5 066514b7ab4bfbfae733afdd9e849e4b
BLAKE2b-256 5faf003dc7e7a944cb110fc8812033e14033dd0a50d09063b9e3bf5b1d1a3f6a

See more details on using hashes here.

Provenance

The following attestation bundles were made for polars_mhci_hmm-0.1.1-cp310-abi3-macosx_10_12_x86_64.whl:

Publisher: release.yml on drchristhorpe/polars_mhci_hmm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page