diff --git a/.Rbuildignore b/.Rbuildignore index d4b4933..b0a15fa 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -6,3 +6,4 @@ ^README\.Rmd$ ^cran-comments\.md$ ^CRAN-SUBMISSION$ +^\.github$ diff --git a/DESCRIPTION b/DESCRIPTION index 0267e70..5855602 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: graDiEnt Title: Stochastic Quasi-Gradient Differential Evolution Optimization -Version: 1.0.1 +Version: 1.1.0 Authors@R: person(given = "Brendan Matthew", family = "Galdo", diff --git a/NEWS.md b/NEWS.md index 6605b2e..a1434e6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,23 @@ +# graDiEnt 1.1.0 + +## New features + +* **Box constraints**: `GetAlgoParams` gains `lower` and `upper` parameters for per-parameter bounds. Enforced via `bounds_type = "reflect"` (default, mirrors proposals back across boundary) or `"clip"` (truncates to boundary). +* **Warm start**: `optim_SQGDE` gains `warm_start` parameter. Pass the output of a previous run to seed the population and continue optimization without re-initializing. +* **Disk recovery**: `GetAlgoParams` gains `recovery_path` (`.rds` file path) and `recovery_freq` (save interval in iterations). Partial results are written to disk periodically via `saveRDS`, surviving interrupts and OOM crashes. Load with `readRDS(recovery_path)`. +* **Progress message control**: `GetAlgoParams` gains `trace_print_freq`. Set to an integer to print progress every N iterations, or `Inf` to suppress all iteration messages. +* **Unified adaptation schemes**: `rand`, `current`, and `best` DE schemes are now selected via `adapt_scheme` in `GetAlgoParams` rather than separate internal functions. +* **`crossover_rate = 0`**: Now accepted; selects exactly one random parameter to update per iteration. +* **`jitter_size = 0`**: Now accepted; disables jitter noise. +* **Block coordinate updating**: `GetAlgoParams` gains `param_block_list`, an optional list of integer vectors partitioning `1:n_params` into blocks. The optimizer cycles through blocks in order across iterations, updating only the current block's parameters instead of using random crossover selection. Useful for high-dimensional problems where updating parameter subsets per iteration is more efficient. + +## Bug fixes + +* Fixed division-by-zero in `grad_approx_fn` when two particles are identical or have equal weights (zero-diff guard). +* Fixed `rand` scheme incorrectly sampling `2*n_diff` parents instead of `2*n_diff + 1`, causing out-of-bounds index access. +* Fixed `runif` jitter applied over `n_params` elements instead of `length(param_indices)` when `crossover_rate < 1`. +* Fixed `GetAlgoParams` validation for `n_params`, `n_particles`, and `n_iter`: finiteness is now checked before `as.integer()` coercion, preventing silent `NA` production and spurious "condition has length > 1" errors on vector inputs. + # graDiEnt 1.0.1 * Package uses "message()" instead of "print()" to communicate with user. * minor fix to documentation. diff --git a/R/GetAlgoParams.R b/R/GetAlgoParams.R index b660f22..ca4e72a 100644 --- a/R/GetAlgoParams.R +++ b/R/GetAlgoParams.R @@ -25,6 +25,7 @@ #' @param bounds_type A string specifying how parameter bounds are enforced on proposals. 'clip' truncates proposals to [lower, upper]. 'reflect' mirrors proposals back across the boundary (with clip fallback for very large steps). Default is 'reflect'. #' @param trace_print_freq A positive integer controlling how often iteration progress is printed. A message is printed every \code{trace_print_freq} iterations. Set to \code{Inf} to suppress progress messages entirely. Default is 100. #' @param converge_crit A string denoting the convergence metric used, valid metrics are 'stdev' (standard deviation of population weight in the last stop_check iterations) and 'percent' (percent improvement in median particle weight in the last stop_check iterations). 'stdev' is the default. +#' @param param_block_list An optional list of integer vectors partitioning parameter indices into blocks. When provided, the algorithm cycles through blocks in order across iterations, updating only the parameters in the current block instead of using random crossover selection. Each index in \code{1:n_params} must appear in exactly one block. Set to \code{NULL} (default) to use standard crossover selection. #' @return A list of control parameters for the optim_SQGDE function. #' @export GetAlgoParams = function(n_params, @@ -51,7 +52,8 @@ GetAlgoParams = function(n_params, stop_check = 10, stop_tol = 1e-4, converge_crit = 'stdev', - trace_print_freq = 100){ + trace_print_freq = 100, + param_block_list = NULL){ # n_params ### catch errors if(length(n_params) > 1 || !is.finite(n_params)){ @@ -331,6 +333,22 @@ GetAlgoParams = function(n_params, } } + # param_block_list + if (!is.null(param_block_list)) { + if (!is.list(param_block_list) || length(param_block_list) < 1) { + stop('ERROR: param_block_list must be NULL or a non-empty list of integer vectors') + } + all_indices = unlist(param_block_list) + if (!is.numeric(all_indices) || any(!is.finite(all_indices)) || any(all_indices < 1) || any(all_indices > n_params)) { + stop('ERROR: param_block_list indices must be integers in 1:n_params') + } + all_indices = as.integer(all_indices) + if (length(all_indices) != n_params || length(unique(all_indices)) != n_params) { + stop('ERROR: param_block_list must partition 1:n_params - each index must appear exactly once') + } + param_block_list = lapply(param_block_list, as.integer) + } + out = list('n_params' = n_params, 'n_particles' = n_particles, 'n_iter' = n_iter, @@ -356,7 +374,8 @@ GetAlgoParams = function(n_params, 'stop_tol' = stop_tol, 'stop_check' = stop_check, 'converge_crit' = converge_crit, - 'trace_print_freq' = trace_print_freq) + 'trace_print_freq' = trace_print_freq, + 'param_block_list' = param_block_list) return(out) } diff --git a/R/SQG_DE_bin_1.R b/R/SQG_DE_bin_1.R index 346de08..d477581 100644 --- a/R/SQG_DE_bin_1.R +++ b/R/SQG_DE_bin_1.R @@ -52,7 +52,8 @@ SQG_DE_bin_1 = function(pmem_index, lower = -Inf, upper = Inf, bounds_type = 'reflect', - n_diff, ...) { + n_diff, + param_block = NULL, ...) { weight_use = current_weight[pmem_index] params_use = current_params[pmem_index, ] @@ -73,12 +74,16 @@ SQG_DE_bin_1 = function(pmem_index, base_index = parent_indices[2*n_diff+1] } - # crossover: select which parameters to update - param_idices_bool = stats::rbinom(len_param_use, prob = crossover_rate, size = 1) - if (all(param_idices_bool == 0)) { - param_idices_bool[sample(x = 1:len_param_use, size = 1)] = 1 + # select which parameters to update + if (!is.null(param_block)) { + param_indices = param_block + } else { + param_idices_bool = stats::rbinom(len_param_use, prob = crossover_rate, size = 1) + if (all(param_idices_bool == 0)) { + param_idices_bool[sample(x = 1:len_param_use, size = 1)] = 1 + } + param_indices = seq(1, len_param_use, by = 1)[as.logical(param_idices_bool)] } - param_indices = seq(1, len_param_use, by = 1)[as.logical(param_idices_bool)] # approximate gradient and self-scaling factor ga = grad_approx_fn(param_indices, n_diff, current_params, diff --git a/R/optim_SQGDE.R b/R/optim_SQGDE.R index 53f836a..38c29e7 100644 --- a/R/optim_SQGDE.R +++ b/R/optim_SQGDE.R @@ -145,6 +145,14 @@ optim_SQGDE = function(ObjFun, control_params = GetAlgoParams(), warm_start = NU converge_test_passed=FALSE for(iter in 1:control_params$n_iter){ + # block coordinate: cycle through blocks; NULL means use crossover + if (!is.null(control_params$param_block_list)) { + block_idx = ((iter - 1) %% length(control_params$param_block_list)) + 1 + current_block = control_params$param_block_list[[block_idx]] + } else { + current_block = NULL + } + if(control_params$parallel_type=='none'){ # adapt particles using SQG DE sequentially temp=matrix(unlist(lapply(1:control_params$n_particles, SQG_DE_bin_1, @@ -159,7 +167,8 @@ optim_SQGDE = function(ObjFun, control_params = GetAlgoParams(), warm_start = NU lower = control_params$lower, upper = control_params$upper, bounds_type = control_params$bounds_type, - n_diff = control_params$n_diff, ...)), + n_diff = control_params$n_diff, + param_block = current_block, ...)), control_params$n_particles, control_params$n_params+1, byrow=TRUE) } else { @@ -176,7 +185,8 @@ optim_SQGDE = function(ObjFun, control_params = GetAlgoParams(), warm_start = NU lower = control_params$lower, upper = control_params$upper, bounds_type = control_params$bounds_type, - n_diff = control_params$n_diff, ...)), + n_diff = control_params$n_diff, + param_block = current_block, ...)), control_params$n_particles, control_params$n_params+1, byrow=TRUE) diff --git a/man/GetAlgoParams.Rd b/man/GetAlgoParams.Rd index 4ee36a2..38cdbfd 100644 --- a/man/GetAlgoParams.Rd +++ b/man/GetAlgoParams.Rd @@ -29,7 +29,8 @@ GetAlgoParams( stop_check = 10, stop_tol = 1e-04, converge_crit = "stdev", - trace_print_freq = 100 + trace_print_freq = 100, + param_block_list = NULL ) } \arguments{ @@ -82,6 +83,8 @@ GetAlgoParams( \item{converge_crit}{A string denoting the convergence metric used, valid metrics are 'stdev' (standard deviation of population weight in the last stop_check iterations) and 'percent' (percent improvement in median particle weight in the last stop_check iterations). 'stdev' is the default.} \item{trace_print_freq}{A positive integer controlling how often iteration progress is printed. A message is printed every \code{trace_print_freq} iterations. Set to \code{Inf} to suppress progress messages entirely. Default is 100.} + +\item{param_block_list}{An optional list of integer vectors partitioning parameter indices into blocks. When provided, the algorithm cycles through blocks in order across iterations, updating only the parameters in the current block instead of using random crossover selection. Each index in \code{1:n_params} must appear in exactly one block. Set to \code{NULL} (default) to use standard crossover selection.} } \value{ A list of control parameters for the optim_SQGDE function. diff --git a/tests/testthat/test-GetAlgoParams.R b/tests/testthat/test-GetAlgoParams.R index 3bb3f38..4e14e23 100644 --- a/tests/testthat/test-GetAlgoParams.R +++ b/tests/testthat/test-GetAlgoParams.R @@ -5,7 +5,8 @@ ALL_NAMES <- c("n_params", "n_particles", "n_iter", "init_sd", "init_center", "crossover_rate", "jitter_size", "parallel_type", "recovery_path", "recovery_freq", "thin", "purify", "n_iters_per_particle", "return_trace", "n_diff", "adapt_scheme", "give_up_init", - "stop_tol", "stop_check", "converge_crit", "trace_print_freq") + "stop_tol", "stop_check", "converge_crit", "trace_print_freq", + "param_block_list") # ── output structure ────────────────────────────────────────────────────────── diff --git a/tests/testthat/test-adapt_scheme.R b/tests/testthat/test-adapt_schemes.R similarity index 100% rename from tests/testthat/test-adapt_scheme.R rename to tests/testthat/test-adapt_schemes.R diff --git a/tests/testthat/test-optim_SQGDE.R b/tests/testthat/test-convergence.R similarity index 100% rename from tests/testthat/test-optim_SQGDE.R rename to tests/testthat/test-convergence.R diff --git a/tests/testthat/test-param_block_list.R b/tests/testthat/test-param_block_list.R new file mode 100644 index 0000000..e1dcff0 --- /dev/null +++ b/tests/testthat/test-param_block_list.R @@ -0,0 +1,118 @@ +library(graDiEnt) + +obj <- function(x) sum((x - 1)^2) + +# ── GetAlgoParams validation ────────────────────────────────────────────────── + +test_that("param_block_list = NULL accepted (default)", { + cp <- GetAlgoParams(n_params = 4) + expect_null(cp$param_block_list) +}) + +test_that("valid partition accepted", { + expect_no_error( + GetAlgoParams(n_params = 4, param_block_list = list(1:2, 3:4)) + ) +}) + +test_that("single-block partition accepted", { + expect_no_error( + GetAlgoParams(n_params = 3, param_block_list = list(1:3)) + ) +}) + +test_that("unequal block sizes accepted", { + expect_no_error( + GetAlgoParams(n_params = 4, param_block_list = list(c(1L, 2L, 3L), 4L)) + ) +}) + +test_that("indices coerced to integer", { + cp <- GetAlgoParams(n_params = 4, param_block_list = list(c(1.0, 2.0), c(3.0, 4.0))) + expect_true(is.integer(cp$param_block_list[[1]])) +}) + +test_that("non-list param_block_list errors", { + expect_error( + GetAlgoParams(n_params = 4, param_block_list = 1:4), + "list" + ) +}) + +test_that("index out of range errors", { + expect_error( + GetAlgoParams(n_params = 4, param_block_list = list(1:3, c(4L, 5L))), + "1:n_params" + ) +}) + +test_that("duplicate index errors", { + expect_error( + GetAlgoParams(n_params = 4, param_block_list = list(c(1L, 2L), c(2L, 3L, 4L))), + "exactly once" + ) +}) + +test_that("missing index errors", { + expect_error( + GetAlgoParams(n_params = 4, param_block_list = list(1L, 2L, 3L)), + "exactly once" + ) +}) + + +test_that("empty list errors", { + expect_error( + GetAlgoParams(n_params = 4, param_block_list = list()), + "non-empty" + ) +}) + +# ── optimizer runs with param_block_list ────────────────────────────────────── + +test_that("optimizer runs without error with 2-block partition", { + set.seed(1) + expect_no_error( + suppressMessages( + optim_SQGDE(obj, + GetAlgoParams(n_params = 4, n_iter = 100, n_particles = 12, + n_diff = 2, + param_block_list = list(1:2, 3:4))) + ) + ) +}) + +test_that("optimizer runs without error with single-element blocks", { + set.seed(1) + expect_no_error( + suppressMessages( + optim_SQGDE(obj, + GetAlgoParams(n_params = 3, n_iter = 100, n_particles = 12, + n_diff = 2, + param_block_list = list(1L, 2L, 3L))) + ) + ) +}) + +test_that("optimizer with param_block_list returns finite solution", { + set.seed(1) + out <- suppressMessages( + optim_SQGDE(obj, + GetAlgoParams(n_params = 4, n_iter = 200, n_particles = 12, + n_diff = 2, init_sd = 1, + param_block_list = list(1:2, 3:4))) + ) + expect_true(all(is.finite(out$solution))) + expect_true(is.finite(out$weight)) +}) + +test_that("block cycling: n_params=1 with single block works", { + set.seed(1) + out <- suppressMessages( + optim_SQGDE(obj, + GetAlgoParams(n_params = 1, n_iter = 50, n_particles = 6, + n_diff = 1, + param_block_list = list(1L))) + ) + expect_true(is.finite(out$solution)) +}) diff --git a/tests/testthat/test-trace_print_freq.R b/tests/testthat/test-progress_messages.R similarity index 100% rename from tests/testthat/test-trace_print_freq.R rename to tests/testthat/test-progress_messages.R diff --git a/tests/testthat/test-bug_fixes.R b/tests/testthat/test-sqg_de_bin_1.R similarity index 100% rename from tests/testthat/test-bug_fixes.R rename to tests/testthat/test-sqg_de_bin_1.R