Practical uses of the 2024 and 2026 CMV ECOclusters

Tools and tips for diagnosing CMV and HLA allele status and annotating repertoire TCRs
Published

September 8, 2026

Over the posts in this series, I’ve explored the 2026 CMV ECOcluster and, to some extent, the 2024 version. I’ve pointed out some interesting quirks and explored how best to bring these ECOclusters to bear on TCR repertoires.

This post summarizes those observations and gives you some practical tools and advice for using the 2026 CMV ECOcluster for CMV and HLA diagnosis, and for combining the two ECOclusters and using them to annotate TCR repertoires. Each section points back to an earlier post where you can find more detail.

1 Diagnose CMV (Post 5)

You can use the 2026 CMV ECOcluster to predict CMV status from a repertoire just by dividing the number of ECOcluster-matched TCRs in a repertoire by the total number of repertoire TCRs. I built a simple classifier into the CMVividly codebase, using simple repertoire breadth on the 2026 CMV ECOcluster. Here’s the ROC curve for that breat vs. CMV label, on both Emerson 2026 cohorts combined:

Code
import pandas as pd
from cmvividly.dx.cmv_dx import CMV_BREADTH_ROC_FPR, CMV_BREADTH_ROC_TPR
from matplotlib import pyplot as plt
import seaborn as sns
from cmvividly.plots.style import set_style
set_style()

f, ax = plt.subplots()
sns.lineplot(x=CMV_BREADTH_ROC_FPR, y=CMV_BREADTH_ROC_TPR, ax=ax)
ax.set_title("CMV breadth ROC on both Emerson cohorts")
ax.set_ylabel("TPR")
ax.set_xlabel("FPR")
f.set_size_inches(5,5)

The classifier takes a pandas dataframe that must have a column called tcr with the TCRB sequence in the Adaptive format (CDR3AA+Vgene+Jgene, and the genes must be in Adaptive format, too… tcrconvert is a handy utility for converting between formats). Here’s an example of running it on one of the Emerson repertoires.

Code
from cmvividly.dx.cmv_dx import BreadthCmvClassifier

# instantiate a classifier
breadth_cmv_classifier = BreadthCmvClassifier()

# Here I'm dummying up a mostly fake repertoire for donor P00089.
# I've saved down the intersection of
# the 2026 ECOcluster with repertoire P00089.
# UPR_P00089 is the number of rows in that repertoire, so I'm dummying
# up the missing rows, which don't affect the classifier
from cmvividly.data.access import load_cmv_ecocluster_intersect_emerson
pdf_tcr_matches_2026 = load_cmv_ecocluster_intersect_emerson()
UPR_P00089 = 347237
sample_name = "P00089"
pdf_fake_P00089 = pdf_tcr_matches_2026[pdf_tcr_matches_2026.sample_name == sample_name]
pdf_fake_P00089["tcr"] = pdf_fake_P00089.tcr_ecocluster
n_rows_to_dummy = UPR_P00089 - len(pdf_fake_P00089)
pdf_fake_P00089 = pd.concat([pdf_fake_P00089, pd.DataFrame({"sample_name": [sample_name] * n_rows_to_dummy})])

cmv_breadth_P00089 = sum(pdf_tcr_matches_2026.sample_name == sample_name) / UPR_P00089

result = breadth_cmv_classifier.classify_repertoire(pdf_fake_P00089)
print(f"CMV prediction for {sample_name}:")

import json
print(json.dumps(result, indent=4))
CMV prediction for P00089:
{
    "prediction": 1,
    "prediction_confidence": 0.9418181818181818,
    "n_matches": 364,
    "total_tcrs": 347237,
    "breadth": 0.0010482753853995974
}

The prediction_confidence returned by the classifier is the probability that the call is correct, using the predictions and labels on the two Emerson cohorts: PPV for positive predictions, NPV or negative predictions. The threshold is the maximum-F1 point (“upper-left-most point”) of the ROC curve.

As noted in Post 5, the performance of the classifier varies somewhat by donor HLA type, creating observable differences by donor race. And the classifier comes with a lot of caveats: it’s only been validated on these two cohorts, relatively high-depth PBMC Adaptive bulk TCRB sequencing from a mix of Caucasian and Asian donors.

Note: the folks who built CMVerify essentially turned the 2024 CMV ECOcluster into a CMV classifier, too. If you’re dealing with single-cell RNA sequencing, CMVerify is a lot better characterized than this classifer is.

2 Predict HLA type for CMV+ donors (Post 5)

Individual HLA-COclusters in the CMV ECOcluster can be used to predict donor HLA allele status from TCR repertoires from (known or predicted) CMV+ donors for 12 HLA-A and B alleles with high sensitivity and specificity. I’ve built simple breadth-based models (just like the CMV classifier above) using the ROC curves derived in Post 5, and then a single meta-model to run them all.

Here’s and example of running them all on one donor:

Code
from cmvividly.dx.hla_dx import AllHlaAlleleClassifierCmvPos

all_hla_classifier = AllHlaAlleleClassifierCmvPos()

print("HLA allele predictions for CMV+ donor P00089:")
all_hla_classifier.classify_cmvpos_repertoire(pdf_fake_P00089)
HLA allele predictions for CMV+ donor P00089:
hla prediction prediction_confidence n_matches total_tcrs breadth
0 A*01:01 0 1.000000 4 347237 0.000012
1 A*02:01 0 1.000000 0 347237 0.000000
2 A*03:01 0 0.966480 1 347237 0.000003
3 A*11:01 0 0.969027 2 347237 0.000006
4 A*31:01 0 1.000000 0 347237 0.000000
5 A*68:01 1 1.000000 6 347237 0.000017
6 B*08:01 0 1.000000 0 347237 0.000000
7 B*15:01 0 0.980583 2 347237 0.000006
8 B*18:01 0 1.000000 0 347237 0.000000
9 B*35:01 0 0.900000 2 347237 0.000006
10 B*38:01 0 1.000000 0 347237 0.000000
11 B*51:01 1 0.937500 4 347237 0.000012

Never run those classifiers on CMV- donors! They’ll behave unpredictably (likely give almost all negative calls). And they come with the same caveats as the CMV classifier: only evaluated on high-depth bulk Adaptive bulk sequencing.

3 Combining the 2024 and 2026 ECOclusters into one dataset

As I explored in Post 3, the 2026 CMV ECOcluster has twice as many TCRs as 2024. It’s much more Class II-dominated, and it’s clearly missing many valuable TCRs from the 2024 version. So, to bring these ECOclusters to bear on TCR repertoires, I recommend combining them.

I wrote a wrapper function to load the 2024 version, load the 2026 version, and combine them: load_combined_2024_2026_cmv_ecoclusters. That function:

  • Drops TCR duplicates from the 2024 version (the 2026 version doesn’t have duplicates)
  • Adds columns describing which ECOcluster version(s) each TCR came from and what HLA allele it was associated with
  • Accepts the 2026 HLA association when the two disagree
  • Writes the combined ECOcluster to data/processed/cmv_ecocluster_combined_2024_2026.tsv
Code
# useful imports
import numpy as np
import pandas as pd
from matplotlib import pyplot as plt

import itables
import seaborn as sns
from cmvividly.plots.style import set_style
set_style()

import itables
itables.init_notebook_mode()

from cmvividly.data.access import load_combined_2024_2026_cmv_ecoclusters

pdf_cmv_ecocluster_merged = load_combined_2024_2026_cmv_ecoclusters()

print(f"TCRs in merged ECOcluster: {len(pdf_cmv_ecocluster_merged)}")
print("Columns: ")
print(", ".join(pdf_cmv_ecocluster_merged.columns))
print("TCR breakdown by HLA class and ECOcluster version:")
pd.crosstab(pdf_cmv_ecocluster_merged.ecocluster_source, pdf_cmv_ecocluster_merged.hla_class)
TCRs in merged ECOcluster: 66024
Columns: 
tcr, cdr3, vgene, jgene, hla, hla_class, hla_cocluster, ecocluster_source, hla_2024, hla_class_2024, hla_cocluster_2024, hla_2026, hla_class_2026, tcr_pgen, log10_tcr_pgen_eps, hla_cocluster_2026, hla_cocluster_npos_hlamatch, hla_cocluster_nneg_hlamatch, hla_cocluster_auroc_hlaaware, hla_cocluster_auroc_hlaunaware, hla_agree_2024_2026, hla_class_agree_2024_2026
TCR breakdown by HLA class and ECOcluster version:
hla_class ci cii
ecocluster_source
2024_and_2026230710222
2024_only69406637
2026_only301636902

I also just saved the combined TSV here so you can download it directly.

When using this TSV to annotate repertoires, if possible you should do the match in an “HLA-aware” fashion: filter the matches to just the TCRs associated with HLA alleles the donor is known (or predicted) to have. That will remove what tends to be just a smattering of “HLA-mismatched” TCR matches.

4 Several groups of V genes (and one pair of J genes) should be treated as one when matching TCRs (Post 4)

In the CMV ECOcluster, we often see the same CDR3 with different V or J genes. Those mismatches are highly concentrated in a small number of groups of V genes and a single pair of J genes, nearly always in the same V or J family. That strongly suggests that TCRs in the CMV ECOcluster that have those often-“confused” V and J genes are performing the same role in the same binding solution.

I made a library method (combine_indistinguishable_genes) to make it easy to match the CMV ECOcluster to a repertoire that way: just run combine_indistinguishable_genes on both the ECOcluster dataframe and the repertoire dataframe, then match on the new tcr column in both dataframes (both columns must be Adaptive-format TCRs).

5 Annotate the CMV ECOcluster TCRs with IEDB (Post 7)

I demonstrated that the CMV ECOcluster TCR intersection with IEDB (on V family + CDR3) is strongly enriched for CMV-annotated TCRs. I consider these peptide associations to be far more valid for repertoire annotation than most of IEDB: the TCRs have two orthogonal associations with CMV (experimental and statistical), and the ECOcluster association implies that, when the TCR is observed in a repertoire, it is usually responding to the CMV peptide and not some other peptide.

Those annotations could be propagated among sequence clusters, but of course you’d need some notion of sequence neighbors that’s relevant to TCR-pMHC binding….

6 Make “fuzzy” matches between ECOcluster TCRs and repertoire TCRs (Post 6)

A single CDR3 amino acid swap, with the same V and J gene, is often thought of as the “smallest possible” change to a TCR, unlikely to change TCR binding. I explored those single swaps within the CMV ECOcluster and showed that they seem to be most tolerated at (0-based) position 4. Or, more accurately, at the first position after the positions determined by the TCR’s V gene.

Limiting “fuzzy” matches to that position, and to the pairs of amino acids most often observed to be swapped in the CMV ECOcluster, could cut down on the rate of false matching while retaining most “real” matches.