Warpspeed confidence what is credible? -- Part 2
In my last post I started exploring a basic statistical analysis of how confident we should feel about the effectiveness of a vaccine for COVID-19 (Operation Warpspeed). A reminder I’m not an epidemiologist, I don’t have any data for safety, and we’re still quite early in the Phase III trials – just approaching the point at which they can take a first “peek” at the data (which is doubleblind). You’ll be best served if you’ve already read the previous post.
The three key conditions that have to be (there are certainly other conditions as well):
- 53 confirmed covid-19 cases among the 30,000 participants
- At least five severe cases of covid-19 among people who receive the placebo
- That there be at least two months of follow-up on half the study participants.
Regulators will require that the vaccine be at least 50 percent effective before the manufacturer can seek emergency use authorization.
Last time
Last time we used a variety of frequentist tools and tests to model our potential results, including a handy table that maps effectiveness to the number of the 53 cases in the placebo and vaccine conditions. We want our vaccine to be at least 50% effective. We can operationalize that most simply with \[\frac{placebo~cases - vaccinated~cases}{placebo~cases}\] So for example if 19 of the 53 cases had received the vaccine our effectiveness is \[\frac{placebo~cases - vaccinated~cases}{placebo~cases} = (34-19)/34 = 0.4411765\] which is very close to what we need. So in the best possible scenario (remember we require that at least 5 people who got the vaccine contract COVID before we can run the numbers) our effectiveness is ~90%. We need the number of people who received the vaccine and still contracted COVID to be 17 or less.
After our classical frequentist efforts we began a journey of exploring bayesian tools and techniques. That’s where we’ll pick up in this post. First let’s load some essential libraries.
library(dplyr)
library(ggplot2)
library(kableExtra)
theme_set(theme_bw())
Last post
we ventured as far as building a simple model with a flat prior
and treating the outcomes as simple binomial equations (a coin flip if you)
will. We built the model in the JAGS language and fed it to run.jags
for
40,000 MCMC iterations. We used tidybayes::tidy_draws
to extract the
chains/draws. That gave us estimates for the rate of infection for both
those who received the placebo and those who received the actual vaccine.
\[\frac{placebo~infection~rate - vaccinated~infection~rate}{placebo~infection~rate} * 100\]
gives us what we really want to investigate which is the percentage difference in
infection rates. We’re looking for (since we multiplied by 100) a number that is
around 50. Which in essence is a 50% effectiveness rating. We kept it simple
assuming both subject pools (the number who received placebo versus the real
vaccine was an even split 15,000 each). A simple call to bayestestR::hdi
gives us a graphical portrayal of what we want to know. Even with a simple 19/34
split we have mounting evidence that the vaccine is working. We’re not at all
totally convinced it’s 50% or more. But we can take heart with the fact that
95% of our draws wind up in the region 7.7% to 71.1% effectiveness. N.B.
yes we’d really like to see a split like 17/36 or even lower to boost our
credibility, I’m using lower numbers simply to make it more “exciting”, we all
have to wait on the real data.
## Calling 4 simulations using the parallel method...
## All chains have finished
## Simulation complete. Reading coda files...
## Coda files loaded successfully
## Finished running the simulation
Okay now that you’re caught up let’s push our bayesian skills even farther.
More credibility for our results
The analysis last time was based upon a
very nice function
bayes.prop.test
contained in a package
called bayesian_first_aid
. It was the first thing I stumbled upon that was a near
equivalent to the frequentist tests we were using. Let’s investigate some other tools.
Fair warning in advance they will give us similar results, but you’re reading
this to learn right? Personally I find it satisfying to know that no matter which
R
tool I choose I get the same answer.
The next tool we’ll use is bayesAB
. This package is on CRAN
and recent. For those of you steeped in other disciplines AB testing generally
seeks to ask questions about whether option A or option B is “better”. It is
often used in marketing for example presenting users with two different versions
of a website or ad and seeking to determine which option yields the most
purchases. Hopefully the analog to our vaccine trial is readily apparent. You
can read more about the package here.
bayesAB
expects its input as a pair of vectors one for option A and one for option B.
Instead of MCMC methods
it uses random sampling from a limited set of distributions. That means it is faster to
run but not suitable for very complex models like runjags
, or brms
or STAN
.
But for our relatively simple problem it is quite useful.
Let’s make two vectors labeled vaccinated
and placebo
with the right
quantities (again assuming an equal 15,000 in each category). The authors
have decided to calculate and plot (A - B) / B with no way to easily
customize. Since we require placebo in the denominator that simply means
by default we get the right value but with a negative sign. We’ll “correct”
that in a bit. By default it samples 100,000 times. It has a custom
summary
and plot
method we’ll display. As with our earlier example we’ll
use a flat uninformative pretending we have no information about the
effectiveness of the vaccine.
vaccinated <- c(rep(0, 15000 - 19), rep(1, 19))
placebo <- c(rep(0, 15000 - 34), rep(1, 34))
AB1 <-
bayesAB::bayesTest(B_data = placebo,
A_data = vaccinated,
priors = c('alpha' = 1, 'beta' = 1),
distribution = 'bernoulli')
summary(AB1)
## Quantiles of posteriors for A and B:
##
## $Probability
## $Probability$A
## 0% 25% 50% 75% 100%
## 0.0004575392 0.0011201359 0.0013089507 0.0015199810 0.0030369367
##
## $Probability$B
## 0% 25% 50% 75% 100%
## 0.0009776941 0.0020570086 0.0023106757 0.0025868629 0.0045641318
##
##
## --------------------------------------------
##
## P(A > B) by (0)%:
##
## $Probability
## [1] 0.0199
##
## --------------------------------------------
##
## Credible Interval on (A - B) / B for interval length(s) (0.9) :
##
## $Probability
## 5% 95%
## -0.6473618 -0.1075419
##
## --------------------------------------------
##
## Posterior Expected Loss for choosing B over A:
##
## $Probability
## [1] 0.8460082
plot(AB1)
Again I’ll remind you the sign is reversed, but you can clearly see that except for the sign the results are comparable to our earlier results. The plots (3) are informative, displaying our prior, the A & B superimposed and our coveted % difference in infection rate.
The third plot explicitly calls out the fact that ~98% of the time our data shows the vaccine to be effective. With the peak (high density) occurring near 50% effective.
The plots are good, but I’m fussy. It used to be slightly complex to get the
draws or chains from a bayesian model. It’s getting easier and easier. In this
case a quick look at our AB1
object tells us they are in AB1$posteriors$Probability
.
This is a list with two vectors A & B. Let’s convert to a tibble
and rename
them back to what they actually are vaccinated = A, placebo = B
. Now if
we tidyr::pivot_longer(everything(), names_to = "group")
we have a nice tibble
we can easily send to ggplot
and format our (my) way.
ggdist
has a variety of nice functions
for summarizing and displaying draws and chains. stat_halfeye
not only
plots it as a density curve but allows us to add per group credible intervals. If
we leave the data “wide” we can compute our diff_rate and plot it. If we prefer a
text table bayestestR::describe_posterior(ci = c(0.89, 0.95))
as_tibble(AB1$posteriors$Probability) %>%
rename(vaccinated = A, placebo = B) %>%
tidyr::pivot_longer(everything(), names_to = "group") %>%
ggplot(aes(x = value, fill = group)) +
ggdist::stat_halfeye(.width = c(0.89, 0.95),
alpha = .8,
slab_colour = "black",
slab_size = .5) +
ggtitle("Infection rates for vaccine and placebo groups (N = 30,000)") +
xlab("Infection rate") +
theme(axis.title.y = element_blank(),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor.y = element_blank())
as_tibble(AB1$posteriors$Probability) %>%
rename(vaccinated = A, placebo = B) %>%
mutate(diff_rate = (placebo - vaccinated) / placebo * 100) %>%
ggplot(aes(x = diff_rate)) +
ggdist::stat_halfeye(.width = c(0.89, 0.95),
alpha = .9,
slab_fill = "dark green",
slab_colour = "black",
slab_size = .5) +
ggtitle("% Difference in Infection rates for vaccine and placebo groups (N = 30,000)") +
xlab("% Difference in Infection Rates") +
theme(axis.title.y = element_blank(),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor.y = element_blank())
as_tibble(AB1$posteriors$Probability) %>%
rename(vaccinated = A, placebo = B) %>%
mutate(diff_rate = (placebo - vaccinated) / placebo * 100) %>%
bayestestR::describe_posterior(ci = c(0.89, 0.95))
## # Description of Posterior Distributions
##
## Parameter | Median | CI | pd | 89% ROPE | % in ROPE
## -------------------------------------------------------------------------------------
## vaccinated | 0.001 | 89% CI [ 0.001, 0.002] | 100.00% | [-0.100, 0.100] | 100
## vaccinated | 0.001 | 95% CI [ 0.001, 0.002] | 100.00% | [-0.100, 0.100] | 100
## placebo | 0.002 | 89% CI [ 0.002, 0.003] | 100.00% | [-0.100, 0.100] | 100
## placebo | 0.002 | 95% CI [ 0.002, 0.003] | 100.00% | [-0.100, 0.100] | 100
## diff_rate | 43.305 | 89% CI [16.664, 67.485] | 98.01% | [-0.100, 0.100] | 0
## diff_rate | 43.305 | 95% CI [ 8.100, 71.230] | 98.01% | [-0.100, 0.100] | 0
Actually let’s go ahead and save the results in a tibble named
diff_rate_results
and showcase the functions in bayestestR
. Rather than
explain each function and what they mean. I’m going to refer you to their
website which does a great job of explaining it..
The bottom line is that the results are comparable to our two earlier explorations.
diff_rate_results <-
as_tibble(AB1$posteriors$Probability) %>%
rename(vaccinated = A, placebo = B) %>%
mutate(diff_rate = (placebo - vaccinated) / placebo * 100)
bayestestR::describe_posterior(diff_rate_results$diff_rate)
## # Description of Posterior Distributions
##
## Parameter | Median | 89% CI | pd | 89% ROPE | % in ROPE
## ----------------------------------------------------------------------------
## Posterior | 43.305 | [16.664, 67.485] | 98.01% | [-0.100, 0.100] | 0
bayestestR::ci(diff_rate_results$diff_rate, method = "HDI", ci = c(0.005,0.025,0.5,0.975,0.995)) %>% plot
bayestestR::p_direction(diff_rate_results$diff_rate) %>% plot
bayestestR::p_significance(diff_rate_results$diff_rate) %>% plot
bayestestR::rope(diff_rate_results$diff_rate, ci = c(0.9, 0.95)) %>% plot
One final bayesian tool (this post)
Okay one more set of analyses using brms
.
The brms package provides an interface to fit Bayesian generalized (non-)linear multivariate multilevel models using Stan, which is a C++ package for performing full Bayesian inference (see https://mc-stan.org/).
Quite frankly it is more than we need for our simple little analysis. A bit like
using a sledge hammer as a fly-swatter. Besides the simple joy of learning about
it however, there is one practical reason to use it. As our data grows and we
inevitably want to conduct more sophisticated analysis factoring things in like
gender, age and race. It is likely to become the tool of choice. I’m not going
to dwell on it but it’s worthy to note that
STAN
uses different method for
its markov chains.
First we need to put the data in the preferred format which is actually
a simple one row tibble. Then we put things in a format brms
can understand.
A bit of an oddity here, because we are moving to a GLM model
we’re not operating on the binomial distribution directly rather transforming back
and forth through logit
and inverse_logit`.
We’ll run ~50,000 across 5 cores and get our summary and diagnostic plots.
library(brms)
d2 <-
tibble(placebo = 34,
vaccine = 19,
n1 = 15000,
n2 = 15000)
model_1 <- bf(placebo | trials(n1) ~ 1)
model_2 <- bf(vaccine | trials(n2) ~ 1)
priors <- get_prior(data = d2,
family = binomial(),
formula = model_1 + model_2)
priors$prior[2] <- "normal(0, 1)"
priors$prior[3] <- "normal(0, 1)"
priors
## prior class coef group resp dpar nlpar bound source
## (flat) Intercept default
## normal(0, 1) Intercept placebo default
## normal(0, 1) Intercept vaccine default
fit2 <-
brm(data = d2, family = binomial(),
model_1 + model_2,
prior = priors,
iter = 10000,
warmup = 1000,
cores = 6,
chains = 6,
seed = 7)
fit2
## Family: MV(binomial, binomial)
## Links: mu = logit
## mu = logit
## Formula: placebo | trials(n1) ~ 1
## vaccine | trials(n2) ~ 1
## Data: d2 (Number of observations: 1)
## Samples: 6 chains, each with iter = 10000; warmup = 1000; thin = 1;
## total post-warmup samples = 54000
##
## Population-Level Effects:
## Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
## placebo_Intercept -5.94 0.16 -6.26 -5.64 1.00 41344 33647
## vaccine_Intercept -6.40 0.20 -6.80 -6.03 1.00 48654 36148
##
## Samples were drawn using sampling(NUTS). For each parameter, Bulk_ESS
## and Tail_ESS are effective sample size measures, and Rhat is the potential
## scale reduction factor on split chains (at convergence, Rhat = 1).
plot(fit2)
Everything looks good except for the funny scale we’re operating on.
Rather than use tidybayes::tidy_draws
to extract the results of our chains
we’ll use posterior_samples(fit2, add_chain = FALSE)
.
We’ll use transmute to convert back to our original scale and get names we prefer.
As we did earlier we’ll use tidyr::pivot_longer
to convert to long format and
pipe it through to ggplot
to compare the two distributions. Then we’ll
plot the effectiveness (diff_rate
) estimate much as we have done before.
Finally we pipe this cleaned up data to bayestestR::describe_posterior()
we get back a table that is a little easier to read. Focus on just the
line for diff_rate
. We can of course also pipe it into other plotting
functions if you have favorites.
library(tidybayes)
theme_set(theme_bw())
post2 <- posterior_samples(fit2, add_chain = FALSE)
post2 %>%
transmute(placebo = inv_logit_scaled(b_placebo_Intercept),
vaccine = inv_logit_scaled(b_vaccine_Intercept)) %>%
tidyr::pivot_longer(cols = everything()) %>%
ggplot(aes(x = value, y = name)) +
tidybayes::stat_halfeye(point_interval = mode_hdi,
.width = c(.5, .95)) +
scale_y_discrete(NULL) +
scale_x_continuous(labels = scales::label_percent()) +
xlab("Percent Infected") +
theme(panel.grid = element_blank())
post2 %>%
mutate(placebo = inv_logit_scaled(b_placebo_Intercept),
vaccine = inv_logit_scaled(b_vaccine_Intercept)) %>%
mutate(diff_rate = (placebo - vaccine) / placebo) %>%
ggplot(aes(x = diff_rate)) +
geom_histogram(color = "grey92", fill = "grey67",
size = .2, bins = 40) +
tidybayes::stat_pointinterval(aes(y = 0),
point_interval = mode_hdi, .width = .95) +
scale_y_continuous(NULL, breaks = NULL) +
scale_x_continuous(labels = scales::label_percent()) +
xlab("Percent Difference in rate of infection") +
theme(panel.grid = element_blank())
post2 %>%
mutate(placebo = inv_logit_scaled(b_placebo_Intercept),
vaccine = inv_logit_scaled(b_vaccine_Intercept)) %>%
mutate(diff_rate = (placebo - vaccine) / placebo) %>%
bayestestR::describe_posterior()
## # Description of Posterior Distributions
##
## Parameter | Median | 89% CI | pd | 89% ROPE | % in ROPE
## ----------------------------------------------------------------------------------------
## placebo_Intercept | -5.934 | [ -6.191, -5.686] | 100.00% | [-0.100, 0.100] | 0
## vaccine_Intercept | -6.393 | [ -6.710, -6.086] | 100.00% | [-0.100, 0.100] | 0
## lp__ | -46.880 | [-48.398, -46.174] | 100.00% | [-0.100, 0.100] | 0
## placebo | 0.003 | [ 0.002, 0.003] | 100.00% | [-0.100, 0.100] | 100
## vaccine | 0.002 | [ 0.001, 0.002] | 100.00% | [-0.100, 0.100] | 100
## diff_rate | 0.368 | [ 0.101, 0.604] | 96.73% | [-0.100, 0.100] | 0
preplot <-
post2 %>%
mutate(placebo = inv_logit_scaled(b_placebo_Intercept),
vaccine = inv_logit_scaled(b_vaccine_Intercept)) %>%
mutate(diff_rate = (placebo - vaccine) / placebo) %>%
select(diff_rate)
plot(bayestestR::hdi(preplot, ci = c(.89, .95, .99)))
Done
I’ve decided to make this a three parter. Please hang in there more bayesian “magic” follows soon. Probably next week.
Hope you enjoyed the post. Comments always welcomed. Especially please let me know if you actually use the tools and find them useful.
Extra credit for me for not expressing a political view at any point. Let the data speak and the counting continue.
Chuck
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License