| Title: | Sandwich Mixed Randomization for Allocation Sequence Generation |
|---|---|
| Description: | Generates allocation sequences for randomized controlled trials using the sandwich mixed randomization (SMR) scheme, together with verification, diagnostics, and release utilities. |
| Authors: | Bingshun Wang [aut, cre] (Designer of the sandwich mixed randomization scheme), Jinliang Xie [ctb], Wei Wang [ctb] |
| Maintainer: | Bingshun Wang <[email protected]> |
| License: | Apache License (>= 2.0) |
| Version: | 1.0.0 |
| Built: | 2026-10-14 19:02:44 UTC |
| Source: | https://github.com/cran/sandwichrand |
sandwichrand generates treatment allocation sequences (randomization lists) for randomized controlled trials with the sandwich mixed randomization (SMR) scheme, and provides the routines needed to verify, document and release them.
The SMR sequence is a sandwich. Writing n1 for the size of the first
block and N for the total sample size, the layout is
|<--- n1 --->|<-------- N - 2*n1 -------->|<--- n1 --->| +------------+----------------------------+------------+ | block 1 | blocks 2 .. K | block K+1 | | imbalanced | permuted blocks of | complement | | | varying sizes | of block 1 | +------------+----------------------------+------------+
Block 1 and block K+1 are the two halves of one balanced pool of
2*n1 slots, so the last block is the exact complement of the first
one: balance over the whole trial is guaranteed while the next allocation
stays unpredictable. Two further paths handle the boundary cases:
N < 4 * ratio_sum gives complete randomization in one single block
(path 1), and N == 2 * n1 gives head and tail blocks only (path 3).
The head-block size n1 is always sampled at random from the
admissible multiples of ratio_sum (there is no need to pre-specify
it and no user override is accepted), and the imbalance of the first block
is measured by the spread of the deviations from the target
allocation, a generalisation that equals the between-group count difference
for a 1:1 allocation and that is zero for an exactly proportional block.
smr_allocgenerate one sequence;
smr_streamsderive independent random streams for high-frequency calling;
smr_verifyre-check every structural invariant of a sequence;
smr_balance, smr_imbalance,
smr_blocks, smr_spread
balance and diagnostics;
smr_correct_guesses, smr_pcg
evaluate allocation unpredictability (proportion of correct guesses);
smr_simulateassess a design by replication, reporting both the imbalance and the correct-guess proportion;
smr_rtf, smr_write_csv
release the list, with or without the block structure;
sandwichrand-methodsprint, summary and
plot methods.
Every sequence is a deterministic function of a single seed (or of a stream
that is handed to the call). The modules of one call draw from independent
L'Ecuyer-CMRG sub-streams derived from that seed, so the modules are
independent and a call to smr_alloc never changes the random
number state of the calling session. For high-frequency calling,
smr_streams derives a family of non-overlapping sub-streams
from one base seed; handing one stream to each call guarantees that no two
calls ever draw from the same random numbers, so a loop stays both
reproducible and free of repeated streams.
ICH E9 and good clinical practice require the block structure and the seed to
remain confidential. keep.blocks = FALSE drops the block columns from
$alloc and from every exported artefact, while the statistician's
copy of the structure stays available in $blocks for quality control.
sandwichrand is research software provided “AS IS”, without warranty of any kind, express or implied. It has not been validated as a computerised system under Good Clinical Practice (GCP), Good Clinical/Manufacturing Practice (GxP), 21 CFR Part 11, EU GMP Annex 11, or any other regulatory framework.
Before using sandwichrand to generate allocation sequences for a registration trial, or for any trial whose results may support a regulatory submission, the sponsor is solely responsible for: (i) independently validating the software and the generated sequences under its own quality system; (ii) maintaining allocation concealment (ICH E9), including keeping the seed, the block structure and any stream information confidential; and (iii) ensuring compliance with all applicable regulations.
The authors accept no liability for any consequences arising from the use of this software.
Bingshun Wang [email protected]
Wang B, Wang X, Ni C, Xie J, Wu S, Wu Y, Shen Y, Dong Z, Lv W, He Y (2026). “Reducing selection bias risk to enhance RCT validity: sandwich mixed randomization outperforms permuted block design.” BMC Medical Research Methodology, 26(1), 152. doi:10.1186/s12874-026-02877-2.
ICH (1998). E9: Statistical Principles for Clinical Trials.
print shows the design and the head of the allocation list,
summary adds the arm balance and the verification table,
as.data.frame extracts the allocation list itself, and plot
draws the running imbalance.
## S3 method for class 'sandwichrand' format(x, ...) ## S3 method for class 'sandwichrand' print(x, n = 6L, ...) ## S3 method for class 'sandwichrand' summary(object, ...) ## S3 method for class 'summary.sandwichrand' print(x, ...) ## S3 method for class 'sandwichrand' plot( x, y = NULL, type = "imbalance", main = "Running imbalance", xlab = "allocation number", ylab = NULL, col = 1, lwd = 2, ... ) ## S3 method for class 'sandwichrand' as.data.frame(x, row.names = NULL, optional = FALSE, ...)## S3 method for class 'sandwichrand' format(x, ...) ## S3 method for class 'sandwichrand' print(x, n = 6L, ...) ## S3 method for class 'sandwichrand' summary(object, ...) ## S3 method for class 'summary.sandwichrand' print(x, ...) ## S3 method for class 'sandwichrand' plot( x, y = NULL, type = "imbalance", main = "Running imbalance", xlab = "allocation number", ylab = NULL, col = 1, lwd = 2, ... ) ## S3 method for class 'sandwichrand' as.data.frame(x, row.names = NULL, optional = FALSE, ...)
x, object
|
An object of class |
... |
Further arguments passed to or from other methods. |
n |
Integer. Number of allocation rows echoed by |
y |
Ignored, present for compatibility with |
type |
Character. Which curve to draw: |
xlab, ylab, main, col, lwd
|
Graphical parameters. |
row.names |
Passed to |
optional |
Passed to |
as.data.frame returns the allocation data frame; format
returns a character vector; summary returns a
"summary.sandwichrand" object; the print and plot methods return their
input invisibly.
Draws a reproducible allocation sequence (randomization list) for a randomized controlled trial with the sandwich mixed randomization (SMR) scheme.
smr_alloc( N, arms, seed = NULL, stream = NULL, block.sizes = NULL, imbalance = c(1, 4), max.cand = 10L, max.iter = 1000L, keep.blocks = TRUE, verbose = FALSE )smr_alloc( N, arms, seed = NULL, stream = NULL, block.sizes = NULL, imbalance = c(1, 4), max.cand = 10L, max.iter = 1000L, keep.blocks = TRUE, verbose = FALSE )
N |
Integer. Total sample size. |
arms |
Specification of the treatment arms and of the allocation ratio.
Either a character vector with one token per allocation-ratio unit –
|
seed |
Integer. Random seed. |
stream |
Numeric vector. The raw state of an independent
L'Ecuyer-CMRG sub-stream (for example one of the values returned by
|
block.sizes |
Integer vector. Candidate sizes of the middle blocks.
Defaults to |
imbalance |
Numeric vector of length two, |
max.cand |
Integer. Maximum number of candidate values kept for
|
max.iter |
Integer. Maximum number of rejection-sampling attempts per module (default 1000). If no candidate qualifies, the closest one is used, so a sequence is always produced. |
keep.blocks |
Logical. |
verbose |
Logical. Emit the design notes and warnings as messages
(default |
The SMR sequence is a sandwich. Writing n1 for the size of the first
block and N for the total sample size, the layout is
|<--- n1 --->|<-------- N - 2*n1 -------->|<--- n1 --->| +------------+----------------------------+------------+ | block 1 | blocks 2 .. K | block K+1 | | imbalanced | permuted blocks of | complement | | | varying sizes | of block 1 | +------------+----------------------------+------------+
Block 1 and block K+1 are the two halves of one balanced pool of
2*n1 slots, so the last block is the exact complement of the first
one: overall balance is guaranteed by construction, while the imbalance
deliberately introduced in block 1 keeps the next allocation unpredictable.
The head-block size n1 is always sampled at random from the
admissible multiples of ratio_sum in [2*ratio_sum, N/2], so
there is no need to pre-specify it and the block structure is never revealed
in advance.
Two further paths handle the boundary cases:
N < 4 * ratio_sum: complete randomization with a
pre-determined N, in one single block.
N == 2 * n1: head and tail blocks only, no middle
section.
An object of class "sandwichrand", a list with the elements
callthe matched call.
designlist describing the design actually used:
N, arms, ratio, ratio_sum, n_arms,
path, n1, n_mid, n_mid_blocks,
imbalance_observed, imbalance_target, seed, ...
allocdata frame with columns id, block,
block_size (the latter two only when keep.blocks = TRUE)
and arm.
balancedata frame with arm, n,
expected and deviation.
blocksdata frame with one row per block: block,
size, first_id, last_id and the arm counts.
notes, warnings
character vectors.
specthe normalized specification, used by
smr_reproduce.
Wang B, Wang X, Ni C, Xie J, Wu S, Wu Y, Shen Y, Dong Z, Lv W, He Y (2026). “Reducing selection bias risk to enhance RCT validity: sandwich mixed randomization outperforms permuted block design.” BMC Medical Research Methodology, 26(1), 152. doi:10.1186/s12874-026-02877-2.
smr_verify, smr_balance,
smr_imbalance, smr_blocks,
smr_rtf, smr_write_csv,
smr_streams.
## Two-arm 1:1 trial, N = 48 -- the illustration of the SMR paper x <- smr_alloc(N = 48, arms = c("TestTrt", "Placebo"), seed = 84529052) x head(x$alloc) x$balance ## Three-arm 2:2:1 trial, N = 150, released without the block structure y <- smr_alloc(N = 150, arms = c("A", "A", "B", "B", "C"), seed = 123456, keep.blocks = FALSE) names(y$alloc) smr_balance(y) ## Small trial: path 1, complete randomization in one block z <- smr_alloc(N = 9, arms = c("Drug", "Placebo"), seed = 777) z$design$path ## High-frequency calling: derive independent streams up front so that no ## two calls ever draw from the same random stream ss <- smr_streams(seed = 42, n = 3) head(smr_alloc(N = 48, arms = c("A", "B"), stream = ss[[1]])$alloc) head(smr_alloc(N = 48, arms = c("A", "B"), stream = ss[[2]])$alloc)## Two-arm 1:1 trial, N = 48 -- the illustration of the SMR paper x <- smr_alloc(N = 48, arms = c("TestTrt", "Placebo"), seed = 84529052) x head(x$alloc) x$balance ## Three-arm 2:2:1 trial, N = 150, released without the block structure y <- smr_alloc(N = 150, arms = c("A", "A", "B", "B", "C"), seed = 123456, keep.blocks = FALSE) names(y$alloc) smr_balance(y) ## Small trial: path 1, complete randomization in one block z <- smr_alloc(N = 9, arms = c("Drug", "Placebo"), seed = 777) z$design$path ## High-frequency calling: derive independent streams up front so that no ## two calls ever draw from the same random stream ss <- smr_streams(seed = 42, n = 3) head(smr_alloc(N = 48, arms = c("A", "B"), stream = ss[[1]])$alloc) head(smr_alloc(N = 48, arms = c("A", "B"), stream = ss[[2]])$alloc)
Returns the arm counts, the expected counts under the target allocation and the deviation of each arm from its target.
smr_balance(x)smr_balance(x)
x |
An object of class |
A data frame with columns arm, n, expected and
deviation.
x <- smr_alloc(N = 150, arms = c("A", "A", "B", "B", "C"), seed = 3) smr_balance(x)x <- smr_alloc(N = 150, arms = c("A", "A", "B", "B", "C"), seed = 3) smr_balance(x)
Returns one row per block with its size, the first and last allocation ids
and the arm counts. This is the statistician's copy of the block structure
(kept even when keep.blocks = FALSE was used at generation time).
smr_blocks(x)smr_blocks(x)
x |
An object of class |
A data frame, or NULL if no block structure is stored.
x <- smr_alloc(N = 48, arms = c("A", "B"), seed = 1) smr_blocks(x)x <- smr_alloc(N = 48, arms = c("A", "B"), seed = 1) smr_blocks(x)
Evaluates allocation unpredictability of a generated sequence with the
Blackwell-Hodges “convergence” guessing strategy. At each allocation
step the next assignment is predicted to go to the treatment arm that is
most under-allocated relative to the target allocation ratio; ties are
broken uniformly at random. CG(i) equals 1 when the guess is right,
0 when it is wrong, and 1/k when k arms are tied for the
largest deficit. For a two-arm 1:1 trial this is exactly the definition of
Wang et al. (2026): guess the arm with the fewer prior allocations, with
CG(i) = 0.5 when the two counts are equal.
smr_correct_guesses(x)smr_correct_guesses(x)
x |
An object of class |
A numeric vector of length N with the per-step correct-guess
indicators CG(1), ..., CG(N). The first element is always
1/n_arms (no prior information), which is 0.5 for 1:1.
Blackwell D, Hodges JL (1957). Design for the control of selection bias. Annals of Mathematical Statistics, 28(2), 449–460.
Wang B, Wang X, Ni C, Xie J, Wu S, Wu Y, Shen Y, Dong Z, Lv W, He Y (2026). “Reducing selection bias risk to enhance RCT validity: sandwich mixed randomization outperforms permuted block design.” BMC Medical Research Methodology, 26(1), 152. doi:10.1186/s12874-026-02877-2.
smr_pcg, smr_imbalance,
smr_simulate.
x <- smr_alloc(N = 48, arms = c("A", "B"), seed = 84529052) smr_correct_guesses(x)x <- smr_alloc(N = 48, arms = c("A", "B"), seed = 84529052) smr_correct_guesses(x)
For each allocation position, computes the spread of the cumulative deviations of the arms from their targets. For a 1:1 allocation this is the running difference between the two arms; it is the quantity the SMR scheme is designed to keep small and that starts (by construction) at the head block's deliberate imbalance.
smr_imbalance(x)smr_imbalance(x)
x |
An object of class |
A numeric vector of length N, the running imbalance.
x <- smr_alloc(N = 48, arms = c("A", "B"), seed = 1) tail(smr_imbalance(x))x <- smr_alloc(N = 48, arms = c("A", "B"), seed = 1) tail(smr_imbalance(x))
The mean of smr_correct_guesses, i.e. the expected proportion
of correct guesses (PCG). A value close to 0.5 means the sequence is
about as unpredictable as complete randomization; values well above
0.5 mean the next allocation can be anticipated from the running
imbalance, which is the predictability that the SMR scheme is designed to
reduce.
smr_pcg(x)smr_pcg(x)
x |
An object of class |
A single number in [0, 1], the proportion of correct guesses.
smr_correct_guesses, smr_simulate.
x <- smr_alloc(N = 48, arms = c("A", "B"), seed = 84529052) smr_pcg(x) ## Expected PCG over many replications s <- smr_simulate(N = 48, arms = c("A", "B"), reps = 200, seed = 11) sx <- smr_alloc(N = 48, arms = c("A", "B"), seed = 84529052) smr_pcg(x) ## Expected PCG over many replications s <- smr_simulate(N = 48, arms = c("A", "B"), reps = 200, seed = 11) s
Re-runs the generator with the specification and the seed stored in an
"sandwichrand" object. The result is identical to the original object,
which is the property an auditor needs: the seed plus the package version
fully determine the list.
smr_reproduce(x)smr_reproduce(x)
x |
An object of class |
A new object of class "sandwichrand", identical to x.
x <- smr_alloc(N = 48, arms = c("A", "B"), seed = 2026) identical(smr_reproduce(x)$alloc, x$alloc)x <- smr_alloc(N = 48, arms = c("A", "B"), seed = 2026) identical(smr_reproduce(x)$alloc, x$alloc)
Produces the deliverable randomization list: the allocation numbers with their treatment (and, optionally, the block structure), followed by the arm balance table. The seed is printed in the report, which therefore has to be handled as confidential trial material (ICH E9 / GCP).
smr_rtf( x, file, title1 = "The sandwich mixed random-allocation schedule", title2 = NULL, keep.blocks = x$design$keep.blocks )smr_rtf( x, file, title1 = "The sandwich mixed random-allocation schedule", title2 = NULL, keep.blocks = x$design$keep.blocks )
x |
An object of class |
file |
Path of the RTF file to write. |
title1 |
Main title of the report. |
title2 |
Subtitle; |
keep.blocks |
Logical. Print the block columns. Defaults to the setting
used when the sequence was generated; |
The path file, invisibly.
x <- smr_alloc(N = 12, arms = c("Drug", "Placebo"), seed = 777) f <- tempfile(fileext = ".rtf") smr_rtf(x, f)x <- smr_alloc(N = 12, arms = c("Drug", "Placebo"), seed = 777) f <- tempfile(fileext = ".rtf") smr_rtf(x, f)
Runs reps independent replications of smr_alloc with
independent random streams and summarises the two key performance metrics of
a randomization procedure: the final and the maximum running imbalance
(allocation balance) and the proportion of correct guesses, PCG (allocation
unpredictability). This is the routine to use when a design (sample size,
arms, imbalance window) needs to be assessed before a trial is started.
smr_simulate(N, arms, reps = 100L, seed = NULL, ...)smr_simulate(N, arms, reps = 100L, seed = NULL, ...)
N |
Integer. Total sample size. |
arms |
Treatment-arm specification, see |
reps |
Integer. Number of replications. |
seed |
Integer. Base seed; the |
... |
Further arguments passed to |
An object of class "smr_simulation", a list with
reps, final_imbalance, max_imbalance, pcg
(the proportion of correct guesses in each replication), the mean running
curves imbalance_curve and pcg_curve (one value per
allocation step), and n1.
s <- smr_simulate(N = 48, arms = c("A", "B"), reps = 200, seed = 11) ss <- smr_simulate(N = 48, arms = c("A", "B"), reps = 200, seed = 11) s
spread is the difference between the largest and the smallest
deviation of the arm counts from the target allocation
count_g - n * mult_g / ratio_sum. For a 1:1 allocation it is exactly
the difference of the two arm sizes (the imbalance used in the SMR paper);
for unequal ratios it is the natural generalisation (spread = 0
means exactly proportional).
smr_spread(cnt, n, mult, ratio_sum)smr_spread(cnt, n, mult, ratio_sum)
cnt |
Numeric vector of arm counts, in the declared arm order. |
n |
Numeric. Number of slots the counts are expressed for. |
mult |
Integer vector. Allocation-ratio units of each arm. |
ratio_sum |
Numeric. Sum of |
A single numeric value, the spread.
## 1:1, 10 slots, counts 7 / 3 -> spread 4 smr_spread(c(7, 3), 10, c(1, 1), 2) ## 2:2:1, 10 slots, counts 5 / 4 / 1 -> spread 2 smr_spread(c(5, 4, 1), 10, c(2, 2, 1), 5)## 1:1, 10 slots, counts 7 / 3 -> spread 4 smr_spread(c(7, 3), 10, c(1, 1), 2) ## 2:2:1, 10 slots, counts 5 / 4 / 1 -> spread 2 smr_spread(c(5, 4, 1), 10, c(2, 2, 1), 5)
Derives n non-overlapping L'Ecuyer-CMRG sub-streams from one base
seed. Each returned value is the raw RNG state of a stream and can be passed
directly to smr_alloc via its stream argument. Because
the streams do not overlap, a loop that hands one stream per call never
re-uses the same random numbers, which is what keeps high-frequency calling
safe and reproducible.
smr_streams(seed, n)smr_streams(seed, n)
seed |
Integer. Base random seed. |
n |
Integer. Number of independent streams to derive. |
A list of n elements, each the numeric state of an
independent L'Ecuyer-CMRG stream.
ss <- smr_streams(seed = 1, 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]]) ## the two sequences are different even though they share the base seed !identical(a$alloc, b$alloc)ss <- smr_streams(seed = 1, 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]]) ## the two sequences are different even though they share the base seed !identical(a$alloc, b$alloc)
Re-checks every invariant that the SMR scheme promises: the length and the
numbering of the sequence, the final balance, and – when the block
structure is available – the consistency of the blocks and the
complementarity of the first and the last block. The function works on any
"sandwichrand" object, so a list received from a third party can be
re-checked independently of how it was produced.
smr_verify(x, stop.on.fail = FALSE)smr_verify(x, stop.on.fail = FALSE)
x |
An object of class |
stop.on.fail |
Logical. Stop with an error when at least one check
fails, instead of returning the table (default |
A data frame of class "smr_check" with one row per check and
the columns check, status ("PASS" or "FAIL")
and detail. Invisibly the same value when stop.on.fail =
TRUE and every check passes.
x <- smr_alloc(N = 48, arms = c("TestTrt", "Placebo"), seed = 84529052) v <- smr_verify(x) all(v$status == "PASS")x <- smr_alloc(N = 48, arms = c("TestTrt", "Placebo"), seed = 84529052) v <- smr_verify(x) all(v$status == "PASS")
Releases the allocation list as a comma-separated file. The block columns
are included only when keep.blocks = TRUE; otherwise the released
file contains only the allocation number and the treatment, as required by
ICH E9 and good clinical practice.
smr_write_csv(x, file, keep.blocks = x$design$keep.blocks)smr_write_csv(x, file, keep.blocks = x$design$keep.blocks)
x |
An object of class |
file |
Path of the CSV file to write. |
keep.blocks |
Logical. Write the block columns. Defaults to the setting used when the sequence was generated. |
The path file, invisibly.
x <- smr_alloc(N = 12, arms = c("Drug", "Placebo"), seed = 777, keep.blocks = FALSE) f <- tempfile(fileext = ".csv") smr_write_csv(x, f)x <- smr_alloc(N = 12, arms = c("Drug", "Placebo"), seed = 777, keep.blocks = FALSE) f <- tempfile(fileext = ".csv") smr_write_csv(x, f)