Which model do I need?
A BEAM model describes whatever set of cells it is built from. Choosing that set is the first decision of any analysis, and it decides which questions the model can answer.
Describes one sample or one person. Compare samples, follow a person over time, plan the depth for this sample.
Describes a cell type, a condition or a cohort. Compare groups, ask which group a sequence resembles, predict sharing within the group.
Generalises over all the data given, like the reference model built from 38,538 repertoires. The baseline for "how likely in people in general".
One model per repertoire
Section titled “One model per repertoire”Built from one sample, a model describes that sample: one person, one tissue, one time point. This is the model to use when the unit of comparison is the sample:
- comparing two samples, or many (distances and clustering);
- following one person over time, or matching samples of the same person;
- planning the depth for this kind of sample.
A model of one sample describes its common sequences from their counts and its rare ones through the prior; the more cells it is built from, the more detail it holds.
One model per group
Section titled “One model per group”A group is any set of repertoires that belong together: a cell type (CD4, CD8, naive, memory), a tissue, a condition, a patient cohort. Pool the cells of all its repertoires and build one model. It describes the group as a whole, averaged over its members, and it is more detailed than any one member’s model because it has seen more cells.
import numpy as npimport BEAMfrom BEAM import BeamModel
reference = BEAM.load_pretrained("human_tcrb_cdr3_aa_foundation") # 38,538 repertoires: the population# donors: three repertoires from one group (stand-in population B: longer CDR3s)per_repertoire = [BeamModel(cells) for cells in donors] # one model per repertoiregroup = BeamModel([s for cells in donors for s in cells]) # the group's cells, pooled
def long_share(model): """P(length >= 17) under a model.""" return sum(p for L, p in model.length_distribution.items() if L >= 17)
for name, model in [("donor 1", per_repertoire[0]), ("donor 2", per_repertoire[1]), ("donor 3", per_repertoire[2]), ("group", group), ("reference", reference)]: print(f"{name:10} P(length >= 17) = {long_share(model):.3f} cells counted: {model.total_weight:>15,.0f}")donor 1 P(length >= 17) = 0.430 cells counted: 30,000donor 2 P(length >= 17) = 0.426 cells counted: 30,000donor 3 P(length >= 17) = 0.426 cells counted: 30,000group P(length >= 17) = 0.427 cells counted: 90,000reference P(length >= 17) = 0.115 cells counted: 5,629,439,400The stand-in group here has longer CDR3s than the population (stand-in population B). Each donor’s model shows it, the pooled group model shows it with more data behind it, and the reference model, built from everyone, does not.
Use a group model to:
- compare groups directly, for example CD8 against CD4 (Comparing groups);
- ask which group a sequence or repertoire resembles, by scoring it under each group’s model;
- predict sharing within the group, with the group model as the population.
Pooling the cells and building, or building per repertoire and adding the models up, give the same model:
import numpy as npfrom BEAM import BeamModel# cells_1, cells_2: two repertoires of one grouppooled = BeamModel(cells_1 + cells_2) # build from all the cellsmerged = BeamModel(cells_1).merged(BeamModel(cells_2)) # or add up two built models
difference = np.abs(pooled.log_pseq_batch(probe) - merged.log_pseq_batch(probe)).max()print(f"largest difference in log probability over 1,000 sequences: {difference:.1e}")largest difference in log probability over 1,000 sequences: 0.0e+00So group models can be assembled from per-repertoire models later, without going back to the raw data.
One reference over everything
Section titled “One reference over everything”Pool every repertoire you have, of every group, and the model generalises over all of them. The reference model shipped with BEAM is this level for human TCRβ: 38,538 repertoires from 447 studies. It is the baseline for “how likely is this sequence in people in general”, and the population for sampling and publicness calculations when nothing more specific is known.
You can build your own reference the same way, for example over all repertoires of one species, one chain or one sequencing platform.
Leaving a cohort out
Section titled “Leaving a cohort out”When you evaluate a cohort against a reference that contains it, the cohort’s own sequences
look more probable than they would to an independent reference. without removes a cohort’s
counts from a model; the result is the model built without the cohort. The example uses a
stand-in corpus of 13 repertoires, three of which form the cohort; how much the cohort’s
sequences gain depends on how large a share of the corpus the cohort is, and is far smaller in
a reference of 38,538 repertoires:
import numpy as npfrom BEAM import BeamModel# corpus_cells: the repertoires a reference is built from; cohort_cells: your cohort,# which is part of that corpusreference = BeamModel(corpus_cells + cohort_cells)cohort = BeamModel(cohort_cells)
held_out = reference.without(cohort) # the reference with the cohort's counts removedrebuilt = BeamModel(corpus_cells) # the same thing, built from scratch
cohort_seqs = sorted(set(cohort_cells))difference = np.abs(held_out.log_pseq_batch(cohort_seqs) - rebuilt.log_pseq_batch(cohort_seqs)).max()print(f"largest difference from rebuilding without the cohort: {difference:.1e}")gain = np.mean(reference.log_pseq_batch(cohort_seqs) - held_out.log_pseq_batch(cohort_seqs))print(f"mean log P of the cohort's sequences is {gain:.2f} nats higher when the cohort is included")largest difference from rebuilding without the cohort: 0.0e+00mean log P of the cohort's sequences is 0.75 nats higher when the cohort is includedWith the reference model this is BEAM.load_pretrained("human_tcrb_cdr3_aa_foundation").without(cohort), when your
cohort is one of the studies it was built from; the paper evaluated its predictions for the
Emerson cohort this way. The subtraction is only right if the cohort’s counts are exactly the
counts the reference holds for it: the same filtered sequences (3 to 27 standard amino acids)
and the same weights (template counts where the platform reported them, otherwise one per
distinct sequence per repertoire). Build the cohort’s counts with these rules, or pass them
directly with without(sequences, abundances=...).
Merging counts or mixing models
Section titled “Merging counts or mixing models”Models of several repertoires or groups can be combined in two ways, and the two describe different things.
Merging adds the members’ counts and builds one model from the total: the same model as
building from all their cells at once (a.merged(b), above). Every row
of each expert then holds the members’ counts together,
Each row sees only a position and a few residues of context, so the merged model learns the members’ local patterns and recombines them: it can start a sequence the way one member does and end it the way another does.
A mixture keeps the members’ models whole. With weights that add up to one (equal by default),
pick member with probability , then draw a whole sequence from that member’s model. A sequence that no member writes stays unlikely.
Each row sees only a position and a few residues, so the model joins one group's start to the other's end.
P(s) = ½ PA(s) + ½ PB(s): a sequence neither group writes stays unlikely.
Two groups that differ at both ends show the difference:
import numpy as npfrom BEAM import BeamModel, Mixture# group_a: 20,000 CDR3s that start CASS and end YEQYF# group_b: 20,000 CDR3s that start CSA and end TEAFFmerged = group_a.merged(group_b) # the counts added: one modelmixture = Mixture([group_a, group_b]) # pick a group (1/2 each), then a sequence from it
print("sequence group A group B mixture merged")for s in ["CASSLGQGYEQYF", "CSARGNTEAFF", "CASSLGQGTEAFF"]: print(f"{s:16} " + " ".join(f"{m.pseq(s):8.1e}" for m in (group_a, group_b, mixture, merged)))
# How much of the merged model's probability falls on sequences that start like one group# and end like the other, summed over every lengthdef share(model, start, end): total = 0.0 for L, pi in model.length_distribution.items(): if L >= len(start) + len(end): clamp = {**dict(enumerate(start)), **{L - len(end) + i: x for i, x in enumerate(end)}} total += pi * np.exp(model.log_clamp_probability(L, clamp)) return totalmixed = share(merged, "CASS", "TEAFF") + share(merged, "CSA", "YEQYF")print(f"\nmerged model: {mixed:.1%} of its probability starts like one group and ends like the other")
# Diversity: Hill number of order 2, the inverse chance that two draws are the same sequenceprint(f"D2: group A {group_a.hill_number(2):,.0f}, group B {group_b.hill_number(2):,.0f}, " f"mixture {mixture.hill_number(2):,.0f}, merged {merged.hill_number(2):,.0f}")beta = mixture.beta_diversity(2.0)print(f"beta diversity of the mixture: {beta.beta:.2f} (gamma {beta.gamma:,.0f} / alpha {beta.alpha:,.0f})")sequence group A group B mixture mergedCASSLGQGYEQYF 4.9e-04 4.1e-31 2.5e-04 1.7e-04CSARGNTEAFF 1.1e-32 9.3e-04 4.7e-04 3.0e-04CASSLGQGTEAFF 2.8e-19 2.6e-15 1.3e-15 6.2e-05
merged model: 47.5% of its probability starts like one group and ends like the otherD2: group A 44,304, group B 38,210, mixture 82,063, merged 127,208beta diversity of the mixture: 2.00 (gamma 82,063 / alpha 41,032)CASSLGQGTEAFF starts like group A and ends like group B. The mixture gives it almost nothing; the merged model gives it almost 50 billion times more, and puts almost half of all its probability on such recombined sequences. That is also why the merged model is more diverse than the two groups together.
Which one to use:
- Merge to describe a group as one population, with all of its evidence behind every row. The group models above and the reference model are merged models, and recombining the members’ patterns is what lets them generalise to sequences no member happened to contain.
- Mix when the members must stay distinct, above all to measure the diversity between them.
Diversity between members
Section titled “Diversity between members”Diversity is measured with Hill numbers. At order ,
is the effective number of sequences; at it is , the inverse of the chance that two draws are the same sequence. For a mixture of members:
- gamma, , is the diversity of the group as a whole;
- alpha is the diversity of a typical member: the members’ averaged by a power mean of order , which at is their harmonic mean;
- beta is the effective number of distinct members, from 1 when all members are identical to when they share no sequence.
The two groups above share no sequence, so beta is 2.00: gamma 82,063 against alpha 41,032. The merged model cannot take gamma’s place: its 127,208 effective sequences include the recombined ones, which belong to no member, so the ratio would no longer count members.
With stand-in groups that overlap, beta falls between the two extremes:
from BEAM import BeamModel, Mixture# group_a, group_a2: two groups from the same population; group_b, group_c: two otherssame = Mixture([group_a, group_a2])different = Mixture([group_a, group_b, group_c])
# beta diversity: the effective number of distinct groups, from 1 (identical) to the countprint(f"two groups, one population: beta = {same.beta_diversity(2.0).beta:.3f}")print(f"three groups, three populations: beta = {different.beta_diversity(2.0).beta:.3f}")two groups, one population: beta = 1.035three groups, three populations: beta = 1.563Two groups drawn from one population count as one (1.04); three groups drawn from three different populations count as 1.56. At order 2 the measure is driven by the most common sequences, which the three stand-in populations largely share; their differences in rarer sequences show at lower orders and in the distance.