Distinct epitopes assumption¶
The Polyclonal
model assumes that a polyclonal antibody mix can be divided into independent classes of neutralizing antibodies that bind to distinct epitopes without competition. Here we interrogate the validity of this assumption, being cognizant of the observation that realistic epitopes are often overlapping and therefore not distinct. To do this, we draw from statistical mechanics principles to compare the antibody escape fractions predicted by Polyclonal
and an identically
formulated model that instead assumes all epitopes are overlapping.
1. Modeling a monoclonal antibody that neutralizes a viral protein¶
Before we consider the polyclonal antibody case, lets first consider the case of a monoclonal antibody that neutralizes a viral protein. Here, the viral protein can exist in two microstates, bound or unbound by a neutralizing antibody. The Boltzmann weight is 1 for the unbound state and
We can then define the partition function
where
2. Modeling polyclonal antibodies that neutralize a viral protein¶
To extend above to a polyclonal antibody mix, we first modify
2.1 Two distinct epitopes¶
In a polyclonal antibody mix, it now becomes possible for new microstates to exist where multiple epitopes are bound by antibodies. For example, we can consider a viral protein that contains two distinct epitopes (1 and 2) that are targeted by polyclonal antibodies. In addition to the microstates where a single epitope is bound, we now require an additional microstate where both epitopes are bound. The Boltzmann weight for this new microstate is:
Thus, we can rewrite the partition function
and the probability of a viral protein being unbound by neutralizing antibodies is:
Note that Eq. 2 exactly corresponds to the Polyclonal
model.
2.2 Two overlapping epitopes¶
In the above case, the two epitopes were distinct and there was no competition amongst neutralizing antibodies. However, if the epitopes are overlapping and there is competition, then the microstate where both epitopes are bound (
Using Eq. 2 and Eq. 3, we can see how
First, we’ll plot the
[1]:
import numpy as np
import pandas as pd
from plotnine import *
n_points = 100
df = (
pd.DataFrame(
{
"c": np.tile(np.logspace(-10, -1, n_points), 2),
"k_d_1": np.append(np.repeat(1e-6, n_points), np.repeat(1e-5, n_points)),
"k_d_2": np.append(np.repeat(1e-6, n_points), np.repeat(1e-7, n_points)),
"f_1": np.repeat(0.5, 2 * n_points),
"f_2": np.repeat(0.5, 2 * n_points),
"scenario": (["same affinities"] * n_points)
+ (["different affinities"] * n_points),
}
)
.assign(
distinct_epitopes=lambda x: (1 / (1 + x.c * x.f_1 / x.k_d_1))
* (1 / (1 + x.c * x.f_2 / x.k_d_2)),
overlap_epitopes=lambda x: (
1 / (1 + x.c * x.f_1 / x.k_d_1 + x.c * x.f_2 / x.k_d_2)
),
)
.melt(
id_vars=["c", "k_d_1", "k_d_2", "f_1", "f_2", "scenario"],
value_vars=["overlap_epitopes", "distinct_epitopes"],
var_name="assumption",
value_name="p_unbound",
)
)
df["scenario"] = (
df["scenario"]
.astype("category")
.cat.reorder_categories(["same affinities", "different affinities"])
)
p = (
ggplot(df, aes(x="c", y="p_unbound"))
+ geom_line(aes(color="assumption"), size=1)
+ facet_wrap("scenario")
+ labs(x="concentration")
+ scale_color_manual(values=["#ec5094", "#5c7594"])
+ scale_x_log10()
+ theme_classic()
+ theme(figure_size=(9, 3.5))
)
_ = p.draw()

There is a small range in the sera concentration where the curves will slightly differ when the two classes of antibodies have the same affinity (
We can show that this is true across the range of parameters p_unbound
under each assumption.
[2]:
import altair as alt
from itertools import product
c_range = np.logspace(-8, -2, 4)
Kds = np.logspace(-8, -2, 4)
fracs = np.array([0, 0.25, 0.5, 0.75, 1])
df = (
pd.DataFrame(
list(product(c_range, Kds, Kds, fracs, fracs)),
columns=["c", "k_d_1", "k_d_2", "f_1", "f_2"],
)
.assign(f_sum=lambda x: x.f_1 + x.f_2)
.query("f_sum == 1")
.assign(
distinct_epitopes=lambda x: (
(1 / (1 + x.c * x.f_1 / x.k_d_1)) * (1 / (1 + x.c * x.f_2 / x.k_d_2))
).round(3),
overlap_epitopes=lambda x: (
(1 / (1 + x.c * x.f_1 / x.k_d_1 + x.c * x.f_2 / x.k_d_2))
).round(3),
log_c=lambda x: np.log10(x.c).astype(int),
log_k_d_1=lambda x: np.log10(x.k_d_1).astype(int),
log_k_d_2=lambda x: np.log10(x.k_d_2).astype(int),
)
.melt(
id_vars=["log_c", "log_k_d_1", "log_k_d_2", "f_1", "f_2"],
value_vars=["overlap_epitopes", "distinct_epitopes"],
var_name="assumption",
value_name="p_unbound",
)
)
c_slider = alt.binding_range(min=-8, max=-2, step=2, name="log(sera concentration): ")
c_filter = alt.selection_point(fields=["log_c"], bind=c_slider, value=-6)
k_d_1_slider = alt.binding_range(min=-8, max=-2, step=2, name="log(antibody 1 Kd): ")
k_d_1_filter = alt.selection_point(fields=["log_k_d_1"], bind=k_d_1_slider, value=-6)
k_d_2_slider = alt.binding_range(min=-8, max=-2, step=2, name="log(antibody 2 Kd): ")
k_d_2_filter = alt.selection_point(fields=["log_k_d_2"], bind=k_d_2_slider, value=-6)
f_1_slider = alt.binding_range(min=0, max=1, step=0.25, name="antibody 1 fraction: ")
f_1_filter = alt.selection_point(fields=["f_1"], bind=f_1_slider, value=0.5)
alt.Chart(df).mark_bar().encode(
x=alt.X("p_unbound:Q", scale=alt.Scale(domain=[0, 1])),
y=alt.Y("assumption:O", axis=alt.Axis(title="")),
color=alt.Color(
"assumption:O",
legend=None,
scale=alt.Scale(
domain=["distinct_epitopes", "overlap_epitopes"],
range=["#ec5094", "#5c7594"],
),
),
tooltip=["assumption:O", "p_unbound:O"],
).add_params(f_1_filter, k_d_2_filter, k_d_1_filter, c_filter).transform_filter(
f_1_filter
).transform_filter(
k_d_2_filter
).transform_filter(
k_d_1_filter
).transform_filter(
c_filter
).properties(
height=60, width=400, title="p_unbound in a two-epitope model"
)
2.3 Extending beyond two epitopes¶
The same logic applies to viral proteins with more than two epitopes targeted by neutralizing antibodies. For example, we can write the case of 3 distinct epitopes as:
and the case of 3 overlapping epitopes as:
Again, we can see how the predicted
[3]:
c_range = np.logspace(-8, -2, 4)
Kds = np.logspace(-6, -2, 3)
fracs = np.array([0, 0.25, 0.5, 0.75, 1])
df = (
pd.DataFrame(
list(product(c_range, Kds, Kds, Kds, fracs, fracs, fracs)),
columns=["c", "k_d_1", "k_d_2", "k_d_3", "f_1", "f_2", "f_3"],
)
.assign(f_sum=lambda x: x.f_1 + x.f_2 + x.f_3)
.query("f_sum == 1")
.assign(
distinct_epitopes=lambda x: (
(1 / (1 + x.c * x.f_1 / x.k_d_1))
* (1 / (1 + x.c * x.f_2 / x.k_d_2))
* (1 / (1 + x.c * x.f_3 / x.k_d_3))
).round(3),
overlap_epitopes=lambda x: (
(
1
/ (
1
+ x.c * x.f_1 / x.k_d_1
+ x.c * x.f_2 / x.k_d_2
+ x.c * x.f_3 / x.k_d_3
)
)
).round(3),
log_c=lambda x: np.log10(x.c).astype(int),
log_k_d_1=lambda x: np.log10(x.k_d_1).astype(int),
log_k_d_2=lambda x: np.log10(x.k_d_2).astype(int),
log_k_d_3=lambda x: np.log10(x.k_d_3).astype(int),
)
.melt(
id_vars=["log_c", "log_k_d_1", "log_k_d_2", "log_k_d_3", "f_1", "f_2", "f_3"],
value_vars=["overlap_epitopes", "distinct_epitopes"],
var_name="assumption",
value_name="p_unbound",
)
)
c_slider = alt.binding_range(min=-8, max=-2, step=2, name="log(sera concentration): ")
c_filter = alt.selection_point(fields=["log_c"], bind=c_slider, value=-4)
k_d_1_slider = alt.binding_range(min=-6, max=-2, step=2, name="log(antibody 1 Kd): ")
k_d_1_filter = alt.selection_point(fields=["log_k_d_1"], bind=k_d_1_slider, value=-4)
k_d_2_slider = alt.binding_range(min=-6, max=-2, step=2, name="log(antibody 2 Kd): ")
k_d_2_filter = alt.selection_point(fields=["log_k_d_2"], bind=k_d_2_slider, value=-4)
k_d_3_slider = alt.binding_range(min=-6, max=-2, step=2, name="log(antibody 3 Kd): ")
k_d_3_filter = alt.selection_point(fields=["log_k_d_3"], bind=k_d_3_slider, value=-4)
f_1_slider = alt.binding_range(min=0, max=1, step=0.25, name="antibody 1 fraction: ")
f_1_filter = alt.selection_point(fields=["f_1"], bind=f_1_slider, value=0.5)
f_2_slider = alt.binding_range(min=0, max=1, step=0.25, name="antibody 2 fraction: ")
f_2_filter = alt.selection_point(fields=["f_2"], bind=f_2_slider, value=0.5)
alt.Chart(df).mark_bar().encode(
x=alt.X("p_unbound:Q", scale=alt.Scale(domain=[0, 1])),
y=alt.Y("assumption:O", axis=alt.Axis(title="")),
color=alt.Color(
"assumption:O",
legend=None,
scale=alt.Scale(
domain=["distinct_epitopes", "overlap_epitopes"],
range=["#ec5094", "#5c7594"],
),
),
tooltip=["assumption:O", "p_unbound:O"],
).add_params(
f_2_filter, f_1_filter, k_d_3_filter, k_d_2_filter, k_d_1_filter, c_filter
).transform_filter(
f_2_filter
).transform_filter(
f_1_filter
).transform_filter(
k_d_3_filter
).transform_filter(
k_d_2_filter
).transform_filter(
k_d_1_filter
).transform_filter(
c_filter
).properties(
height=60, width=400, title="p_unbound in a three-epitope model"
)