Detecting pharmacovigilance signals (disproportionality)
Spontaneous-report databases like the FDA's FAERS are mined for
signals of disproportionate reporting (SDR): drug-reaction pairs that occur
together more than expected given the background of all reports. The core
device is a 2x2 contingency table and a disproportionality metric computed
from it — PRR, ROR, EBGM, or IC (BCPNN).
You can build the 2x2 table directly from the public, free OpenFDA
/drug/event endpoint (no PHI, no MedDRA license to query; the reaction terms
returned are already MedDRA PTs). This skill is statistical screening: a high
PRR is a hypothesis, not a confirmed adverse drug reaction.
When to use
- You have a drug of interest and want to see which reactions are over-reported.
- You need a PRR / ROR with confidence interval, or an Empirical Bayes EBGM/EB05
/ IC025 to control for the small-count noise PRR/ROR suffer from.
- You are building a routine signal-screening run over OpenFDA or your own
aggregated case counts.
The 2x2 table
For one drug D and one reaction R, classify every report:
|
Reaction R |
Not R |
| Drug D |
a |
b |
| Not D |
c |
d |
- PRR = [a/(a+b)] / [c/(c+d)]
- ROR = (a·d)/(b·c)
- IC (BCPNN, log2 information component) ≈ log2( a·(a+b+c+d) / ((a+b)·(a+c)) )
- EBGM = Empirical Bayes Geometric Mean — a gamma-Poisson shrinkage of the
observed/expected ratio (the MGPS method) that pulls small-count estimates
toward 1; report EB05 (the 5th percentile) as the conservative signal.
Common signal thresholds (screening only): PRR ≥ 2 with χ² ≥ 4 and a ≥ 3; ROR
lower 95% CI > 1; IC025 > 0; EB05 ≥ 2.
Quick start (real OpenFDA count queries)
Base endpoint: https://api.fda.gov/drug/event.json. No key needed to try it
(240 req/min, 1,000/day per IP; with a free api_key= key: 240/min,
120,000/day). The count=<field>.exact parameter returns a terms histogram, and
search= with +AND+ filters the population — that is all you need for a 2x2.
import requests
BASE = "https://api.fda.gov/drug/event.json"
def fda_count(search: str | None, count_field: str) -> int:
"""Total reports matching `search` (sum of the .exact histogram)."""
params = {"count": count_field}
if search:
params["search"] = search
r = requests.get(BASE, params=params, timeout=30)
if r.status_code == 404: # OpenFDA returns 404 for an empty result set
return 0
r.raise_for_status()
return sum(row["count"] for row in r.json()["results"])
def cell_count(search: str | None) -> int:
"""Number of reports matching `search` (use meta.results.total via limit=1)."""
params = {"limit": 1}
if search:
params["search"] = search
r = requests.get(BASE, params=params, timeout=30)
if r.status_code == 404:
return 0
r.raise_for_status()
return r.json()["meta"]["results"]["total"]
# Build the 2x2 for warfarin x "gastrointestinal haemorrhage".
DRUG = 'patient.drug.openfda.generic_name:"warfarin"'
RXN = 'patient.reaction.reactionmeddrapt.exact:"gastrointestinal haemorrhage"'
a = cell_count(f"{DRUG}+AND+{RXN}") # drug & reaction
b = cell_count(DRUG) - a # drug, not reaction
c = cell_count(RXN) - a # reaction, not drug
N = cell_count(None) # total reports in FAERS
d = N - a - b - c
Compute the metrics from (a, b, c, d):
import math
def prr(a, b, c, d):
return (a / (a + b)) / (c / (c + d))
def ror(a, b, c, d):
return (a * d) / (b * c)
def ror_ci(a, b, c, d):
lnror = math.log((a * d) / (b * c))
se = math.sqrt(1/a + 1/b + 1/c + 1/d) # Woolf's method
lo, hi = math.exp(lnror - 1.96 * se), math.exp(lnror + 1.96 * se)
return lo, hi
def ic(a, b, c, d):
n = a + b + c + d
expected = (a + b) * (a + c) / n
return math.log2(a / expected) if a and expected else float("nan")
print("PRR", round(prr(a, b, c, d), 2))
print("ROR", round(ror(a, b, c, d), 2), "95% CI", ror_ci(a, b, c, d))
print("IC", round(ic(a, b, c, d), 2))
For EBGM / EB05 use a maintained Empirical Bayes implementation (e.g. the
openEBGM R package or PhViD in R) on the same (a, b, c, d) rather than
hand-rolling the gamma-Poisson MGPS shrinkage — the shrinkage prior is the whole
point and easy to get wrong.
Workflow
- Pick the population. Decide your denominator: all of FAERS, or a
restricted background (e.g. one drug class, one year via
receivedate:[20230101+TO+20231231]). The choice of c/d defines the
"expected".
- Resolve the drug field. Prefer
patient.drug.openfda.generic_name (RxNorm
ingredient-normalized) over the free-text medicinalproduct to avoid brand
fragmentation. Restrict to suspect drugs with
patient.drug.drugcharacterization:1 if you want suspect-only signals.
- Use
.exact for the reaction field so "injection site reaction" counts as
one phrase, not three words: patient.reaction.reactionmeddrapt.exact.
- Build the 2x2 with the cell counts above. Verify
a + b + c + d == N.
- Compute PRR and ROR with CIs; add IC025 / EB05 for small counts.
- Apply thresholds (e.g. PRR ≥ 2, χ² ≥ 4, a ≥ 3) — but treat them as a
triage filter, not a verdict.
- Hand flagged pairs to a safety scientist for medical review, confounder
assessment, and labeling/expectedness checks.
Hand-off to / from OpenMed
- From
reporting-adverse-events: your own coded, de-identified ICSRs give
internal counts you can use instead of or alongside OpenFDA — the same
2x2 math applies. Aggregate only counts; never put narrative PHI in the table.
- From
normalizing-rxnorm: normalize the drug name to an RxNorm ingredient
before querying so brand/generic synonyms collapse to one cell.
- To
querying-openfda-labels: for every signal, check whether the reaction
is already on the label (expected) via /drug/label. To
reporting-adverse-events: a confirmed signal may require expedited reporting.
- OpenMed runs NER/de-id on-device; only de-identified drug/reaction codes
(no PHI) are sent to OpenFDA.
Edge cases & gotchas
- Disproportionality ≠ causality. A high PRR reflects reporting patterns,
notoriety bias, and indication confounding — not a proven causal link.
- Small counts break PRR/ROR. With
a < 3 the ratios are unstable and CIs
explode. This is exactly why EBGM/EB05 and IC025 (shrinkage) exist —
prefer them for rare events.
- OpenFDA is a sample, not all of FAERS, and is not deduplicated the way the
curated FAERS quarterly files are. Use it for screening; reproduce confirmed
signals against the official FAERS extracts.
.exact is mandatory for counting phrases. Without it, OpenFDA tokenizes
the reaction and your counts are wrong.
- OpenFDA returns HTTP 404 for an empty result set (not an empty list) — the
helpers above treat 404 as zero. Respect the rate limits; register a free key
for routine runs.
- MedDRA versioning. OpenFDA reaction terms are MedDRA PTs at FDA's coding
version; if you join to your own MedDRA-coded cases, align the version. MedDRA
itself is licensed — you query OpenFDA's already-coded terms, you do not need a
MedDRA license to read them, but you do to code your own cases.
Standards & references
1---2name: detecting-pv-signals3description: Computes disproportionality signals — PRR, ROR, EBGM, and IC (BCPNN) — over FAERS / OpenFDA drug-event data to flag potential safety signals. Use when the user wants to mine spontaneous-report data for drug-reaction associations, build a 2x2 contingency table, compute a Proportional Reporting Ratio or Reporting Odds Ratio, run Empirical Bayes (EBGM/EB05) or Information Component shrinkage, or screen a drug for over-reported reactions. Trigger keywords: disproportionality, signal detection, PRR, ROR, EBGM, EB05, IC, BCPNN, MGPS, 2x2 table, signal of disproportionate reporting, SDR, OpenFDA, FAERS. Pairs adjacent to OpenMed: aggregate de-identified, coded cases (from reporting-adverse-events) then query the public OpenFDA /drug/event count API to build the contingency table. Reaction terms are MedDRA PTs (licensed, user-supplied).4license: Apache-2.05---67# Detecting pharmacovigilance signals (disproportionality)89Spontaneous-report databases like the FDA's **FAERS** are mined for10**signals of disproportionate reporting (SDR)**: drug-reaction pairs that occur11together *more than expected* given the background of all reports. The core12device is a **2x2 contingency table** and a disproportionality metric computed13from it — **PRR**, **ROR**, **EBGM**, or **IC (BCPNN)**.1415You can build the 2x2 table directly from the **public, free OpenFDA**16`/drug/event` endpoint (no PHI, no MedDRA license to *query*; the reaction terms17returned are already MedDRA PTs). This skill is **statistical screening**: a high18PRR is a *hypothesis*, not a confirmed adverse drug reaction.1920## When to use2122- You have a drug of interest and want to see which reactions are over-reported.23- You need a PRR / ROR with confidence interval, or an Empirical Bayes EBGM/EB0524 / IC025 to control for the small-count noise PRR/ROR suffer from.25- You are building a routine signal-screening run over OpenFDA or your own26 aggregated case counts.2728## The 2x2 table2930For one drug D and one reaction R, classify every report:3132| | Reaction R | Not R |33| ---------- | ---------- | ----- |34| Drug D | **a** | **b** |35| Not D | **c** | **d** |3637- **PRR** = [a/(a+b)] / [c/(c+d)]38- **ROR** = (a·d)/(b·c)39- **IC** (BCPNN, log2 information component) ≈ log2( a·(a+b+c+d) / ((a+b)·(a+c)) )40- **EBGM** = Empirical Bayes Geometric Mean — a gamma-Poisson *shrinkage* of the41 observed/expected ratio (the MGPS method) that pulls small-count estimates42 toward 1; report **EB05** (the 5th percentile) as the conservative signal.4344Common signal thresholds (screening only): PRR ≥ 2 with χ² ≥ 4 and a ≥ 3; ROR45lower 95% CI > 1; **IC025 > 0**; **EB05 ≥ 2**.4647## Quick start (real OpenFDA count queries)4849Base endpoint: `https://api.fda.gov/drug/event.json`. No key needed to try it50(240 req/min, 1,000/day per IP; with a free `api_key=` key: 240/min,51120,000/day). The `count=<field>.exact` parameter returns a terms histogram, and52`search=` with `+AND+` filters the population — that is all you need for a 2x2.5354```python55import requests5657BASE = "https://api.fda.gov/drug/event.json"5859def fda_count(search: str | None, count_field: str) -> int:60 """Total reports matching `search` (sum of the .exact histogram)."""61 params = {"count": count_field}62 if search:63 params["search"] = search64 r = requests.get(BASE, params=params, timeout=30)65 if r.status_code == 404: # OpenFDA returns 404 for an empty result set66 return 067 r.raise_for_status()68 return sum(row["count"] for row in r.json()["results"])6970def cell_count(search: str | None) -> int:71 """Number of reports matching `search` (use meta.results.total via limit=1)."""72 params = {"limit": 1}73 if search:74 params["search"] = search75 r = requests.get(BASE, params=params, timeout=30)76 if r.status_code == 404:77 return 078 r.raise_for_status()79 return r.json()["meta"]["results"]["total"]8081# Build the 2x2 for warfarin x "gastrointestinal haemorrhage".82DRUG = 'patient.drug.openfda.generic_name:"warfarin"'83RXN = 'patient.reaction.reactionmeddrapt.exact:"gastrointestinal haemorrhage"'8485a = cell_count(f"{DRUG}+AND+{RXN}") # drug & reaction86b = cell_count(DRUG) - a # drug, not reaction87c = cell_count(RXN) - a # reaction, not drug88N = cell_count(None) # total reports in FAERS89d = N - a - b - c90```9192Compute the metrics from `(a, b, c, d)`:9394```python95import math9697def prr(a, b, c, d):98 return (a / (a + b)) / (c / (c + d))99100def ror(a, b, c, d):101 return (a * d) / (b * c)102103def ror_ci(a, b, c, d):104 lnror = math.log((a * d) / (b * c))105 se = math.sqrt(1/a + 1/b + 1/c + 1/d) # Woolf's method106 lo, hi = math.exp(lnror - 1.96 * se), math.exp(lnror + 1.96 * se)107 return lo, hi108109def ic(a, b, c, d):110 n = a + b + c + d111 expected = (a + b) * (a + c) / n112 return math.log2(a / expected) if a and expected else float("nan")113114print("PRR", round(prr(a, b, c, d), 2))115print("ROR", round(ror(a, b, c, d), 2), "95% CI", ror_ci(a, b, c, d))116print("IC", round(ic(a, b, c, d), 2))117```118119For **EBGM / EB05** use a maintained Empirical Bayes implementation (e.g. the120`openEBGM` R package or `PhViD` in R) on the same `(a, b, c, d)` rather than121hand-rolling the gamma-Poisson MGPS shrinkage — the shrinkage prior is the whole122point and easy to get wrong.123124## Workflow1251261. **Pick the population.** Decide your denominator: all of FAERS, or a127 restricted background (e.g. one drug class, one year via128 `receivedate:[20230101+TO+20231231]`). The choice of `c`/`d` defines the129 "expected".1302. **Resolve the drug field.** Prefer `patient.drug.openfda.generic_name` (RxNorm131 ingredient-normalized) over the free-text `medicinalproduct` to avoid brand132 fragmentation. Restrict to suspect drugs with133 `patient.drug.drugcharacterization:1` if you want suspect-only signals.1342. **Use `.exact`** for the reaction field so "injection site reaction" counts as135 one phrase, not three words: `patient.reaction.reactionmeddrapt.exact`.1363. **Build the 2x2** with the cell counts above. Verify `a + b + c + d == N`.1374. **Compute PRR and ROR with CIs**; add **IC025** / **EB05** for small counts.1385. **Apply thresholds** (e.g. PRR ≥ 2, χ² ≥ 4, a ≥ 3) — but treat them as a139 *triage filter*, not a verdict.1406. **Hand flagged pairs to a safety scientist** for medical review, confounder141 assessment, and labeling/expectedness checks.142143## Hand-off to / from OpenMed144145- **From** `reporting-adverse-events`: your own coded, de-identified ICSRs give146 internal counts you can use *instead of* or *alongside* OpenFDA — the same147 2x2 math applies. Aggregate only counts; never put narrative PHI in the table.148- **From** `normalizing-rxnorm`: normalize the drug name to an RxNorm ingredient149 before querying so brand/generic synonyms collapse to one cell.150- **To** `querying-openfda-labels`: for every signal, check whether the reaction151 is already on the label (expected) via `/drug/label`. **To**152 `reporting-adverse-events`: a confirmed signal may require expedited reporting.153- OpenMed runs NER/de-id **on-device**; only de-identified drug/reaction *codes*154 (no PHI) are sent to OpenFDA.155156## Edge cases & gotchas157158- **Disproportionality ≠ causality.** A high PRR reflects reporting patterns,159 notoriety bias, and indication confounding — not a proven causal link.160- **Small counts break PRR/ROR.** With `a < 3` the ratios are unstable and CIs161 explode. This is exactly why **EBGM/EB05** and **IC025** (shrinkage) exist —162 prefer them for rare events.163- **OpenFDA is a sample, not all of FAERS, and is not deduplicated** the way the164 curated FAERS quarterly files are. Use it for screening; reproduce confirmed165 signals against the official FAERS extracts.166- **`.exact` is mandatory for counting phrases.** Without it, OpenFDA tokenizes167 the reaction and your counts are wrong.168- **OpenFDA returns HTTP 404 for an empty result set** (not an empty list) — the169 helpers above treat 404 as zero. Respect the rate limits; register a free key170 for routine runs.171- **MedDRA versioning.** OpenFDA reaction terms are MedDRA PTs at FDA's coding172 version; if you join to your own MedDRA-coded cases, align the version. MedDRA173 itself is licensed — you query OpenFDA's already-coded terms, you do not need a174 MedDRA license to read them, but you do to code your own cases.175176## Standards & references177178- OpenFDA drug adverse event API: https://open.fda.gov/apis/drug/event/179- OpenFDA query syntax (`count`, `.exact`, `search` AND/OR): https://open.fda.gov/apis/query-syntax/180- OpenFDA authentication & rate limits: https://open.fda.gov/apis/authentication/181- Evans et al., PRR for signal generation (Pharmacoepidemiol Drug Saf, 2001): https://pubmed.ncbi.nlm.nih.gov/11828828/182- Bate et al., BCPNN / Information Component (Eur J Clin Pharmacol, 1998): https://pubmed.ncbi.nlm.nih.gov/9696956/183- DuMouchel, Empirical Bayes / MGPS (EBGM): https://www.tandfonline.com/doi/abs/10.1080/00031305.1999.10474456184- CIOMS VIII — Practical Aspects of Signal Detection: https://cioms.ch/publications/product/practical-aspects-of-signal-detection-in-pharmacovigilance/