Situation: We have a base assignment vector z of 1s and 0s, and we’re taking k samples from the ntotal! permutations of z.
Symbols
- ntreat : number of treated cases
- nctrl : number of control cases
- ntotal=ntreat+nctrl
- k : number of draws of assignment vectors
- Ncomb=(ntreatntotal) : number of unique assignment vectors
Probability of any duplicates
Derivation
Goal: Probability that there are one or more duplicate assignment vectors in the k samples. I.e., the probability that we can find at least 2 of the k draws have the same pattern of 1s and 0s.
We’ll start with the probability that all draws are distinct assignment vectors, then invert it.
The first draw produces as assignment vector z1. The second permutation is drawn uniformly from the ntotal! permutations, and z2 (the assignment vector it produces) is drawn uniformly from the Ncomb assignment vectors. The probability that z2=z1 is
Pr(z2=z1)=NcombNcomb−1.
Likewise,
Pr(z3∈{z1,z2}∣z2=z1))=NcombNcomb−2,
and so on.
Therefore
P(zi all distinct )=1×NcombNcomb−1×NcombNcomb−2×⋯×NcombNcomb−(k−1)=i=0∏k−1NcombNcomb−i
and
P(zi not all distinct )=1−i=0∏k−1(NcombNcomb−i)
Note: This is also the “birthday problem”.
Python code
from math import comb, log, exp # comb requires version >= 3.8
def collision_prob(num_cases, num_treat, num_draws):
num_combos = comb(num_cases, num_treat)
if num_draws > num_combos:
return 1.0
# Compute the probability of no collisions
# Using log and exp might help with large `num_combos`
log_prob = sum(
log(num_combos - j) - log(num_combos)
for j in range(num_draws)
)
# Convert to probability of 1+ collisions
return 1 - exp(log_prob)
Expected number of duplicates
Derivation
For each unique pattern i, the probability it appears at least once in k samples is
P(pattern i appears)=1−P(pattern i never appears)=1−(NcombNcomb−1)k
By linearity of expectation,
E[# distinct]=i=1∑NcombP(pattern i appears)=Ncomb(1−(1−Ncomb1)k)
Then, since
# distinct+# duplicates=k,
we have
E[# duplicates]=k−E[# distinct]=k−Ncomb(1−(1−Ncomb1)k)=k+Ncomb((1−Ncomb1)k−1)
Expected fraction of duplicates
Derivation
Seems easy:
E[duplicates/k]=1+kNcomb((1−Ncomb1)k−1).
Unfortunately, this is difficult to compute when Ncomb is large.
A couple functions will help with accuracy
- log1p(x) = log(1 + x)
- expm1(x) = exp(x) - 1
Using log1p and expm1,
E[duplicates/k]=1+kNcomb((1−Ncomb1)k−1)=1+kNcomb(exp(log(1−Ncomb1)k)−1)=1+kNcombexpm1(log(1−Ncomb1)k)=1+kNcombexpm1(k⋅log(1−Ncomb1))=1+kNcombexpm1(k⋅log1p(−Ncomb1))
For very large Ncomb, we’ll want to work with α=k/Ncomb, and we’ll use
(1−1/Ncomb)k≈exp(−k/Ncomb)=exp(−α).
Then
E[duplicates/k]=1+kNcomb((1−Ncomb1)k−1)≈1+kNcomb(exp(−k/Ncomb)−1)=1+α1(exp(−α)−1)=1+expm1(−α)/α
When Ncomb is very large, we’ll also want to use log(Ncomb) in some intermediate calculations, via
Ncomb=exp(log(Ncomb)),
and helper functions
- gamma(x) = (x - 1)! when x is a positive integer, i.e., gamma(x + 1) = factorial(x)
- gammaln(x) = log(gamma(x)).
Then
log(Ncomb)=log(ntreat!⋅nctrl!ntotal!)=gammaln(ntotal+1)−gammaln(ntreat+1)−gammaln(nctrl+1),
and
α=exp(log(k)−log(Ncomb)).
Python code
from math import comb, log, exp, log1p, expm1
# comb requires version >= 3.8
from scipy.special import gammaln
def expected_duplicate_fraction(num_cases, num_treat, num_iter):
"""
Expected fraction of draws that are duplicates: E[dup]/k
Uses an approximation for large `num_cases`
Parameters
----------
num_cases : int - number of cases
num_treat : int - number of 1's
num_iter : int - number of draws
Returns
-------
float - fraction of duplicates (between 0 and 1)
"""
log_N = (
gammaln(num_cases + 1) -
gammaln(num_treat + 1) -
gammaln(num_cases - num_treat + 1)
)
# Case 1: N is manageable
if log_N < 700:
N = comb(num_cases, num_treat)
expected_distinct = -N * expm1(num_iter * log1p(-1/N))
return 1 - expected_distinct / num_iter
# Case 2: N is very large
log_k = log(num_iter)
alpha = exp(log_k - log_N) # k/N
if alpha < 1e-10:
# For very small α, use Taylor series to avoid cancellation
frac_dup = alpha / 2 - alpha**2 / 6 + alpha**3 / 24
else:
frac_dup = 1 + expm1(-alpha) / alpha
return frac_dup