designit (R package) — v0.5.0
VERSION PIN — read this first. Everything below is read off designit 0.5.0 (
Version: 0.5.0,Date/Publication: 2024-03-21 14:30:05 UTC,Packaged: 2024-03-19 09:27:56 UTC). The R6 class members and function defaults have changed across releases (see the NEWS excerpts in §8:bc$n_availablewas removed in 0.3.0;optimize_design()itself was new in 0.2.0;$scoring_fis deprecated and now errors). Do not carry these signatures to another version without re-checking. Accessed 2026-07-13.
1. Citation
- Authors (from
DESCRIPTION,Authors@R): Iakov I. Davydov [aut, cre, cph]; Juliane Siebourg-Polster [aut, cph]; Guido Steiner [aut, cph]; Konrad Rudolph [ctb]; Jitao David Zhang [aut, cph]; Balazs Banfai [aut, cph]; F. Hoffman-La Roche [cph, fnd]. (Note:DESCRIPTIONspells the funder “F. Hoffman-La Roche”, with one n.) - Maintainer: Iakov I. Davydov iakov.davydov@roche.com
- Title: “Blocking and Randomization for Experimental Design”
- Version summarized: 0.5.0 · Published: 2024-03-21 · Repository: CRAN
- URLs: https://cran.r-project.org/package=designit · https://bedapub.github.io/designit/ · https://github.com/BEDApub/designit/ · BugReports: https://github.com/BEDApub/designit/issues
- License:
MIT + file LICENSE. The shippedLICENSEfile is the CRAN two-line template, in full:YEAR: 2024 COPYRIGHT HOLDER: designit authors - Depends:
R (>= 4.1.0). Imports:rlang (>= 0.4.0), dplyr (>= 1.0.0), purrr, ggplot2, scales, tibble, tidyr, assertthat, stringr, R6, data.table, stats.RoxygenNote: 7.3.1. - There is no designit method paper. The package has no associated publication.
DESCRIPTIONattributes only the OSAT score to a paper: it says the package provides “an implementation for OSAT score by Yan et al. (2012, doi:10.1186/1471-2164-13-689)”. The package documentation is therefore the citable artifact for designit itself. - Access date: 2026-07-13.
2. Access note
Read in full, from the CRAN source tarball designit_0.5.0.tar.gz (https://cran.r-project.org/src/contrib/),
which is the authoritative artifact behind both the CRAN page and the pkgdown site. Read: DESCRIPTION,
LICENSE, NAMESPACE, NEWS.md, README.md, all of R/ (the roxygen blocks in R/ are the source
of the reference manual), all of vignettes/, and the rendered vignettes in inst/doc/.
No paywall. No boundary on the API surface — signatures and defaults below are transcribed from
the R source, not from memory or from a prose rendering. Two vignettes are shipped pre-rendered
(vignettes/plate_layouts.Rmd and the plate-scoring examples are child= includes of
vignettes/cached/_*.html); I read the cached HTML, which is what the published vignette shows.
The “Reference manual” was not read as a separate PDF — its content is generated from the R/
roxygen blocks I read directly.
3. Thesis
Batch effects can be avoided at the design stage by assigning samples to batches/locations
intelligently rather than randomly: define a BatchContainer (the experiment’s dimensions) plus a
scoring function that reflects the contrasts of interest, and let a generic optimizer shuffle samples
to minimize that score.
4. Problem & context
From DESCRIPTION (verbatim, MIT):
“Intelligently assign samples to batches in order to reduce batch effects. Batch effects can have a significant impact on data analysis, especially when the assignment of samples to batches coincides with the contrast groups being studied.”
The NCS22_talk vignette (“designit: a flexible engine to generate experiment layouts”) frames why
pure randomization is not enough (verbatim, MIT; vignette §“Go fully random?”):
”* Could it be sufficient to randomly distribute samples across batches?
- Not necessarily!
- Often sample sizes are too small to avoid grouping by change
- Experimental constraints might not allow for a fully random layout”
and quotes Box: “Block what you can and randomize what you cannot.” (G. Box, 1978). The same
vignette demonstrates a seeded “gone wrong” case: set.seed(17) produces a bad random assignment of
31 grouped subjects into 3 batches (comment in source: # gives 'bad' random assignment).
5. Method / approach — recommended procedure and exact defaults
5.1 The two-object model
BatchContainer— R6 class. Holds locations (the experiment’s dimensions) and, once assigned, samples + the sample→location assignment.cloneable = FALSE(use$copy()).optimize_design()— a standalone exported function, not a method. It takes a container, returns a new container (it callsbatch_container$copy()internally, so it does not modify its argument in place).
The NCS22_talk vignette states this division (verbatim, MIT):
”* Data structure:
BatchContainerclass
- R6 object storing:
- Experiment dimensions (cages, plates…)
- Sample annotation
- Scoring functions for sample distribution
- Main function:
optimize_design()”
5.2 Method vs. standalone function — verified against NAMESPACE
The complete export list of 0.5.0 (NAMESPACE, verbatim) is:
BatchContainer, BatchContainerDimension, L1_norm, L2s_norm, accept_leftmost_improvement,
assign_from_table, assign_in_order, assign_random, batch_container_from_table,
compile_possible_subgroup_allocation, complete_random_shuffling, drop_order, first_score_only,
form_homogeneous_subgroups, generate_terms, get_order, mk_exponentially_weighted_acceptance_func,
mk_plate_scoring_functions, mk_simanneal_acceptance_func, mk_simanneal_temp_func,
mk_subgroup_shuffling_function, mk_swapping_function, optimize_design, optimize_multi_plate_design,
osat_score, osat_score_generator, plot_plate, shuffle_grouped_data, shuffle_with_constraints,
shuffle_with_subgroup_formation, sum_scores, worst_score
get_samples is NOT in that list. It is an R6 method: bc$get_samples(...). There is no
standalone get_samples() function in 0.5.0. Likewise $get_locations(), $move_samples(),
$score(), $copy(), $print(), $scores_table(), $plot_trace() are methods; $trace is a
public field; and $samples, $samples_attr, $assignment, $has_samples, $has_samples_attr,
$n_locations, $n_dimensions, $dimension_names, $scoring_f are active bindings.
Conversely assign_in_order(), assign_random(), assign_from_table(), optimize_design(),
osat_score(), osat_score_generator(), plot_plate() are standalone functions taking the
container as first argument.
5.3 Exact signatures and defaults (verbatim functional strings)
BatchContainer$new(locations_table, dimensions, exclude = NULL)
dimensions: “A vector or list of dimensions. Every dimension should have a name. Could be an integer vector of dimensions or a named list. Every value of a list could be either dimension size or parameters forBatchContainerDimension$new().” Three accepted spellings, all seen in the docs:dimensions = c("plate" = 3, "column" = 8, "row" = 6)(sizes → values1:size);dimensions = list("batch" = 3, "location" = 11);dimensions = list(plate = 3, row = list(values = letters[1:3]), column = list(values = c(1, 3))).exclude: adata.frameof locations to drop, columns must match the dimension names. Used only together withdimensions;locations_tableanddimensions/excludeare mutually exclusive (msg = "dimensions and exclude cannot be used together with locations_table").- There is no batch-vs-position dimension declaration. All dimensions are formally equal — a
container is just the cartesian product of its dimensions (
expand.grid()), minusexcluded rows. Which dimension is a “batch” and which is a “position” is decided later and only by the scoring function:osat_score_generator(batch_vars = "plate", ...)is what makesplatea batch;mk_plate_scoring_functions(row = "row", column = "column", ...)is what makesrow/columnpositions. (Read off the code; the docs never state a batch/position distinction because none exists.)
BatchContainerDimension$new(name, size = NULL, values = NULL)
- Requires
nameplus eithersizeorvalues.sizeimplies values1:size. Active bindings:$size,$short_info(e.g."mydim<size=10>"), both read-only.
bc$get_samples(assignment = TRUE, include_id = FALSE,
remove_empty_locations = FALSE, as_tibble = TRUE)
bc$get_locations()
bc$move_samples(src, dst, location_assignment) # either src&dst, or location_assignment, not both
bc$score(scoring)
bc$copy()
bc$scores_table(index = NULL, include_aggregated = FALSE)
bc$plot_trace(index = NULL, include_aggregated = FALSE, ...)
$move_samples()mutates in place (returnsinvisible(self)). The Basic-example vignette flags this twice, verbatim: “Warning: This will change your BatchContainer in-place.”as_tibble = FALSEreturns adata.table(lower overhead);include_id = TRUEkeeps.sample_id.
Assignment initializers (all return a new BatchContainer; all call $copy() first):
assign_in_order(batch_container, samples = NULL) # sample i -> location i, NA-pad the rest
assign_random(batch_container, samples = NULL) # assign_in_order() then sample() the assignment
assign_from_table(batch_container, samples) # samples table carries its own location columns
batch_container_from_table(tab, location_cols) # build container AND assign, from one table
optimize_design() will call assign_in_order() for you if you pass samples = and the
container is empty — so an explicit initializer call is optional (see the end-to-end example below,
which passes invivo_study_samples straight to optimize_design()).
The optimizer — full signature, every default (from R/optimize.R):
optimize_design(batch_container, samples = NULL,
scoring = NULL,
n_shuffle = NULL,
shuffle_proposal_func = NULL,
acceptance_func = accept_strict_improvement,
aggregate_scores_func = identity,
check_score_variance = TRUE,
autoscale_scores = FALSE, autoscaling_permutations = 100, autoscale_useboxcox = TRUE,
sample_attributes_fixed = FALSE,
max_iter = 1e4, min_delta = NA, quiet = FALSE)
Behavioural defaults worth pinning:
scoringis mandatory in practice despite defaulting toNULL:msg = "Scoring should be provided when calling optimize_design()".- Default shuffle = ONE pairwise swap per iteration. If both
n_shuffleandshuffle_proposal_funcareNULL, the optimizer buildsmk_swapping_function(n_swaps = 1)(which returns the internalpairwise_swapping).shuffle_proposal_functakes priority overn_shuffleif both are given. n_shuffleas a schedule: iflength(n_shuffle) > 1it is a per-iteration protocol andmax_iteris silently reduced:max_iter <- min(max_iter, length(n_shuffle), na.rm = T). Optimization also stops when the protocol is exhausted (the shuffle function returnsNULL).- Default acceptance = strict improvement, i.e.
accept_strict_improvement:all(current_score <= best_score) && any(current_score < best_score). (Note:accept_strict_improvementis@keywords internaland not exported; it is reachable only as the default value.) - Default aggregation =
identity(no aggregation; scores stay multi-dimensional). - Default annealing: none. SA is opt-in via
acceptance_func = mk_simanneal_acceptance_func(). max_iter = 1e4;min_delta = NA(off).min_deltastops when the euclidean distance between successive best aggregated score vectors drops below it.check_score_variance = TRUEby default — 100 random permutations, hard-fails on zero-variance subscores (see §9).- Returns the optimized
BatchContainer. (Doc bug: the roxygen says@return A trace object, but the function returnsbatch_container; the trace is inside it, atbc$trace.) bc$traceaccumulates across successiveoptimize_design()calls on the same container (each gets anoptimization_index), and storescall,seed,rng_kind,scores,aggregated_scores,elapsed, and the start/end assignment vectors. (NEWS.md0.5.0: “BatchContainer trace saves the random seed as well as RNG kind”.)
OSAT scoring:
osat_score(bc, batch_vars, feature_vars, expected_dt = NULL, quiet = FALSE)
osat_score_generator(batch_vars, feature_vars, quiet = FALSE)
batch_vars— “character vector with batch variable names to take into account for the score computation.”feature_vars— “character vector with sample variable names to take into account for score computation.” Both accept vectors:osat_score_generator(batch_vars = "plate", feature_vars = c("treatment", "dose")).osat_score()takes aBatchContaineror a plaindata.frame/data.table(one row per location) and returns a list:list(score = ..., expected_dt = ...).osat_score_generator()is the wrapper you actually pass tooptimize_design(scoring = ): it returns a closurefunction(bc)returning the bare numeric score, cachingexpected_dtacross iterations (“to take full advantage of the speed gain without managing the buffered objects in the user code”).expected_dt“does not change during the optimization process. So it is a good idea to cache this value.”- The exact score formula (
R/osat.R, verbatim):score <- with(merged_df, sum((N - .n_expected)^2)), where.n_expected = .n_samples * .freq_batch— i.e. sum of squared deviations of observed from expected counts over the (batch × feature-level) contingency table, with expectation = feature level count × batch frequency. Lower is better.
Other scoring functions — the choice designit offers:
mk_plate_scoring_functions(batch_container, plate = NULL, row, column, group,
p = 2, penalize_lines = "soft")
- Returns a list of scoring functions, one per plate (“List of scoring functions, one per plate,
that calculate a real valued measure for the quality of the group distribution (the lower the
better).”). Named
"Plate"for a single plate, elsepaste("Plate", plate_names). - This is the distance-based / spatial score: it builds a pairwise well-distance matrix
(
mk_dist_matrix(plate_x, plate_y, dist = "minkowski", p = 2, penalize_lines = "soft")) and scores so that “larger pairwise distances of samples of same group —> smaller overall score” (source comment). Exact per-group term (R/score_plates.R):score <- score + ((group_freq[["lengths"]][i]^-2.6) * (group_trial %*% (distance_matrix %*% group_trial)))^-2— note the hard-coded exponents-2.6and-2, which are not exposed as parameters and not explained anywhere in the docs. p: “p parameter for minkowski type of distance metrics. Special cases: p=1 - Manhattan distance; p=2 - Euclidean distance.” Defaultp = 2.penalize_lines: “How to penalize samples of the same group in one row or column of the plate. Valid options are: ‘none’ - there is no penalty and the pure distance metric counts, ‘soft’ - penalty will depend on the well distance within the shared plate row or column, ‘hard’ - samples in the same row/column will score a zero distance.” Default"soft".MAX_PLATE_DIM <- 200.
When to choose which score — what the docs actually say. The docs give a task-based rule, not a
statistical one, and state it only in the plate-layout material (cached _plate_layouts.html, verbatim):
“For optimization
optimize_multi_plate_design()iteratively callsoptimize_design()for different steps of the experiment. For across plate optimization osat scoring is used. For within plate optimization spatial scoreing is used. The order of the factors indicate their relative importance.”
So: OSAT score → balance a factor across batches/plates. Plate (distance) score → spread a factor
within a plate. The NCS22_talk vignette demonstrates the same split as an explicit “2-step
optimization” (OSAT first with osat_score_generator, then within-plate with
mk_plate_scoring_functions + shuffle_with_constraints(dst = plate == .src$plate) to keep swaps
inside a plate). The docs give no criterion beyond this across/within split — no guidance on
choosing p, on penalize_lines, or on when a distance score would be preferable to OSAT for the
same purpose.
Combining multiple scores (all supported; optimize_design(scoring = ) accepts a function or a
named list of functions, each returning a numeric vector):
- No aggregation (default).
aggregate_scores_func = identity+accept_strict_improvement: a solution is accepted only if every subscore is ≤ and at least one is <. - Priority order.
accept_leftmost_improvement(current_score, best_score, ..., tolerance = 0.0)— compares left-to-right, first score is most important;tolerancelets “improvement in a less important score to exhibit some influence”. Used throughout the vignettes asacceptance_func = ~ accept_leftmost_improvement(..., tolerance = 0.01)(purrr mapper syntax is supported foracceptance_func,aggregate_scores_func,shuffle_proposal_func). - Aggregators (exported):
first_score_only(scores, ...),worst_score(scores, na.rm = FALSE, ...)(= max / infinity-norm),sum_scores(scores, na.rm = FALSE, ...),L2s_norm(scores, ...)(squared L2),L1_norm(scores, ...). - Exponential down-weighting:
mk_exponentially_weighted_acceptance_func(kappa = 0.5, simulated_annealing = FALSE, temp_function = mk_simanneal_temp_func(T0 = 500, alpha = 0.8)); “Weight for the first score’s delta is 1, then the original delta multiplied with kappa^(p-1) for the p’th score”. Asserts0 < kappa < 1. - Normalization is the
autoscale_scoresroute (defaultFALSE). Setautoscale_scores = TRUEto “perform a transformation on the fly to equally scale scores to a standard normal. This makes scores more directly comparable and easier to aggregate.” Usesautoscaling_permutations = 100random permutations (floored at 20 regardless:max(20, floor(autoscaling_permutations))), and a boxcox transform ifautoscale_useboxcox = TRUEand thebestNormalizepackage is installed, else a plain mean/sd standardization. Note autoscaling only kicks in whenscore_dim > 1. The optimizer-examples vignette warns (verbatim): “by ‘normalizing’ the distribution of the scores we obtain values centered around zero, thus that the optimized scores are likely to be negative. We may also want to decrease the delta_min parameter to match the new numerical range.” And on L2: “we don’t use the auto-scaling in this case as the L2-norm based optimization would force both normalized scores towards zero, not the minimal (negative) value that would be desired in that case.” - No Pareto / multi-objective front is offered. Aggregation is scalarization (or the strict/leftmost rules).
Simulated annealing:
mk_simanneal_temp_func(T0, alpha, type = "Quadratic multiplicative")
mk_simanneal_acceptance_func(temp_function = mk_simanneal_temp_func(T0 = 500, alpha = 0.8))
simanneal_acceptance_prob(current_score, best_score, temp, eps = 0.1) # internal, not exported
type∈"Exponential multiplicative"(assertsalphain[0.8, 0.9]),"Logarithmic multiplicative"(assertsalpha > 1),"Quadratic multiplicative"(default;T0 / (1 + alpha * k * k); assertsalpha > 0),"Linear multiplicative"(T0 / (1 + alpha * k); assertsalpha > 0). Default type chosen because it “seems to perform well”.- SA requires a scalar score:
msg = "Multi-dimensional scores have to be aggregated for simulated annealing to work.\nPlease specify a suitable aggregation function." - Doc advice (verbatim): “It is generally recommended for SA to make small changes at each step, like allowing just 1 sample swap per iteration.”
Shuffle-proposal functions:
mk_swapping_function(n_swaps = 1)
complete_random_shuffling(batch_container, ...)
shuffle_with_constraints(src = TRUE, dst = TRUE)
mk_subgroup_shuffling_function(subgroup_vars, restrain_on_subgroup_levels = c(), n_swaps = 1)
shuffle_grouped_data(batch_container, allocate_var, keep_together_vars = c(), keep_separate_vars = c(),
n_min = NA, n_max = NA, n_ideal = NA, subgroup_var_name = NULL,
report_grouping_as_attribute = FALSE, prefer_big_groups = FALSE, strict = TRUE,
fullTree = FALSE, maxCalls = 1e6)
form_homogeneous_subgroups(batch_container, allocate_var, keep_together_vars = c(),
n_min = NA, n_max = NA, n_ideal = NA, subgroup_var_name = NULL,
prefer_big_groups = TRUE, strict = TRUE)
compile_possible_subgroup_allocation(subgroup_object, fullTree = FALSE, maxCalls = 1e6)
shuffle_with_subgroup_formation(subgroup_object, subgroup_allocations, keep_separate_vars = c(),
report_grouping_as_attribute = FALSE)
shuffle_with_constraints(src, dst)takes expressions evaluated against the samples/locations table;dstadditionally sees.src(the selected source row). The canonical use is confining swaps to one plate:shuffle_with_constraints(dst = plate == .src$plate).- Defaults inconsistency (read off the code, undocumented):
prefer_big_groupsdefaults toTRUEinform_homogeneous_subgroups()but toFALSEin theshuffle_grouped_data()wrapper that calls it. The two paths are not equivalent at their defaults. - A shuffle function must return “either a numeric vector or a list” — an atomic location-assignment
vector, or
list(src=, dst=), orlist(location_assignment=, samples_attr=). ReturningNULLsignals the end of the protocol and stops optimization. optimize_design()is documented as preferringn_shuffle: “It is recommended to use then_shuffleparameter to steer the optimization protocol.” And on fully random proposals: “Note that this is usually not a good strategy for converging to a solution.”
Multi-plate wrapper:
optimize_multi_plate_design(batch_container, across_plates_variables = NULL,
within_plate_variables = NULL,
plate = "plate", row = "row", column = "column",
n_shuffle = 1, max_iter = 1000, quiet = FALSE)
It internally runs OSAT across plates (with acceptance_func = accept_leftmost_improvement), then
loops plate-by-plate with mk_plate_scoring_functions() + mk_subgroup_shuffling_function(subgroup_vars = plate, restrain_on_subgroup_levels = curr_plate). See §9 for a serious latent bug in this
function. (Also: the NCS22_talk vignette calls it “the multi_plate_layout() function” in prose —
no such function exists; the exported name is optimize_multi_plate_design.)
Plotting:
plot_plate(.tbl, plate = plate, row = row, column = column,
.color, .alpha = NULL, .pattern = NULL,
title = paste("Layout by", rlang::as_name(rlang::enquo(plate))),
add_excluded = FALSE, rename_empty = FALSE)
.tbl may be a BatchContainer or a data.frame. .color is required (no default).
5.4 Complete, runnable end-to-end example (verbatim, MIT — from R/optimize.R @examples)
data("invivo_study_samples")
bc <- BatchContainer$new(
dimensions = c("plate" = 2, "column" = 5, "row" = 6)
)
bc <- optimize_design(bc, invivo_study_samples,
scoring = osat_score_generator("plate", "Sex"),
max_iter = 100
)
plot_plate(bc$get_samples(), .col = Sex)
(Note that this example passes .col = to plot_plate, which has no .col argument — it partially
matches .color. The vignettes consistently spell it .color.)
The fuller, idiomatic spine — samples data.frame → container → optimize → retrieve assignment
(verbatim, MIT; condensed from vignettes/basic_examples.Rmd):
n_samp <- nrow(samples)
n_loc_per_plate <- 48 - 4
n_plates <- ceiling(n_samp / n_loc_per_plate)
exclude_wells <- expand.grid(plate = seq(n_plates), column = c(1, 8), row = c(1, 6))
bc <- BatchContainer$new(
dimensions = c("plate" = n_plates, "column" = 8, "row" = 6),
exclude = exclude_wells
)
bc <- assign_random(bc, samples)
bc <- optimize_design(bc,
scoring = osat_score_generator(
batch_vars = "plate",
feature_vars = c("treatment", "dose")
),
# shuffling schedule
n_shuffle = c(rep(10, 200), rep(2, 400))
)
bc$plot_trace()
bc$get_samples(remove_empty_locations = TRUE) # <- the final assignment
(The vignette’s own prose describes this schedule as “swap 10 samples for 100 times, then swap 2
samples for 400 times”, while the code says rep(10, 200) — the prose and the code disagree on 100
vs 200.)
6. Key claims / findings
- designit’s OSAT score is intended to reproduce Yan et al. (2012)‘s, not approximate it. The
roxygen says the score “is intended to ensure even distribution of samples across batches and is
closely related to the chi-square test contingency table (Yan et al. (2012))”. The
osatvignette goes further and asserts numerical identity with the original OSAT package — see §7 and §12. - The default optimizer is a hill-climber, not an annealer.
accept_strict_improvement+ one pairwise swap per iteration +max_iter = 1e4. - Scores are minimized. “the lower the better” (plate scoring); OSAT is a sum of squared deviations from expected counts, so 0 = perfectly balanced.
- Multi-score optimization is first-class, with three distinct combination strategies (strict
dominance, leftmost priority with
tolerance, or scalarizing aggregation), plus optional autoscaling to a standard normal for comparability. - The two-step plate procedure is the package’s recommended plate recipe: OSAT across plates, then distance-based spatial scoring within each plate, with swaps constrained to stay within a plate.
- Constraint satisfaction is done in the shuffle function, not the score. The
shuffling_with_constraintsvignette’s key idea (verbatim): “every reshuffling produces a ‘valid’ sample permutation that is not violating those constraints, even if the suggested solution may be quite bad. During optimization, we pick the best design solution from the possible ones by appropriate scoring.” keep_separate_varsis explicitly a soft constraint: “This is a soft constraint and will be relaxed in a stepwise way until solutions can be found.”- Empirically demonstrated in the shipped vignette: with
set.seed(17), random assignment of 31 grouped subjects into 3 batches comes out unbalanced — the package’s motivating counter-example to “just randomize”.
7. Load-bearing statements — license-aware mode (MIT → verbatim permitted, marked)
License verified: DESCRIPTION says License: MIT + file LICENSE; LICENSE is the CRAN MIT template
(reproduced in §1). Short verbatim quotes are therefore permitted, each located below.
-
[VERBATIM —
R/osat.R, roxygen forosat_score()]“The OSAT score is intended to ensure even distribution of samples across batches and is closely related to the chi-square test contingency table (Yan et al. (2012) \doi{10.1186/1471-2164-13-689}).”
-
[VERBATIM —
vignettes/osat.Rmd, §“Using designit OSAT score implementation”] — the code compares designit’s score against the OSAT package’s own optimum value, and the comment asserts:bc$score(scoring_f) g_setup@metadata$optValue |> head(1) # should be identical -
[VERBATIM —
R/optimize.R,@param acceptance_func]“Defaults to strict improvement rule, i.e. all elements of a score have to be smaller or equal in order to accept the solution as better.”
-
[VERBATIM —
vignettes/NCS22_talk.Rmd, §“Continuous confounding”]“Assays are often performed in well plates (24, 96, 384) Observed effects
- Edge effects (bad plate sealing)
- Gradients (non-equal temperature distribution)
- Row / column effects (pipetting issues) Since plate effects often cannot be avoided, we aim to distribute sample groups of interest evenly across the plate and adjust for the effect computationally.”
-
[VERBATIM —
vignettes/NCS22_talk.Rmd, §“Batch effects matter”]“Due to the plate effect, the control rows are affected differently. It is virtually impossible to normalize readouts in a meaningful way.”
8. Stated scope, assumptions, limitations (the source’s own caveats)
- Lifecycle: experimental. README badge:
Lifecycle: experimental. - The API has churned, by the maintainers’ own record (
NEWS.md, verbatim excerpts):- 0.5.0: “remove several unexported functions”; “minimum R version 4.1.0”; “BatchContainer trace saves the random seed as well as RNG kind”.
- 0.3.0: “
bc$n_availablewas removed (usebc$n_locationsinstead)”; “BatchContainerstores locations table (dimensions & excluded)”. - 0.2.0: “new optimizer
optimize_design()”; “various API improvements”. - 0.1.0: “
BatchContainermajor API update”; “rename fields for consistency”; “hide cachind ($samples_dt) behind$get_samples()”. - Live deprecation in 0.5.0: assigning
$scoring_fraisesstop("scoring_f is deprecated, pass it to optimize_design() directly instead"); assigning$assignmentwarns “this field might become read-only in the future, please use $move_samples() instead”. This is the source’s own evidence that signatures drift between releases.
- SA needs scalar scores (asserted, see §5.3).
- Constraints cost balance. The
shuffling_with_constraintsvignette’s own closing comment (verbatim): “Obeying all constraints does not lead to a very balanced sample allocation”. - Strict subgrouping may have no solution:
strictis documented as “if TRUE, subgroup size constraints have to be met strictly, implying the possibility of finding no solution at all”; and the vignette: “It is often necessary to release thestrictcriterion to find any solution at all that satisfies those size criteria.” - Search-tree limits:
maxCalls = 1e6(“Maximum number of recursive calls in the search tree, to avoid long run times with very large trees”);MAX_PERMUTATIONS <- 1e6inmk_subgroup_shuffling_function. - Autoscaling is an estimate from random permutations (min 20), so it is stochastic.
9. Failure modes / invalidity patterns — with the exact detector strings
designit fails loudly, via assertthat, and the messages are exact strings (functional strings,
reproduced verbatim). This is the referee-relevant surface.
A. The optimizer refuses to run when a score cannot discriminate (the balance-not-achievable detector).
check_score_variance = TRUE (default) draws 100 random permutations (random_perm = 100,
hard-coded in the optimize_design() call to random_score_variances(), not the
autoscaling_permutations argument) and computes each subscore’s variance. Then:
- zero-variance subscore ⇒ hard abort:
"Low variance scores detected! Check scores # <i>"(threshold:low_var_scores <- score_vars < 1e-10). - non-evaluable ⇒ message
"Caution! Non-evaluable scores detected! Check scores # <i>". - On success it prints
"Checking variances of <n>-dim. score vector."followed by the variances and" - OK"(or" !!"). This is the closest thing to “the optimizer cannot balance this” — a score that cannot vary under permutation (e.g. a feature perfectly nested in the batch dimension, or a batch variable with a single level) stops the run rather than silently returning the input. The docs never describe it in those terms; the mechanism is read offR/optimize.R.
B. NAs are silently dropped from the OSAT score. warning("NAs in features / batch columns; they will be excluded from scoring") — and note osat_score_generator() sets quiet = quiet || !first_call,
so the warning fires only on the first iteration. Hard stop if a whole variable is NA:
msg = "All elements of one of the features / batches are NAs". This warning is visibly emitted in the
shipped plate-layout vignette output, meaning designit’s own worked example is scoring with NAs excluded.
C. Capacity / shape violations (all assertthat aborts):
"more samples than availble locations in the batch container"(note the typoavailble— this is the literal string)."sample assignment length doesn't match the number of available locations";"sample assignment does not match sample_ids (1..N_samples)";"sample assignment should an integer vector without Infs"."All locations in a container cannot be excluded";"Columns of exclude should match dimensions";"Some values are outside range in dimension '<dim_name>'"."batch container already has samples"— a container can be filled only once."some of the samples columns match batch container dimension names"— sample column names must not collide with dimension names..sample_idis reserved everywhere:"Cannot use reserved name for a dimension (.sample_id)","samples data.frame has a column with reserved name .sample_id", etc.- Sample-table hygiene, checked by
validate_samples():"Samples should be a data.frame or tibble","Non-unique rows in samples","Samples should have at least one row","Samples contain all-NA rows". Duplicate sample rows are rejected outright — identical replicate rows must be made unique first. stop("Samples lost or duplicated during exchange; check src and dst")— integrity check after a move.
D. Constraint-shuffling dead ends:
warning("Cannot find destanation matching the constraints")(literal typodestanation) —shuffle_with_constraintsreturnslist(src = NULL)whendstmatches nothing.msg = "source conditions not satisfied".msg = "Cannot form subgroups under strict setting with given constraints!".warning("More group allocations requested than in available blocks. No solution returned.")andwarning("Surplus block members. All available units are to be allocated. No solution returned.").msg = "Subgroup shuffling is pointless for small containers (n<10)";"Subgroup shuffling is pointless if there's only one subgroup involved";"Subgroup shuffling requires all subgroups to have a minimum size of 2";"Selected subgrouping variables should not contain any NA values".msg = "Subgroup shuffling would lead to more than 1e+06 possible permutations. Consider a different solution."
E. Scoring-contract violations: "scoring function should return a numeric vector of positive length"; "scoring cannot be a partially named list"; "score name cannot be 'step'";
"No samples in the batch container, cannot compute score"; and per-iteration
"NA apprearing during scoring in iteration <n>" (literal typo apprearing).
F. Plate-scoring preconditions: "Row and column coordinates for plate <p> must be integer values"
(coordinates must be integer, not numeric); "...must not include NAs"; "...must be within range 1...200"; "Group variable for plate <p> must not be all NAs"; "Plate coordinates must be unique for plate <p>"; "Non-unique row + column combination found. Please provide a plate variable."
G. Two latent bugs in 0.5.0 — read directly off the shipped code, NOT documented anywhere. These matter because they fail silently, not loudly:
-
optimize_multi_plate_design()reads a free variablebcinstead of its ownbatch_containerargument. InR/score_plates.Rthe function signature bindsbatch_container, but from line 232 onward the body usesbc:skip_osat <- ... dplyr::n_distinct(bc$get_locations()[[plate]]) < 2, thenbc <- optimize_design(bc, ...), and it returnsbc.bcis never assigned before first use, so R looks it up in the caller’s/global environment. The vignettes all happen to name their containerbc, which masks the bug. Consequence: callingoptimize_multi_plate_design(my_container, across_plates_variables = "Group", ...)will either error withobject 'bc' not found, or — worse — silently optimize whatever globalbchappens to exist, ignoring the container you passed. (R’s lazy evaluation means theacross_plates_variables = NULLpath short-circuits before touchingbc, so the bug only bites when you actually request across-plate optimization.) -
bc$excludedoes not exist;plot_plate(add_excluded = TRUE)therefore adds nothing.BatchContainerin 0.5.0 stores onlylocations_df(already anti-joined againstexcludeat construction) — there is noexcludefield or active binding. R6 returnsNULLfor an absent member, soplot_plate()’sexcluded <- .tbl$excludeisNULLand the laterdplyr::bind_rows(.tbl, excluded)is a no-op. Confirmed empirically from the package’s own rendered vignette (inst/doc/basic_examples.html, verbatim output):bc$exclude #> NULLYet the same vignette’s prose instructs: “To properly distinguish between empty and excluded locations one can do the following. * Supply the BatchContainer directly * set
add_excluded = TRUE…”. The documented feature is inert in 0.5.0. (plot_plate’s@param add_excludedeven says “flag to add excluded wells (in bc$exclude) to the plot”.)
H. Doc/code drift to be aware of: optimize_design()’s @return says “A trace object” but it
returns the BatchContainer (§5.3); NCS22_talk names a nonexistent multi_plate_layout(); the basic
vignette’s prose says “swap 10 samples for 100 times” where the code says rep(10, 200).
10. What the source does NOT address (confident silences)
- No statistical validity theory. There is no power analysis, no Type-I-error argument, no claim about what the optimized layout does to downstream inference. The docs never say “and therefore your estimates are unbiased.”
- No stopping rule / convergence criterion beyond
max_iterandmin_delta. Nothing tells you how many iterations are enough, and there is no diagnostic that says “this design is now good enough” or “the optimizer failed to balance”. Absolute OSAT score values are never given an interpretation (no “score < X means balanced”); the trace plot is the only offered read-out. - No treatment of already-confounded designs. The guidance question “what about designs that
cannot be fixed by assignment (e.g. perfect confounding)?” is not answered anywhere in the docs.
The closest artifacts are (a) the zero-variance abort (§9A), which is a mechanism, not a discussion,
and (b)
formula_utils.R(generate_terms,drop_order,get_order), which containsstop("dataset contains fewer rows than the main effects model.")andstop("there are no interactions left in the model.")— butgenerate_termsis exported and undocumented in every vignette; nothing explains its role. designit offers no confounding detector and no warning that assignment cannot rescue a design whose contrast is nested in a batch. - No run-order / temporal dimension. See §12 — there is no time/run-order concept at all.
- No power/replication guidance, no sample-size advice, no cost model.
- Randomization for its own sake is not defended — designit optimizes; it never discusses the inferential price of a non-random (optimized) allocation, nor offers restricted randomization or a randomization test.
- No reproducibility contract beyond the recorded seed. The trace stores
seedandrng_kind, but no vignette shows restoring from them. - The hard-coded plate-score exponents (
-2.6,-2) are never justified or referenced.
11. Open questions / ambiguities the source leaves unresolved
- Is the designit OSAT score exactly Yan et al.’s, or merely proportional/closely related? The
roxygen hedges (“closely related to the chi-square test contingency table”), while the
osatvignette asserts identity in a code comment (“should be identical”) — and that vignette only runs if theOSATpackage is installed, so a reader typically never sees the comparison execute. The numeric equality is claimed, not shown in the shipped output. - What is the principled basis for the plate score’s functional form (the
^-2.6and^-2)? Unstated. - When should one autoscale? The docs show how, and warn about sign/range consequences, but give no rule.
- What
p/penalize_linesto pick? The wrapper defaults to"soft"; the plate-layout vignette’s manual example uses"hard"with no stated reason for the difference. - How many iterations are enough? No guidance beyond “play with T0 and alpha”.
12. Guidance answers
Q: BatchContainer$new() full argument list; what dimensions means; how batch/position dimensions
are declared. → BatchContainer$new(locations_table, dimensions, exclude = NULL). dimensions is a
named integer vector / named list / list of BatchContainerDimension objects; the container is the
cartesian product of dimension values minus exclude. There is no batch/position declaration — the
container does not know which dimension is a batch. That role is conferred by the scoring function
(batch_vars= in osat_score_generator, row=/column= in mk_plate_scoring_functions). See §5.3.
Q: R6 — which operations are methods vs standalone functions; is bc$get_samples() a method and is
there no standalone get_samples()? → Verified against NAMESPACE (§5.2): the skill’s assertion is
correct. get_samples is not exported; it exists only as the R6 method bc$get_samples(assignment = TRUE, include_id = FALSE, remove_empty_locations = FALSE, as_tibble = TRUE). Full method/binding/
function split is in §5.2. Notable trap: $move_samples() mutates in place, while
assign_*()/optimize_design() all $copy() and return a new container.
Q: assign_in_order() / other initializers, and optimize_design(): full signatures, every argument,
all defaults (iterations, acceptance/annealing schedule, shuffle schedule). → §5.3, verbatim.
Headline defaults: max_iter = 1e4, min_delta = NA, acceptance_func = accept_strict_improvement
(no annealing by default), aggregate_scores_func = identity, shuffle = 1 pairwise swap/iteration
when both n_shuffle and shuffle_proposal_func are NULL, check_score_variance = TRUE,
autoscale_scores = FALSE, autoscaling_permutations = 100 (floored at 20),
autoscale_useboxcox = TRUE, sample_attributes_fixed = FALSE, quiet = FALSE.
Q: osat_score_generator() full signature; what batch_vars/feature_vars mean; is it the same
score as Yan et al. 2012, an approximation, or a variant? Quote it. →
osat_score_generator(batch_vars, feature_vars, quiet = FALSE); both are character vectors;
batch_vars names the container/batch columns, feature_vars the sample-annotation columns to balance.
On the relationship to Yan et al., the docs give two different strengths of claim, quoted verbatim:
- Hedged (roxygen,
R/osat.R): “The OSAT score is intended to ensure even distribution of samples across batches and is closely related to the chi-square test contingency table (Yan et al. (2012) \doi{10.1186/1471-2164-13-689}).” - Identity (code comment,
vignettes/osat.Rmd):bc$score(scoring_f)vsg_setup@metadata$optValue |> head(1)→ ”# should be identical”. DESCRIPTION: “we provide an implementation for OSAT score by Yan et al. (2012 …)”. The implemented formula issum((N - .n_expected)^2)with.n_expected = .n_samples * .freq_batch. The docs never say “approximation” or “variant”. They claim an implementation of the same score.
Q: The other scoring functions available, and what the docs say about when to choose which. →
Only two score families ship: osat_score/osat_score_generator (categorical balance across
batches) and mk_plate_scoring_functions (distance-based spatial spread within a plate; also the
internal mk_dist_matrix). Everything else exported (worst_score, sum_scores, L1_norm,
L2s_norm, first_score_only) is an aggregator, not a score. Choice rule as stated by the docs:
OSAT for across-plate/batch balance, spatial for within-plate distribution (quoted in §5.3);
users are otherwise expected to write custom scoring functions (optimize_design(scoring = ) accepts
any function(bc) returning a numeric vector). No deeper selection criterion is given.
Q: How multiple scores are combined (weighting, normalization, Pareto/aggregation). → Supported and
documented (§5.3): strict dominance (default), leftmost priority with tolerance
(accept_leftmost_improvement), exponential weighting (mk_exponentially_weighted_acceptance_func,
kappa = 0.5), and scalarizing aggregators (first_score_only, worst_score, sum_scores,
L1_norm, L2s_norm) applied via aggregate_scores_func. Normalization = autoscale_scores = TRUE
(off by default), boxcox-via-bestNormalize or mean/sd, estimated from autoscaling_permutations = 100
random permutations. No Pareto front / multi-objective machinery. Aggregation happens after
autoscaling and before the acceptance test (@param aggregate_scores_func: “AFTER (potential)
auto-scaling and BEFORE acceptance evaluation”).
Q: A complete, runnable end-to-end example, code verbatim. → §5.4 (two: the minimal @examples
block from optimize_design, and the fuller plate spine from basic_examples.Rmd).
Q: What happens when a balanced assignment is NOT achievable (batch capacity < number of conditions, unequal group sizes)? Any diagnostic that the optimizer failed to balance? → Partially answered, and the honest answer is mostly “no”.
- There is a pre-flight detector, but it detects undiscriminating scores, not unachievable
balance:
check_score_variance = TRUEaborts with"Low variance scores detected! Check scores # …"when a subscore has variance< 1e-10over 100 random permutations (§9A). - There is a hard capacity assert:
"more samples than availble locations in the batch container"(sic). - There is no post-hoc diagnostic that the optimizer failed to balance. OSAT simply converges to
the lowest reachable sum-of-squared-deviations, and the docs give no threshold, no residual-imbalance
test, and no warning distinguishing “converged to a good design” from “converged to the least-bad
impossible design”. The only feedback is the printed score trace /
bc$plot_trace(). Unequal group sizes are handled implicitly by the expected-count model (.n_expected = .n_samples * .freq_batchscales expectations by batch frequency), so unequal batches do not error — they just shift the target. - The
shuffling_with_constraintsvignette does state the tradeoff outcome plainly (verbatim): “Obeying all constraints does not lead to a very balanced sample allocation”.
Q: Any statement about run-order / position-within-plate effects (edge effects) and how to constrain for them? → Position-within-plate: yes, explicitly. Run-order: no — the concept is absent.
- Edge/position effects are named and motivated (verbatim, §7 item 4): “Edge effects (bad plate
sealing) / Gradients (non-equal temperature distribution) / Row / column effects (pipetting issues)”,
with the stated remedy: “we aim to distribute sample groups of interest evenly across the plate and
adjust for the effect computationally.” The
plate_effect_exampledataset + figure show a real plate effect where control rows are affected differently and “It is virtually impossible to normalize readouts in a meaningful way.” - The mechanism offered is the distance-based plate score (
mk_plate_scoring_functions, spreading same-group samples apart) pluspenalize_lines = c("none","soft","hard")for row/column effects, plusexclude =to physically drop wells (the basic vignette excludes the four corner wells:exclude_wells <- expand.grid(plate = seq(n_plates), column = c(1, 8), row = c(1, 6))), plusshuffle_with_constraints(dst = plate == .src$plate)to keep optimization within a plate. - Caveat:
excludeis how you avoid edge wells, but note thebc$exclude/add_excludeddefect in §9G2 — you can exclude wells, you just cannot plot them as excluded in 0.5.0. - Run order / temporal position is never modelled. There is no run-order dimension, no time covariate, no sequence-effect score. A “batch” may be a processing day (the optimizer-examples vignette: “8 samples can be processed per day (batch)”), but the order within or across days is never scored or constrained.
Q: Any statement about designs that are already confounded / cannot be fixed by assignment? → Not addressed. See §10. The docs contain no discussion of perfect/structural confounding, no detector for it, and no warning that assignment cannot repair it. The zero-variance abort (§9A) would incidentally fire on some perfectly-confounded configurations (a score that cannot change under any permutation), but the docs never present it that way, and it is not a general confounding check.
Q: Must-quote — every signature and default above; the exact package version and release date. →
Signatures/defaults are reproduced verbatim in §5.3. Version/date: Version: 0.5.0,
Date/Publication: 2024-03-21 14:30:05 UTC (CRAN “Published: 2024-03-21”),
Packaged: 2024-03-19 09:27:56 UTC. Guidance’s expectation of 0.5.0 / 2024-03-21 is confirmed.