Clinical trial site enrolment: forecast which sites will miss target
In this article (8 sections)
Related programme: the Data Science course (6 months; ₹1,30,000 (inclusive of GST) · EMI from ₹5,000/month).
To forecast whether a trial site will meet its enrolment target, treat randomisations as a rate per active site-month, borrow strength from the other sites so that newly activated sites are not judged on one or two months of noise, and report the probability of reaching target by the enrolment close date rather than a single projected number. In the synthetic example below, one site is safe, two are clearly at risk and one is a genuine coin-flip — and a straight-line projection gets the youngest site badly wrong.
What the clinical operations lead needs to decide
Every multi-site study reaches the same meeting: enrolment is behind plan, the close date is fixed, and the choices are to add sites, redistribute targets, fund recruitment support at weak sites, or close a site that is not recruiting. Each choice is expensive. The analysis has to rank sites by how likely they are to miss, and it has to be fair to a site activated six weeks ago.
Site feasibility, screening and randomisation logs
Site master: site_id, city, investigator, activated_on (site initiation completed and site cleared to enrol), target.
Screening log: subject_id, site_id, screened_on, status (screen_fail, randomised, withdrawn), screen_fail_reason.
Public trial registrations in India are listed on the Clinical Trials Registry – India, which is useful for understanding a study's registered design, but site-level enrolment tracking always comes from the sponsor's or CRO's own systems. Everything below is synthetic.
The KPIs
- Randomised to date (k) — count of
status = 'randomised'. Not screened, not consented. - Active site-months (t) — days from
activated_onto today ÷ 30.4375. - Raw rate = k ÷ t.
- Screen-failure rate = screen fails ÷ screened. High values point to eligibility problems in that site's patient pool, not low effort.
- Shrunk rate = (a₀ + k) ÷ (b₀ + t), where a₀ ÷ b₀ is the pooled study rate and b₀ is how many site-months of evidence the prior is worth.
- P(hit) = probability the site randomises at least (target − k) more participants before close, under a Poisson process whose rate is uncertain (gamma-distributed). That gives a negative-binomial predictive distribution.
The model, runnable with the standard library
import math, sqlite3
from datetime import date
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE sites (site_id TEXT, city TEXT, activated_on TEXT, target INT);
INSERT INTO sites VALUES
('S01','Pune','2026-02-01',12), ('S02','Lucknow','2026-03-15',12),
('S03','Kochi','2026-06-01',10), ('S04','Jaipur','2026-08-10',10);
CREATE TABLE screening (subject_id TEXT, site_id TEXT, screened_on TEXT, status TEXT);
""")
rows = []
plan = {"S01": (9, 4), "S02": (3, 5), "S03": (4, 2), "S04": (0, 3)} # randomised, screen fails
n = 0
for site, (rand, fail) in plan.items():
for i in range(rand):
n += 1; rows.append((f"SUB{n:03d}", site, "2026-07-01", "randomised"))
for i in range(fail):
n += 1; rows.append((f"SUB{n:03d}", site, "2026-07-01", "screen_fail"))
db.executemany("INSERT INTO screening VALUES (?,?,?,?)", rows)
as_of, close = date(2026, 9, 27), date(2027, 3, 31)
site_rows = db.execute("""
SELECT s.site_id, s.city, s.activated_on, s.target,
SUM(c.status = 'randomised') AS randomised,
COUNT(c.subject_id) AS screened
FROM sites s LEFT JOIN screening c USING (site_id)
GROUP BY s.site_id ORDER BY s.site_id""").fetchall()
def months(d1, d2):
return (d2 - d1).days / 30.4375
# Pooled prior: the study-wide rate, worth b0 site-months of evidence
tot_k = sum(r[4] for r in site_rows)
tot_t = sum(months(date.fromisoformat(r[2]), as_of) for r in site_rows)
prior_rate = tot_k / tot_t
b0 = 3.0
a0 = prior_rate * b0
def p_at_least(need, a, b, horizon):
"""Negative-binomial predictive: P(X >= need), X ~ Poisson(lam*horizon), lam ~ Gamma(a, b)."""
if need <= 0:
return 1.0
p = b / (b + horizon)
cdf = sum(math.exp(math.lgamma(a + x) - math.lgamma(a) - math.lgamma(x + 1)
+ a * math.log(p) + x * math.log(1 - p)) for x in range(need))
return 1 - cdf
remaining = months(as_of, close)
print(f"pooled rate {prior_rate:.2f}/site-month, horizon {remaining:.1f} months")
print("site city active_m rand scr_fail% raw_rate shrunk_rate need P(hit)")
for site, city, act, target, k, scr in site_rows:
t = months(date.fromisoformat(act), as_of)
a, b = a0 + k, b0 + t
need = target - k
print(f"{site} {city:8} {t:6.1f} {k:4d} {100*(scr-k)/scr:8.0f} {k/t:8.2f} {a/b:11.2f} {need:4d} {p_at_least(need, a, b, remaining):6.2f}")Output:
pooled rate 0.81/site-month, horizon 6.1 months
site city active_m rand scr_fail% raw_rate shrunk_rate need P(hit)
S01 Pune 7.8 9 31 1.15 1.06 3 0.91
S02 Lucknow 6.4 3 62 0.47 0.58 9 0.04
S03 Kochi 3.9 4 33 1.03 0.94 6 0.47
S04 Jaipur 1.6 0 100 0.00 0.53 10 0.03Reading the four sites
S01, Pune needs three more randomisations in six months at about one a month. A 91% chance of hitting target — leave it alone, and consider it for extra allocation if another site closes.
S02, Lucknow is the real problem, and its 62% screen-failure rate says why: patients are being found but not qualifying. That is a conversation about the site's referral sources and the eligibility criteria, not about recruitment effort. At about 0.6 a month it needs nine more; a 4% chance.
S03, Kochi has a good rate but a short history. 47% means the decision is genuinely open; revisit in six weeks rather than acting now.
S04, Jaipur has zero randomisations after seven weeks. The raw rate says zero forever; the shrunk rate says "probably a little below an average site". Even so, ten participants in six months at that pace is unlikely (3%). The useful output here is not "close the site" but "this site's target is unrealistic at any plausible rate" — reallocate part of its target now, while there is time.
Why the shrinkage matters
A straight-line projection for S04 (0 per month) would declare the site dead; a projection that ignored its zero and used the study average would suggest it can still reach 10. Neither is defensible. The gamma prior with b₀ = 3 says, in effect, "until a site has about three months of its own history, lean towards the study's pooled experience." Choose b₀ with the study team and write it in the report. Test sensitivity: at b₀ = 1, new sites move faster towards their own data.
Traps in trial enrolment data
| Trap | What it does to the numbers | Fix |
|---|---|---|
| Counting screened as enrolled | Inflates weak sites with high screen failure | Use randomised only |
| Contract date as the start | Penalises sites with slow ethics approval or initiation | Start the clock at activation |
| Ignoring withdrawals | Overstates evaluable participants | Track randomised and still-on-study separately |
| One pooled rate for every region | Hides structural differences in patient pools | Pool within region, or add a region term |
| Straight-line projection | Ignores festive-season and monsoon dips in outpatient footfall | Check month-of-year patterns in past studies before assuming a flat rate |
Presenting it to the study team
Show the P(hit) column sorted ascending, with the reason beside each at-risk site — high screen failure, slow activation, zero activity — because the reason decides the action. Give the study-level view too: expected randomisations across all sites versus the total target, which answers "do we need new sites at all?" Keep the prior strength visible in a footnote so nobody mistakes a modelling choice for data.
Poisson-gamma models are a compact example of the Bayesian reasoning taught in our Data Science programme. If you are coming from site coordination work, clinical research coordinator to data scientist shows how to turn a tracker like this into a portfolio piece.
Frequently asked questions
How do you calculate a clinical trial site's enrolment rate?
Divide the number of randomised participants at the site by the months the site has been active, counted from its activation date rather than its contract date. Participants who failed screening do not count towards enrolment.
Why not just divide the remaining target by the site's current rate?
A new site with one or two months of data has a very noisy rate, so a straight-line projection either writes it off too early or trusts a lucky start. Pooling information across sites and reporting a probability of hitting target handles that uncertainty honestly.
Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.
This article is part of our Data Science programme — 6 months. From data foundations to machine learning, deep learning and deployment.
Explore Data Science