diff --git a/R/Boost.R b/R/Boost.R index 61db34613..4b51e4643 100644 --- a/R/Boost.R +++ b/R/Boost.R @@ -4,7 +4,7 @@ #' #' @param IVs.train a matrix or data frame of variables of numeric or factor data types. #' @param DV.train a numeric or factor vector with compatible dimensions to \code{(IVs.train)}. -#' @param IVs.test a matrix or data frame of variables of numeric or factor data types with compatible dimensions to \code{(IVs.train)}. If NULL, will use \code{(IVs.train)} as default. +#' @param IVs.test a matrix or data frame of variables of numeric or factor data types with compatible dimensions to \code{(IVs.train)}. If NULL, will use \code{(IVs.train)} as default. Columns are matched to \code{(IVs.train)} by name when the two share the same predictor names, and positionally when they share no names at all (as in \code{cbind(test.x_1, test.x_2)} against \code{cbind(x_1, x_2)}). Names that only partly overlap the training predictors are ambiguous and return an error. #' @param type \code{NULL} (default). To perform a classification of discrete integer classes from factor target variable \code{(DV.train)} with a base category of 1, set to \code{(type = "CLASS")}, else for continuous \code{(DV.train)} set to \code{(type = NULL)}. #' @param depth options: (integer, NULL, "max"); \code{(depth = NULL)}(default) Specifies the \code{order} parameter in the \link{NNS.reg} routine, assigning a number of splits in the regressors, analogous to tree depth. #' @param learner.trials integer; 100 (default) Sets the number of trials to obtain an accuracy \code{threshold} level. If the number of all possible feature combinations is less than selected value, the minimum of the two values will be used. @@ -141,20 +141,13 @@ NNS.boost <- function(IVs.train, names(x) <- train_names } else if (length(x) == p) { supplied <- names(x) - if (!is.null(supplied) && all(nzchar(supplied)) && - !identical(make.unique(supplied, sep = "."), train_names)) { - # Align a named test row by the training predictor names rather - - # than silently renaming positionally supplied values. - - if (anyDuplicated(supplied) || - !setequal(supplied, train_names)) { - stop( - "Named [IVs.test] values must exactly match the training predictors.", - call. = FALSE - ) - } - x <- x[train_names] + if (!is.null(supplied) && all(nzchar(supplied))) { + # Normalize duplicate names exactly as the training frame does, then + # align a named test row by the training predictor names only when + # the two name sets describe the same predictors. + supplied <- make.unique(supplied, sep = ".") + ordering <- .nns_match_predictor_names(supplied, train_names, "IVs.test") + if (!is.null(ordering)) x <- x[ordering] } x <- as.data.frame(as.list(x), check.names = FALSE, @@ -175,37 +168,16 @@ NNS.boost <- function(IVs.train, call. = FALSE) } - if (!had_column_names) { - names(x) <- train_names - } else { + if (had_column_names) { # Normalize duplicate names with make.unique() identically to the - # training frame (and to NNS.reg), so cbind(x, x) test input aligns - # with the c("x", "x.1") training columns rather than erroring. - - names(x) <- make.unique(names(x), sep = ".") - missing_names <- setdiff(train_names, names(x)) - extra_names <- setdiff(names(x), train_names) - if (length(missing_names) || length(extra_names)) { - stop( - sprintf( - "[IVs.test] columns must exactly match [IVs.train]. Missing: %s; extra: %s.", - if (length(missing_names)) - paste(missing_names, collapse = ", ") - else - "none", - if (length(extra_names)) - paste(extra_names, collapse = ", ") - else - "none" - ), - call. = FALSE - ) - } - x <- x[, train_names, drop = FALSE] + supplied <- make.unique(names(x), sep = ".") + ordering <- .nns_match_predictor_names(supplied, train_names, "IVs.test") + if (!is.null(ordering)) x <- x[, ordering, drop = FALSE] } - + names(x) <- train_names + x } diff --git a/R/Regression.R b/R/Regression.R index e4f59a17d..f980fa461 100644 --- a/R/Regression.R +++ b/R/Regression.R @@ -141,6 +141,37 @@ out } +# Reconcile supplied prediction / test column names with the training predictor +# names. Names only carry alignment information when both sides refer to the +# same predictors, so three cases are distinguished: +# +# * same set of names -> reorder the supplied columns into the training +# order, which catches columns handed over in a different order; +# * no names in common -> the names cannot express an alignment at all, as in +# cbind(test.x_1, test.x_2) against cbind(x_1, x_2), so keep the supplied +# order and match positionally per the documented "compatible dimensions"; +# * partial overlap -> ambiguous, and in practice a wrong or misspelled +# column, so error. +# +# [supplied] and [train.names] must already be make.unique() normalized and of +# equal length. Returns the permutation to apply to the supplied columns, or +# NULL when they are to be taken in the order supplied. +.nns_match_predictor_names <- function(supplied, train.names, label) { + if (setequal(supplied, train.names)) return(match(train.names, supplied)) + if (!any(supplied %in% train.names)) return(NULL) + + missing.names <- setdiff(train.names, supplied) + extra.names <- setdiff(supplied, train.names) + stop(sprintf( + paste0("[%s] columns must exactly match the training predictors, or ", + "share no names with them to be matched positionally. ", + "Missing: %s; extra: %s."), + label, + if (length(missing.names)) paste(missing.names, collapse = ", ") else "none", + if (length(extra.names)) paste(extra.names, collapse = ", ") else "none" + ), call. = FALSE) +} + .nns_reg_prepare_points <- function(point.est, train.names) { if (is.null(point.est)) return(NULL) p <- length(train.names) @@ -151,16 +182,13 @@ names(out) <- train.names return(out) } - supplied.name <- colnames(point.est) out <- as.data.frame(point.est, check.names = FALSE, stringsAsFactors = FALSE) if (ncol(out) != 1L) { stop("[point.est] must contain exactly one predictor column.", call. = FALSE) } - if (!is.null(supplied.name) && nzchar(supplied.name[1L]) && - !identical(supplied.name[1L], train.names[1L])) { - stop("Named [point.est] columns must exactly match the training predictors.", - call. = FALSE) - } + # A lone column can only align one way, so its name never selects between + # predictors: cbind(test.x) against a single training predictor is matched + # positionally. names(out) <- train.names return(out) } @@ -174,12 +202,8 @@ # Apply the same duplicate-name normalization used for the training frame. # Example: c(x = ..., x = ...) becomes c("x", "x.1") on both sides. supplied <- make.unique(supplied, sep = ".") - if (!setequal(supplied, train.names)) { - stop("Named [point.est] values must exactly match the training predictors.", - call. = FALSE) - } - names(point.est) <- supplied - point.est <- point.est[train.names] + ordering <- .nns_match_predictor_names(supplied, train.names, "point.est") + if (!is.null(ordering)) point.est <- point.est[ordering] } out <- as.data.frame(as.list(point.est), check.names = FALSE, stringsAsFactors = FALSE) @@ -199,15 +223,10 @@ # Normalize prediction names identically before validating/reordering so # cbind(x, x) matches training columns c("x", "x.1") by position. supplied.names <- make.unique(supplied.names, sep = ".") - if (!setequal(supplied.names, train.names)) { - stop("Named [point.est] columns must exactly match the training predictors.", - call. = FALSE) - } - names(out) <- supplied.names - out <- out[, train.names, drop = FALSE] - } else { - names(out) <- train.names + ordering <- .nns_match_predictor_names(supplied.names, train.names, "point.est") + if (!is.null(ordering)) out <- out[, ordering, drop = FALSE] } + names(out) <- train.names out } @@ -647,7 +666,7 @@ #' @param dim.red.method options: ("cor", "NNS.dep", "NNS.caus", "all", "equal", \code{numeric vector}, NULL) method for determining synthetic X* coefficients (per Dana and Dawes (2004)). Selection of a method automatically engages the dimension reduction regression. The default is \code{NULL} for full multivariate regression. \code{(dim.red.method = "NNS.dep")} uses \link{NNS.dep} for nonlinear dependence weights, while \code{(dim.red.method = "NNS.caus")} uses \link{NNS.caus} for causal weights. \code{(dim.red.method = "cor")} uses standard linear correlation for weights. \code{(dim.red.method = "all")} averages all methods for further feature engineering. \code{(dim.red.method = "equal")} uses unit weights. Alternatively, user can specify a numeric vector of coefficients. #' @param tau options("ts", NULL); \code{NULL}(default) To be used in conjunction with \code{(dim.red.method = "NNS.caus")} or \code{(dim.red.method = "all")}. If the regression is using time-series data, set \code{(tau = "ts")} for more accurate causal analysis. #' @param type \code{NULL} (default). To perform a classification, set to \code{(type = "CLASS")}. Like a logistic regression, it is not necessary for target variable of two classes e.g. [0, 1]. -#' @param point.est a numeric or factor vector with compatible dimensions to \code{x}. Returns the fitted value \code{y.hat} for any value of \code{x}. +#' @param point.est a numeric or factor vector with compatible dimensions to \code{x}. Returns the fitted value \code{y.hat} for any value of \code{x}. Columns are matched to \code{x} by name when the two share the same predictor names, and positionally when they share no names at all (as in \code{cbind(test.x_1, test.x_2)} against \code{cbind(x_1, x_2)}). Names that only partly overlap the training predictors are ambiguous and return an error. #' @param location Sets the legend location within the plot, per the \code{x} and \code{y} co-ordinates used in base graphics \link{legend}. #' @param return.values logical; \code{TRUE} (default), set to \code{FALSE} in order to only display a regression plot and call values as needed. #' @param plot logical; \code{TRUE} (default) To plot regression. diff --git a/R/Stack.R b/R/Stack.R index c81bccae8..b772f19d2 100644 --- a/R/Stack.R +++ b/R/Stack.R @@ -4,7 +4,7 @@ #' #' @param IVs.train a vector, matrix or data frame of variables of numeric or factor data types. #' @param DV.train a numeric or factor vector with compatible dimensions to \code{(IVs.train)}. -#' @param IVs.test a vector, matrix or data frame of variables of numeric or factor data types with compatible dimensions to \code{(IVs.train)}. If NULL, will use \code{(IVs.train)} as default. +#' @param IVs.test a vector, matrix or data frame of variables of numeric or factor data types with compatible dimensions to \code{(IVs.train)}. If NULL, will use \code{(IVs.train)} as default. Columns are matched to \code{(IVs.train)} by name when the two share the same predictor names, and positionally when they share no names at all (as in \code{cbind(test.x_1, test.x_2)} against \code{cbind(x_1, x_2)}). Names that only partly overlap the training predictors are ambiguous and return an error. #' @param type \code{NULL} (default). To perform a classification of discrete integer classes from factor target variable \code{(DV.train)} with a base category of 1, set to \code{(type = "CLASS")}, else for continuous \code{(DV.train)} set to \code{(type = NULL)}. Like a logistic regression, this setting is not necessary for target variable of two classes e.g. [0, 1]. #' @param obj.fn expression; \code{expression(sum((predicted - actual)^2))} (default) Sum of squared errors is the default objective function. Any \code{expression()} using the specific terms \code{predicted} and \code{actual} can be used. #' @param objective options: ("min", "max") \code{"min"} (default) Select whether to minimize or maximize the objective function \code{obj.fn}. @@ -145,13 +145,13 @@ NNS.stack <- function(IVs.train, names(x) <- train_names } else if (length(x) == p) { supplied <- names(x) - if (!is.null(supplied) && all(nzchar(supplied)) && - !identical(make.unique(supplied, sep = "."), train_names)) { - if (anyDuplicated(supplied) || !setequal(supplied, train_names)) { - stop("Named [IVs.test] values must exactly match the training predictors.", - call. = FALSE) - } - x <- x[train_names] + if (!is.null(supplied) && all(nzchar(supplied))) { + # Normalize duplicate names exactly as the training frame does, then + # align by name only when the two name sets describe the same + # predictors. + supplied <- make.unique(supplied, sep = ".") + ordering <- .nns_match_predictor_names(supplied, train_names, "IVs.test") + if (!is.null(ordering)) x <- x[ordering] } x <- as.data.frame(as.list(x), check.names = FALSE, stringsAsFactors = FALSE) @@ -171,26 +171,16 @@ NNS.stack <- function(IVs.train, call. = FALSE) } - if (!had_names) { - names(x) <- train_names - } else { + if (had_names) { # Normalize duplicate names with make.unique() identically to the # training frame (and to NNS.reg), so cbind(x, x) test input aligns # with the c("x", "x.1") training columns rather than erroring. - names(x) <- make.unique(names(x), sep = ".") - missing_names <- setdiff(train_names, names(x)) - extra_names <- setdiff(names(x), train_names) - if (length(missing_names) || length(extra_names)) { - stop(sprintf( - paste0("[IVs.test] columns must exactly match [IVs.train]. ", - "Missing: %s; extra: %s."), - if (length(missing_names)) paste(missing_names, collapse = ", ") else "none", - if (length(extra_names)) paste(extra_names, collapse = ", ") else "none" - ), call. = FALSE) - } - x <- x[, train_names, drop = FALSE] + supplied <- make.unique(names(x), sep = ".") + ordering <- .nns_match_predictor_names(supplied, train_names, "IVs.test") + if (!is.null(ordering)) x <- x[, ordering, drop = FALSE] } - + names(x) <- train_names + x } diff --git a/man/NNS.boost.Rd b/man/NNS.boost.Rd index 92904e75f..7439b698f 100644 --- a/man/NNS.boost.Rd +++ b/man/NNS.boost.Rd @@ -33,7 +33,7 @@ NNS.boost( \item{DV.train}{a numeric or factor vector with compatible dimensions to \code{(IVs.train)}.} -\item{IVs.test}{a matrix or data frame of variables of numeric or factor data types with compatible dimensions to \code{(IVs.train)}. If NULL, will use \code{(IVs.train)} as default.} +\item{IVs.test}{a matrix or data frame of variables of numeric or factor data types with compatible dimensions to \code{(IVs.train)}. If NULL, will use \code{(IVs.train)} as default. Columns are matched to \code{(IVs.train)} by name when the two share the same predictor names, and positionally when they share no names at all (as in \code{cbind(test.x_1, test.x_2)} against \code{cbind(x_1, x_2)}). Names that only partly overlap the training predictors are ambiguous and return an error.} \item{type}{\code{NULL} (default). To perform a classification of discrete integer classes from factor target variable \code{(DV.train)} with a base category of 1, set to \code{(type = "CLASS")}, else for continuous \code{(DV.train)} set to \code{(type = NULL)}.} diff --git a/man/NNS.reg.Rd b/man/NNS.reg.Rd index d8f9f0428..ff849d32c 100644 --- a/man/NNS.reg.Rd +++ b/man/NNS.reg.Rd @@ -44,7 +44,7 @@ NNS.reg( \item{type}{\code{NULL} (default). To perform a classification, set to \code{(type = "CLASS")}. Like a logistic regression, it is not necessary for target variable of two classes e.g. [0, 1].} -\item{point.est}{a numeric or factor vector with compatible dimensions to \code{x}. Returns the fitted value \code{y.hat} for any value of \code{x}.} +\item{point.est}{a numeric or factor vector with compatible dimensions to \code{x}. Returns the fitted value \code{y.hat} for any value of \code{x}. Columns are matched to \code{x} by name when the two share the same predictor names, and positionally when they share no names at all (as in \code{cbind(test.x_1, test.x_2)} against \code{cbind(x_1, x_2)}). Names that only partly overlap the training predictors are ambiguous and return an error.} \item{location}{Sets the legend location within the plot, per the \code{x} and \code{y} co-ordinates used in base graphics \link{legend}.} diff --git a/man/NNS.stack.Rd b/man/NNS.stack.Rd index 5d985a656..ff309c83d 100644 --- a/man/NNS.stack.Rd +++ b/man/NNS.stack.Rd @@ -32,7 +32,7 @@ NNS.stack( \item{DV.train}{a numeric or factor vector with compatible dimensions to \code{(IVs.train)}.} -\item{IVs.test}{a vector, matrix or data frame of variables of numeric or factor data types with compatible dimensions to \code{(IVs.train)}. If NULL, will use \code{(IVs.train)} as default.} +\item{IVs.test}{a vector, matrix or data frame of variables of numeric or factor data types with compatible dimensions to \code{(IVs.train)}. If NULL, will use \code{(IVs.train)} as default. Columns are matched to \code{(IVs.train)} by name when the two share the same predictor names, and positionally when they share no names at all (as in \code{cbind(test.x_1, test.x_2)} against \code{cbind(x_1, x_2)}). Names that only partly overlap the training predictors are ambiguous and return an error.} \item{type}{\code{NULL} (default). To perform a classification of discrete integer classes from factor target variable \code{(DV.train)} with a base category of 1, set to \code{(type = "CLASS")}, else for continuous \code{(DV.train)} set to \code{(type = NULL)}. Like a logistic regression, this setting is not necessary for target variable of two classes e.g. [0, 1].} diff --git a/tests/testthat/test-predictor-name-matching.R b/tests/testthat/test-predictor-name-matching.R new file mode 100644 index 000000000..450d0f761 --- /dev/null +++ b/tests/testthat/test-predictor-name-matching.R @@ -0,0 +1,154 @@ +test_that("test columns sharing no names with training are matched positionally", { + # cbind() names its columns after the supplied expressions, so a test set + # built as cbind(test.x_1, test.x_2) carries names that describe nothing + # about the training predictors x_1 / x_2. Those names cannot express an + # alignment, so the columns are taken in the order supplied. + set.seed(123) + x_1 <- rnorm(120); x_2 <- rnorm(120) + y <- 10 * x_1 + 10 * x_2 + rnorm(120) + + set.seed(321) + test.x_1 <- rnorm(20); test.x_2 <- rnorm(20) + + named <- NNS.stack(cbind(x_1, x_2), y, IVs.test = cbind(test.x_1, test.x_2), + method = 1, folds = 2, ncores = 1, status = FALSE)$stack + bare <- NNS.stack(cbind(x_1, x_2), y, + IVs.test = unname(cbind(test.x_1, test.x_2)), + method = 1, folds = 2, ncores = 1, status = FALSE)$stack + + expect_length(named, 20L) + expect_equal(named, bare, tolerance = 1e-12) +}) + +test_that("foreign test names do not silently reorder columns", { + set.seed(4) + x <- data.frame(a = rnorm(80), b = rnorm(80)) + y <- x$a - 2 * x$b + + ordered <- x[1:5, c("a", "b")] + reversed <- x[1:5, c("b", "a")] + + foreign <- setNames(ordered, c("zz", "yy")) + foreign.reversed <- setNames(reversed, c("zz", "yy")) + + base <- NNS.reg(x, y, point.est = ordered, plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est + + # Positional, so the same values in the same order agree ... + expect_equal( + NNS.reg(x, y, point.est = foreign, plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est, + base, tolerance = 1e-12 + ) + # ... and swapping the columns changes the answer, since the names are not + # consulted at all. + expect_false(isTRUE(all.equal( + NNS.reg(x, y, point.est = foreign.reversed, plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est, + base + ))) +}) + +test_that("matching names are still aligned by name, not by position", { + set.seed(5) + x <- data.frame(a = rnorm(80), b = rnorm(80)) + y <- x$a - 2 * x$b + + expect_equal( + NNS.reg(x, y, point.est = x[1:5, c("a", "b")], plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est, + NNS.reg(x, y, point.est = x[1:5, c("b", "a")], plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est, + tolerance = 1e-12 + ) +}) + +test_that("partially overlapping names remain an error", { + # A test set that names some training predictors and not others is + # ambiguous, and in practice a wrong or misspelled column. + set.seed(6) + x <- data.frame(a = rnorm(60), b = rnorm(60)) + y <- x$a + x$b + + bad <- setNames(x[1:5, ], c("a", "c")) + + expect_error( + NNS.reg(x, y, point.est = bad, plot = FALSE, residual.plot = FALSE), + "exactly match" + ) + expect_error( + NNS.stack(x, y, IVs.test = bad, method = 1, folds = 2, ncores = 1, + status = FALSE), + "exactly match" + ) +}) + +test_that("named test vectors follow the same rule", { + set.seed(7) + x <- data.frame(a = rnorm(60), b = rnorm(60)) + y <- x$a - 2 * x$b + + base <- NNS.reg(x, y, point.est = c(a = 0.5, b = -0.5), plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est + + # Permutation of the training names is aligned by name. + expect_equal( + NNS.reg(x, y, point.est = c(b = -0.5, a = 0.5), plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est, + base, tolerance = 1e-12 + ) + # Names shared with nothing in training fall back to position. + expect_equal( + NNS.reg(x, y, point.est = c(q = 0.5, z = -0.5), plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est, + base, tolerance = 1e-12 + ) + expect_error( + NNS.reg(x, y, point.est = c(a = 0.5, z = -0.5), plot = FALSE, + residual.plot = FALSE), + "exactly match" + ) +}) + +test_that("a single predictor accepts any test column name", { + set.seed(8) + x <- rnorm(60); y <- x^2 + rnorm(60) + + expect_equal( + NNS.reg(x, y, point.est = cbind(test.x = x[1:4]), plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est, + NNS.reg(x, y, point.est = x[1:4], plot = FALSE, + residual.plot = FALSE, ncores = 1)$Point.est, + tolerance = 1e-12 + ) +}) + +test_that("NNS.boost applies the same test-column rule", { + set.seed(9) + x <- data.frame(a = rnorm(80), b = rnorm(80), c = rnorm(80)) + y <- as.numeric(x$a + x$b > 0) + + foreign <- setNames(x[1:6, ], c("t.a", "t.b", "t.c")) + expect_error( + NNS.boost(IVs.train = x, DV.train = y, IVs.test = foreign, + epochs = 8, learner.trials = 5, folds = 1, status = FALSE), + NA + ) + + partial <- setNames(x[1:6, ], c("a", "t.b", "t.c")) + expect_error( + NNS.boost(IVs.train = x, DV.train = y, IVs.test = partial, + epochs = 8, learner.trials = 5, folds = 1, status = FALSE), + "exactly match" + ) + + by.name <- NNS.boost(IVs.train = x, DV.train = y, + IVs.test = x[1:6, c("c", "a", "b")], + epochs = 8, learner.trials = 5, folds = 1, + status = FALSE, seed = 7)$results + in.order <- NNS.boost(IVs.train = x, DV.train = y, + IVs.test = x[1:6, c("a", "b", "c")], + epochs = 8, learner.trials = 5, folds = 1, + status = FALSE, seed = 7)$results + expect_equal(by.name, in.order, tolerance = 1e-12) +})