Randomization is the cornerstone of the randomized controlled trial (RCT). A randomization procedure must satisfy two competing objectives simultaneously: it must produce comparable treatment groups (allocation balance), and it must prevent anyone from guessing the next allocation (allocation unpredictability). The permuted block design (PBD), still the dominant procedure in practice, achieves near-perfect balance at the price of predictability: once an investigator infers the block structure, the final assignment inside a block becomes foreseeable.
The Sandwich Mixed Randomization (SMR) scheme of Wang et al. (2026) resolves this tension. An SMR sequence is a “sandwich”:
|<--- n1 --->|<-------- N - 2*n1 -------->|<--- n1 --->|
+------------+----------------------------+------------+
| block 1 | blocks 2 .. K | block K+1 |
| imbalanced | permuted blocks of | complement |
| | varying sizes | of block 1 |
+------------+----------------------------+------------+
The two end blocks are deliberately and controllably imbalanced, so
that the first part of the sequence remains unpredictable; the middle
part uses permuted blocks with varying block sizes; and the last block
is the exact complement of the first, so that overall balance is
guaranteed by construction. The sandwichrand package is an
efficient, reproducible R implementation of this scheme.
The head and tail blocks both have size n1. The size
n1 is a multiple of ratio_sum (the sum of the
allocation-ratio units, e.g. 2 for 1:1, 5 for 2:2:1) and is sampled
uniformly at random from the admissible values in
[2*ratio_sum, N/2], keeping at most 10 candidate values.
This means the block structure is not fixed in advance and cannot be
predicted.
A pool of 2*n1 slots is built in exact proportion to the
target allocation and shuffled by Fisher–Yates. The first
n1 slots form the head block and the remaining
n1 slots form the tail block, so the two are complementary
and their union is exactly proportional. The shuffle is repeated
(replacement randomization) until the head block’s imbalance is
acceptable.
The imbalance is measured by the spread of the deviations from the target allocation,
spread = max_g( count_g - n * mult_g / ratio_sum )
- min_g( count_g - n * mult_g / ratio_sum ).
For a 1:1 allocation this is exactly the difference between the two
group sizes, i.e. the definition used in the paper; for unequal ratios
it is the natural generalization (spread = 0 means exactly
proportional). The default admissible window is [1, 4]; 4
is the “safety factor” discussed in the paper. The head block must be
strictly imbalanced (spread >= 1), otherwise
the scheme would degenerate into a plain permuted-block design.
The middle section holds N - 2*n1 slots, split into
blocks whose sizes are drawn at random from 2*ratio_sum,
3*ratio_sum and 4*ratio_sum (the “doubled,
tripled or quadrupled” sizes of the paper). At every step only feasible
sizes are drawn, so no “crumb” block is created. Each block is filled in
exact proportion to the target allocation (largest-remainder
apportionment) and then shuffled.
N < 4*ratio_sum: the trial
is too small for a sandwich, so a single block of size N is
produced by complete randomization.N == 2*n1: head and tail
blocks only, no middle section.sandwichrand is a pure R package (no compiled code)
depending only on base R and the parallel,
stats, graphics and utils
packages.
The public interface is small and orthogonal:
| Function | Purpose |
|---|---|
smr_alloc() |
Generate one allocation sequence. |
smr_streams() |
Derive independent random streams for high-frequency calling. |
smr_reproduce() |
Regenerate a sequence from its stored specification. |
smr_verify() |
Re-check every structural invariant. |
smr_balance() |
Arm counts and deviations from target. |
smr_blocks() |
Block structure (the statistician’s copy). |
smr_imbalance() |
Running imbalance along the sequence. |
smr_spread() |
Spread of deviations (the imbalance measure). |
smr_simulate() |
Assess a design by replication. |
smr_rtf() |
Write an RTF randomization report. |
smr_write_csv() |
Write a CSV randomization list. |
x <- smr_alloc(N = 48, arms = c("TestTrt", "Placebo"), seed = 84529052)
print(x, n = 8)
#> SMR allocation sequence (sandwich mixed randomization)
#> N = 48
#> arms = TestTrt : Placebo (ratio 1:1, ratio_sum = 2, 2 arms)
#> layout = 6 + 6 x 4/6/8 + 6 (path 2)
#> first block = n1 = 6, imbalance 2 (target [1, 4])
#> middle = 6 block(s), 36 slots, candidate sizes 4 6 8
#> seed = 84529052 (The seed was specified by the user.)
#> block columns = kept
#>
#> Allocation list (first 8 of 48 rows)
#> id block block_size arm
#> 1 1 6 TestTrt
#> 2 1 6 TestTrt
#> 3 1 6 TestTrt
#> 4 1 6 Placebo
#> 5 1 6 Placebo
#> 6 1 6 TestTrt
#> 7 2 8 TestTrt
#> 8 2 8 TestTrt
#>
#> Arm balance
#> arm n expected deviation
#> TestTrt 24 24 0
#> Placebo 24 24 0The arm balance is exact (24 vs 24) and the block structure is stored
in $blocks:
y <- smr_alloc(N = 150, arms = c("A", "A", "B", "B", "C"), seed = 123456)
y$balance
#> arm n expected deviation
#> 1 A 60 60 0
#> 2 B 60 60 0
#> 3 C 30 30 0The arms argument accepts a character vector (one token
per ratio unit), a single blank-separated string, or a named ratio
vector:
In a multi-centre RCT the treatment groups must be balanced
within every centre, not only overall, otherwise centre
and arm become confounded. The standard remedy is stratification
by centre: run smr_alloc() independently in each
centre and combine the per-centre lists. Each centre may have its own
sample size (and its own allocation ratio, if required). To keep the
whole multi-centre schedule reproducible from a single number, derive
one independent stream per centre with smr_streams() and
hand one to each call.
centers <- data.frame(
center = c("Site 01", "Site 02", "Site 03"),
n = c(48, 36, 24) # planned enrolment per centre
)
streams <- smr_streams(seed = 20260301, n = nrow(centers))
## One independent SMR run per centre, then one merged list
rand_list <- do.call(rbind, lapply(seq_len(nrow(centers)), function(i) {
x <- smr_alloc(N = centers$n[i], arms = c("TestTrt", "Placebo"),
stream = streams[[i]])
cbind(center = centers$center[i], x$alloc)
}))
rand_list$subject <- seq_len(nrow(rand_list)) # global sequential id
head(rand_list[, c("center", "subject", "id", "arm")], 10)
#> center subject id arm
#> 1 Site 01 1 1 TestTrt
#> 2 Site 01 2 2 Placebo
#> 3 Site 01 3 3 Placebo
#> 4 Site 01 4 4 TestTrt
#> 5 Site 01 5 5 Placebo
#> 6 Site 01 6 6 Placebo
#> 7 Site 01 7 7 Placebo
#> 8 Site 01 8 8 Placebo
#> 9 Site 01 9 9 TestTrt
#> 10 Site 01 10 10 TestTrt## Balance is exact within every centre (and therefore overall)
with(rand_list, table(center, arm))
#> arm
#> center Placebo TestTrt
#> Site 01 24 24
#> Site 02 18 18
#> Site 03 12 12Each centre’s block structure is still available in its own
smr_alloc() object for quality control. The released list
drops it and is written with write.csv():
Every structural invariant promised by the scheme is checked by
smr_verify(), and a randomized check of the design matrix
is run by the test suite.
v <- smr_verify(x)
v
#> SMR verification report
#> [PASS] rows : rows = 48, expected 48
#> [PASS] arms_complete : unknown or missing arm values = 0
#> [PASS] ids : distinct ids = 48, range [1, 48], expected 48
#> [PASS] balance : max |deviation| = 0
#> [PASS] seed_or_stream : seed = 84529052, stream_used = FALSE
#> [PASS] blocks_sum : sum of block sizes = 48, expected 48
#> [PASS] blocks_contiguous : blocks cover ids 1..48 contiguously
#> [PASS] n1_equal : head block size = 6, tail block size = 6
#> [PASS] n1_min : head block size = 6, minimum = 4
#> [PASS] complementarity : combined head+tail deviation from target = [0.000, 0.000]
all(v$status == "PASS")
#> [1] TRUEThe running imbalance starts at the head block’s deliberate imbalance and returns to at most 1 by the end of the sequence:
A design can be assessed by replication on the two key performance
metrics of a randomization procedure — allocation
balance (the absolute imbalance E|D(i)|, with
D(i) = n_A(i) - n_B(i)) and allocation
unpredictability (the Blackwell–Hodges proportion of correct
guesses, PCG):
s <- smr_simulate(N = 48, arms = c("A", "B"), reps = 1000, seed = 11)
s
#> SMR design assessment by simulation
#> replications = 1000
#> final imbalance : mean 0, median 0, max 0, P(|imb| <= 1) 1
#> maximum imbalance : mean 4.793, median 5, 95% quantile 7
#> correct-guess PCG : mean 0.5601146, median 0.5625, min 0.5104167, max 0.6666667
#> first block size n1 : 4 .. 22The per-step correct guesses and their mean are also available for a
single sequence. Under the convergence strategy the next assignment is
predicted to go to the arm with the fewer prior allocations
(0.5 on ties); a PCG close to 0.5 means the
sequence is about as unpredictable as complete randomization:
Every sequence is a deterministic function of a single seed. The seed actually used is stored and can be reused:
x1 <- smr_alloc(N = 48, arms = c("A", "B"), seed = 2026)
x2 <- smr_alloc(N = 48, arms = c("A", "B"), seed = 2026)
identical(x1$alloc, x2$alloc) # TRUE
#> [1] TRUE
identical(smr_reproduce(x1)$alloc, x1$alloc) # TRUE
#> [1] TRUEFor high-frequency calling (e.g. a simulation loop),
smr_streams() derives a family of non-overlapping
L’Ecuyer-CMRG sub-streams from one base seed; each call is handed one
stream, so no two calls ever draw from the same random numbers, while
the whole loop remains reproducible:
ss <- smr_streams(seed = 42, n = 3)
a <- smr_alloc(N = 48, arms = c("A", "B"), stream = ss[[1]])
b <- smr_alloc(N = 48, arms = c("A", "B"), stream = ss[[2]])
!identical(a$alloc, b$alloc) # independent streams -> different sequences
#> [1] TRUEA call to smr_alloc() never changes the random number
state of the calling session.
parallel::nextRNGStream() guarantees non-overlapping
sub-streams (each about \(2^{127}\)
draws long). One call derives four module sub-streams (selection of
n1, Module I, Module II, Path 1), which are therefore
statistically independent. This mirrors and strengthens the
CALL STREAMINIT mechanism of the SAS macro.runif() call, identical to the naive loop but
about an order of magnitude faster for long vectors.ratio_sum and never more
than one slot from the quota otherwise.sandwichrand provides an efficient, reproducible and
auditable R implementation of the Sandwich Mixed Randomization scheme.
It simultaneously guarantees overall allocation balance and allocation
unpredictability, supports any number of arms and any allocation ratio,
samples the head-block size at random, and uses a spread-based imbalance
measure that is a natural generalization of the between-group difference
used for 1:1 designs. The package is ready for use in clinical trials
and for simulation studies that evaluate randomization procedures.