
A Fixed, Covariate-Homogeneous-Block Randomized Design
Source:R/design_fixed_optimal_blocks.R
DesignFixedOptimalBlocks.RdA fixed-sample-size DesignFixed that first partitions
subjects into B approximately equal-sized, covariate-homogeneous blocks by
(approximately or exactly) minimizing total within-block pairwise covariate distance
\(\sum_{k=1}^{B} \sum_{i, j \in \text{block } k, i < j} D(x_i, x_j)\), then
randomizes treatment independently within each resulting block at probability
prob_T (via block_ra, the same mechanism as
DesignFixedBlocking). Unlike
DesignFixedBlocking, which forms blocks from user-specified column-wise
strata (categorical levels / quantile bins), here blocks are formed directly from a
multivariate distance/clustering criterion over all covariates jointly, so this
design generalizes matched-pair designs
(DesignFixedBinaryMatch) from block size 2
to arbitrary block size \(B\). When B is omitted and n is known
at initialization, the default is floor(sqrt(n)), truncated below at 1 (the
usual heuristic block count for balancing within-block homogeneity against
within-block sample size).
Block-formation algorithms. method selects among three block
construction strategies with different exactness/scalability trade-offs (see the
new() argument documentation for details): "K-way" (default, balanced
k-means-style anticlustering via anticlust, fast and empirically close to
optimal), "greedy" (nearest-neighbor greedy matching via blockTools,
fast even for large \(n\)), and "ompr" (an exact mixed-integer program
solved with GLPK via ompr/ompr.roi, globally optimal but scaling as
\(O(n^2 B)\) in the number of decision variables — practical only for small
\(n\)). Block membership is computed lazily (on first draw, via
get_or_compute_block_ids()) and cached in private$block_ids for reuse
across replicates.
Distance specification ("ompr" only). dist selects the
pairwise distance \(D(x_i, x_j)\) the exact solver minimizes:
"euclidean", "sum_abs_diff" (sum of absolute coordinate differences),
"mahal" (default, Mahalanobis distance accounting for covariate
correlation/scale), or a user-supplied distance function. The "K-way" and
"greedy" methods use their own respective packages' built-in distance
conventions and do not consult dist.
No-covariate fallback. If the covariate matrix has zero columns, block
membership is assigned by simple round-robin (rep(seq_len(B), length.out = n))
rather than by any of the three clustering algorithms, since there is no covariate
information to cluster on.
Solver backend (method = "ompr" only). roi_solver selects the
MILP backend ompr.roi dispatches to, a closed set
c("glpk", "gurobi", "cplex") (default "glpk") – validated against this
set, not passed through to ROI::ROI_registered_solvers() unchecked, so a typo
or an unsupported solver name fails fast with a clear message rather than an opaque
ompr error three layers down. Gurobi and CPLEX are supported because they extend
which block-formation problems stay practical, not just which are expressible: GLPK's
branch-and-bound is single-threaded with no commercial-grade presolve/cutting-plane
machinery, while Gurobi/CPLEX are typically an order of magnitude faster on the same
MILP and solve in parallel, meaningfully extending the \(n\) for which the exact
"ompr" method stays practical. Scope is deliberately closed to these two for
now – not because other ROI plugins (CBC, SYMPHONY, ...) wouldn't work mechanically
(the dispatch is generic), but because Gurobi and CPLEX are the two most widely used
commercial solvers and the only ones worth a maintained step-by-step guide at this
point; extending the closed set is a small, low-risk addition later if a real need for
a third backend appears, not a reason to leave the set open-ended now.
Wiring up Gurobi:
Obtain a Gurobi license (a free academic license is available from Gurobi for non-commercial use) and install the Gurobi Optimizer itself. This sets up
GUROBI_HOMEand the license file (gurobi.lic, discoverable via theGRB_LICENSE_FILEenvironment variable or Gurobi's default search path) – entirely outside this package's control or dependency graph.Install Gurobi's own R package. Not available via CRAN – it ships inside the Gurobi installation itself:
R CMD INSTALL "$GUROBI_HOME/R/gurobi_<version>_R_<Rmajor.minor>.tar.gz"(exact filename/path depends on your Gurobi version and platform; see theR/subdirectory of your Gurobi install). This is the vendor interfaceROI.plugin.gurobiwraps – required even though it's not what you call directly.Install the ROI bridge package from CRAN:
install.packages("ROI.plugin.gurobi"). This package is on CRAN (it only depends on ROI + thegurobiR package from step 2 being present at load time) and is the only new artifact this class's own dependency graph ever touches.Verify: after
library(ROI.plugin.gurobi),"gurobi" %in% ROI::ROI_registered_solvers()should beTRUE.Pass
roi_solver = "gurobi"to the constructor.
Wiring up CPLEX:
Obtain an IBM CPLEX license (a free academic license is available from IBM) and install IBM ILOG CPLEX Optimization Studio.
Install Rcplex (CRAN), CPLEX's R interface. Unlike
ROI.plugin.gurobi, Rcplex is a source package that compiles against your local CPLEX installation – it needs to be pointed at your CPLEX SDK's include/lib directories at install time (typically viaconfigure.argstoinstall.packages(), naming your CPLEX version'scplex/include/cplex/lib/<platform>paths). The exact flag names and paths are CPLEX-version- and platform-specific – follow Rcplex's ownINSTALL/README instructions for your installed CPLEX version rather than a fixed command copied from here, since this changes across CPLEX releases.Install the ROI bridge package from CRAN:
install.packages("ROI.plugin.cplex")(depends on Rcplex from step 2 being present and working).Verify: after
library(ROI.plugin.cplex),"cplex" %in% ROI::ROI_registered_solvers()should beTRUE.Pass
roi_solver = "cplex"to the constructor.
Dependency-graph consequence: ROI.plugin.gurobi/ROI.plugin.cplex
(and Rcplex) are never added to Suggests – they're free/CRAN-
available themselves, but declaring them would misrepresent the dependency as
something install.packages("EDI", dependencies = TRUE) could satisfy, when the
vendor package/license underneath cannot be. The lazy-check pattern already used for
ompr/ompr.roi/ROI.plugin.glpk extends naturally: check
requireNamespace("ROI.plugin.gurobi"/"ROI.plugin.cplex") at solve time (not at
package load or class-definition time) for whichever roi_solver was requested,
and error informatively – naming the missing package and, if that's present but the
solve still fails, noting the likely cause is a missing vendor license/installation,
not something this class can diagnose further.
Bootstrap. draw_bootstrap_indices() resamples within blocks by
default (bootstrap_type = "within_blocks" or NULL, via
stratified_bootstrap_indices_cpp()) or resamples whole blocks otherwise (via
resample_group_rows_cpp()), mirroring the block structure in the resampling
scheme, as in DesignFixedBlocking.
References
Higgins, M. J., Sävje, F., and Sekhon, J. S. (2016). "Improving massive
experiments with threshold blocking." Proceedings of the National Academy of
Sciences, 113(27), 7369-7376, doi:10.1073/pnas.1510504113
, for optimal/near-optimal
covariate-based blocking prior to randomization. See also
randomized block
design for orientation and
Mahalanobis distance for
the default "ompr" distance.
Super classes
Design -> DesignFixed -> DesignFixedOptimalBlocks
Methods
+ inherited public methods from DesignFixed
+ inherited public methods from Design
Design$add_one_subject_response()Design$any_censoring()Design$applicable_inference_class_names()Design$assert_all_responses_recorded()Design$assert_all_subjects_arrived()Design$assert_even_allocation()Design$assert_fixed_sample()Design$capabilities()Design$check_experiment_completed()Design$draw_ws_according_to_design()Design$duplicate()Design$get_X()Design$get_X_imp()Design$get_X_raw()Design$get_design_formula()Design$get_edi_version_created()Design$get_effective_dead()Design$get_effective_time()Design$get_missingness_method()Design$get_n()Design$get_ordinal_levels()Design$get_original_ordinal_levels()Design$get_prob_T()Design$get_response_type()Design$get_response_type_original()Design$get_t()Design$get_w()Design$get_y()Design$get_y_L()Design$get_y_R()Design$get_y_original()Design$has_general_censoring()Design$incompatible_inference_classes_due_to_design_structure()Design$is_a_bernoulli_capable()Design$is_a_cluster_capable()Design$is_a_kk_matching_capable()Design$is_blocking_design()Design$is_fixed_sample_size()Design$is_matching_design()Design$prepare_for_resampling_replay()Design$randomization_family()Design$supports()Design$supports_randomization_draw()Design$supports_resampling()Design$supports_resampling_replay()Design$transform_y()Design$unavailable_inference_classes_due_to_missing_packages()Design$warm_all_subject_data_cache()
DesignFixedOptimalBlocks$supports_batch_w_pregeneration()
Returns TRUE so the calling framework pre-generates
all replicate w vectors for a simulation cell in one batch,
paying the one-time block-formation cost (K-way anticlustering,
greedy matching, or the exact ompr/GLPK solve) once per cell and
reusing the resulting block assignment across replicates, rather than
recomputing it per replicate.
DesignFixedOptimalBlocks$new()
Initialize a fixed optimal-blocks design. Block formation
itself is deferred until the first draw (see class documentation);
this constructor only validates and records configuration, including
checking that B (or its floor(sqrt(n)) default) admits a
feasible block-size partition of n when n is already
known.
Usage
DesignFixedOptimalBlocks$new(
B = NULL,
method = "K-way",
dist = "mahal",
roi_solver = "glpk",
response_type,
prob_T = 0.5,
include_is_missing_as_a_new_feature = TRUE,
n = NULL,
verbose = FALSE,
missingness_method = "impute",
design_formula = ~.,
seed = NULL
)Arguments
BNumber of blocks to form. If omitted and
nis supplied, defaults tofloor(sqrt(n)), with a minimum of 1.methodAlgorithm used to partition subjects into blocks.
"K-way"(default)Balanced k-means anticlustering via
anticlust::balanced_clustering. Requires the anticlust package. Produces well-spread blocks and is significantly faster than"greedy"(e.g., ~10x faster for \(n=200, p=10, B=10\)) while achieving a better within-block distance objective (e.g., ~4% lower)."greedy"Greedy nearest-neighbour matching via
blockTools::block. Requires the blockTools package. Fast even for large \(n\)."ompr"Exact mixed-integer programme solved with GLPK via ompr. Globally optimal but scales as \(O(n^2 B)\) in variables and is only practical for small \(n\).
distDistance specification used only when
method = "ompr". Either a function or one of"euclidean","sum_abs_diff", or"mahal". Default is"mahal".roi_solverMILP backend used only when
method = "ompr". A closed setc("glpk", "gurobi", "cplex"), default"glpk". See the class documentation's "Solver backend" and wiring-guide sections for the Gurobi/CPLEX setup steps.response_typeThe response type for the design.
prob_TTreatment assignment probability within each block.
include_is_missing_as_a_new_featureWhether to include missingness indicators.
nPlanned sample size.
verboseWhether to print progress messages.
missingness_methodHow to handle missing values in covariates.
design_formulaA formula object.
seedInteger seed for reproducibility.
Examples
des = DesignFixedOptimalBlocks$new(n = 9, response_type = 'continuous')
des$add_all_subjects_to_experiment(data.frame(x = rnorm(9)))
des$assign_w_to_all_subjects()