This page walks through the two Bayesian disproportionality algorithms used on every heatmap page — MGPS (score = EBGM) and RGPS (score = ERAM) — as implemented in the site’s Python pipeline (build_jp_heatmap_pages.py / article_algorithms.py), following Harpaz et al. 2022. If you prefer numbers to symbols, jump to the worked Myocarditis example.
Shrinks the ordinary reporting ratio toward 1 using a two-gamma mixture prior fitted across all preferred terms for the brand. The expected count is age/sex stratified, but not adjusted for other vaccines that co-report the same event.
First builds a regression-adjusted expected count that accounts for other vaccine products acting as “maskers”, then shrinks with a single gamma prior. That is why ERAM can be much higher (or lower) than EBGM for the same PT.
Both start from the same contingency-table counts. The divergence is almost entirely in how they form the expected count E, and in which prior they use to shrink n/E.
For a given cumulative month × brand page (e.g. Pfizer through Dec 2022), every preferred term (PT) contributes:
The stratified expected count is computed as:
In code (score_scope):
# a = brand reports of this PT; evS[s] = all-brand event counts in stratum s e_strat = sum(drug_s[d][s] * evS[s] / N_s[d][s] for s in range(N_STRATA) if N_s[d][s])
PRR on the heatmaps is still the unstratified ratio [a/(a+b)]/[c/(c+d)]. MGPS and RGPS both use stratified (or regression-adjusted) expecteds for their Bayesian scores.
For each PT i, the relative reporting ratio λi has a two-component gamma mixture prior (DuMouchel 1999):
The five hyperparameters (α1, β1, α2, β2, P) are fitted by empirical-Bayes maximum likelihood over all PTs of the brand in that cumulative run:
def fit_mgps_prior(n, E, seed=0): """Fit the 5-parameter two-gamma mixture prior from (n, E) pairs.""" # ... maximise mixture negative-binomial marginal likelihood ... a1, b1, a2, b2 = np.exp(best[:4]) p = float(sigmoid(best[4])) if a1 / b1 > a2 / b2: # order components by prior mean a1, b1, a2, b2, p = a2, b2, a1, b1, 1 - p return (a1, b1, a2, b2, p)
After the prior is fit, each PT gets posterior component weights wi1, wi2 from the negative-binomial marginals, and
where ψ is the digamma function. EB05 / EB95 are the 5th / 95th percentiles of the same posterior mixture (found by bisection on the mixture CDF).
def mgps_ebgm(n, E, prior): a1, b1, a2, b2, p = prior w1, w2 = _posterior_weights(n, E, prior) return np.exp(w1 * (digamma(a1 + n) - np.log(b1 + E)) + w2 * (digamma(a2 + n) - np.log(b2 + E)))
How it is called for one brand in one cumulative month:
n_arr = np.array([rows[p]["a"] for p in pts_d], float) e_arr = np.array([max(rows[p]["expected"], 1e-9) for p in pts_d], float) prior = fit_mgps_prior(n_arr, e_arr) ebgm = mgps_ebgm(n_arr, e_arr, prior) eb05 = mgps_quantile(n_arr, e_arr, prior, 0.05)
RGPS is run once per preferred term, across all vaccine products (type × manufacturer) in the country’s reports. Pfizer and Moderna are then scored as target products of that fit.
Stratum event rates are shrunk with a mean-1 gamma prior Gamma(g0, g0), ordered, and pooled until each group has at least max(10, √N+/2) events:
def grouped_strata(stratum_events, stratum_reports): p0 = total_events / ns.sum() g0 = estimate_mean1_gamma(Ns, p0 * ns) lam = (Ns + g0) / (es + g0) # shrunk stratum rates n_min = max(10.0, np.sqrt(total_events) / 2.0) # walk strata from highest lam → lowest, open a new group when n_min is hit
Other products whose grouped-strata-adjusted rates look extreme are selected as regression predictors. With fj the adjusted expected count and δ0 from another mean-1 gamma fit:
selected = np.where(
(np.abs(Nj - fj) > 2.5)
& ((q01 > mu0) | (q99 < mu0))
)[0]
An empirical-Bayes logistic regression is fit with grouped-stratum intercepts and coefficients for the selected products (other products collapsed into an OTHER category). The site uses the ordinary logistic link (α = 1/2 in the white paper’s extended-logistic family — Empirica’s α-optimisation is unpublished):
beta0 = _elr_newton(cell_g, cell_cat, cell_n, cell_y, n_groups, len(selected), p_group, g0, delta0, p0) baseline = sigmoid(beta0) # per grouped stratum
For the target brand, the RGPS expected count is the sum of the fitted baseline probabilities over that brand’s reports in each grouped stratum:
t_grouped = np.asarray(t_counts, float) @ Gmat # target reports → groups E_target = float(t_grouped @ baseline)
This ERGPS is the “Expected (RGPS)” column on the Top signals table. When other products soak up much of the event, ERGPS falls below EMGPS and ERAM rises.
A single Gamma(γ, δ) prior is fit to the (Nj, Ej) pairs of all products for this event. The score is the posterior arithmetic mean:
gamma_a, gamma_b = fit_single_gamma(Nj, Ej_products) # across products shape = N_target + gamma_a rate = E_target + gamma_b ERAM = shape / rate ER05 = gamma_dist.ppf(0.05, shape, scale=1.0 / rate)
| MGPS | RGPS | |
|---|---|---|
| Scope of the fit | One prior per brand, across all PTs | One regression + prior per PT, across products |
| Expected count E | Age/sex Mantel–Haenszel only | Regression-adjusted for selected co-reported products |
| Prior | Two-component gamma mixture (5 params) | Single gamma (2 params) |
| Score | EBGM = posterior geometric mean | ERAM = posterior arithmetic mean |
| Credible interval | EB05 / EB95 of the mixture | ER05 / ER95 of Gamma(N+γ, E+δ) |
| What it corrects | Sampling noise / small counts | Sampling noise and product masking |
| Fallback on this site | — | If the RGPS fit fails, ERAM/ER05/ER95 fall back to EBGM/EB05/EB95 |
The heatmaps define
so +100% means ERAM is twice EBGM. That typically happens when Step 2 of RGPS produces a much smaller expected count than MGPS’s stratified E: other products (often other COVID brands, or strongly associated non-COVID vaccines) were selected as predictors and “claimed” part of the background rate. The same observed count a over a smaller ERGPS yields a larger posterior mean.
Conversely, a negative masking effect means the regression raised the expected count for the target brand (the event looks more common in that brand’s strata / product neighbourhood than the plain stratified rate suggested).
Click any tile on a heatmap page to open the masking explainer — it shows EMGPS, ERGPS, the fold change between them, and how many predictors RGPS selected for that PT.
Everything below is taken from the live US pipeline for the page Pfizer — Nov 2022 (cumulative from Jan 2021), preferred term Myocarditis. No symbols only — each step shows the actual counts that produced EBGM ≈ 1.30 and ERAM ≈ 2.55 on that page.
| Myocarditis | Not Myocarditis | Total | |
|---|---|---|---|
| Pfizer | a = 1,932 | b = 431,517 | ndrug = 433,449 |
| Other brands | c = 1,164 | d = 571,203 | 572,367 |
| All US in scope | nevent = 3,096 | 1,002,720 | N = 1,005,816 |
Crude (unstratified) expected count would be ndrug × nevent / N = 433,449 × 3,096 / 1,005,816 = 1,334.2. MGPS and RGPS do not use that crude figure.
Across the 27 Harpaz sex × age strata, each stratum contributes
drug_s × evS / N_s. The largest contributions for Myocarditis are young males:
| Stratum | Ns | Pfizer reports | Myocarditis (all brands) | E contribution |
|---|---|---|---|---|
| M × 17–45 | 93,681 | 41,678 | 1,285 | 571.69 |
| M × 13–16 | 10,564 | 7,618 | 381 | 274.75 |
| F × 17–45 | 219,620 | 101,793 | 405 | 187.72 |
| F × 46–75 | 290,491 | 118,352 | 254 | 103.48 |
| M × 46–75 | 135,675 | 55,994 | 250 | 103.18 |
| M × unk age | 24,737 | 11,104 | 195 | 87.53 |
| M × 5–12 | 12,305 | 9,893 | 56 | 45.02 |
| … 12 other strata with events … | 112.21 | |||
| EMGPS = Σ contributions | 1,485.58 | |||
A two-gamma mixture prior is fit by empirical Bayes over all Pfizer PTs in this cumulative run (not just Myocarditis). The fitted hyperparameters are:
For Myocarditis specifically, the posterior almost entirely prefers the second (near-1) component:
RGPS rebuilds the expected count from a product × stratum table (every vaccine type × manufacturer in the US data). Strata are collapsed into G = 12 rate groups (shrinkage strength g0 = 0.43). Products whose grouped-strata-adjusted counts look extreme are selected as regression predictors. For Myocarditis, 22 products are selected. The biggest |N − f| gaps:
| Product | N (Myocarditis reports) | f (adjusted expected) | |N − f| | μ | |
|---|---|---|---|---|---|
| * | COVID19 | PFIZER\BIONTECH | 1,922 | 1,460.8 | 461.2 | 1.32 |
| * | COVID19 | JANSSEN | 105 | 277.5 | 172.5 | 0.38 |
| * | COVID19 | MODERNA | 981 | 1,106.2 | 125.2 | 0.89 |
| * | VARZOS | GSK | 1 | 26.4 | 25.4 | 0.05 |
| * | VARZOS | UNKNOWN | 0 | 11.7 | 11.7 | 0.02 |
| * | HPV9 | MERCK | 1 | 12.5 | 11.5 | 0.09 |
| COVID19-2 | PFIZER\BIONTECH | 7 | 18.5 | 11.5 | 0.39 | |
| * | COVID19 | UNKNOWN | 18 | 6.7 | 11.3 | 2.66 |
| * = selected as a masking predictor (22 total; remaining 14 are mostly other non-COVID vaccines with N≈0 but f>2.5) | |||||
Janssen is a classic masker here: it has far fewer Myocarditis reports than its age/sex mix would predict (105 vs 277), so the regression attributes part of the background rate to other products’ absence of signal — which lowers Pfizer’s baseline.
After fitting the penalised logistic regression (grouped-stratum intercepts + the 22 selected product coefficients), Pfizer’s expected count is the sum of the fitted baseline probabilities over Pfizer’s reports:
Same observed count (1,932), but the “null” background that Pfizer is compared against has almost halved once masking products are accounted for.
A single Gamma(γ, δ) prior is fit across all products’ (Nj, Ej) pairs for Myocarditis. The fit gives γ = 0.270, δ = 0.297. Then:
| PRR | MGPS (EBGM) | RGPS (ERAM) | |
|---|---|---|---|
| Score | 2.19 | 1.30 | 2.55 |
| Expected count used | (unstratified) | 1,485.6 | 758.9 |
| 95% / 90% interval | — | EB05–EB95: 1.25–1.35 | ER05–ER95: 2.45–2.64 |
| Masking effect | — | ERAM/EBGM − 1 = +96% | |
So for this PT, PRR says “about 2×”, MGPS says “after age/sex stratification and shrinkage, only about 1.3×”, and RGPS says “after also removing the masking of other products (especially Janssen’s deficit and Moderna’s share), about 2.5×”. That +96% jump is exactly the masking effect shown when you click the Myocarditis tile on the Pfizer Nov 2022 heatmap.
Adaptations on this site vs. the article’s Empirica runs: each score is a cumulative country-only database run (not all-VAERS); products are VAERS vaccine type × manufacturer; each report is assigned its priority vaccine (Pfizer > Moderna > other); the RGPS link is ordinary logistic (α = 1/2). Scores are capped at 5 for colour on the treemaps only — the stored parquet values and table columns are uncapped.