Skip to content

Commit ba42aff

Browse files
committed
Improving test coverage and updating workflows vignettes
1 parent fe3f02b commit ba42aff

15 files changed

+401
-20
lines changed

R/generate_roxygen_docs.R

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,6 @@ generate_roxygen_docs <- function(
8080
c(num_params, fit_params, compile_params, special_params)
8181
)
8282

83-
# Document block-specific params
84-
if ("learn_rate" %in% block_params) {
85-
# This can happen if a user names a block `learn` and it has a `rate` param.
86-
# It's an edge case, but we should not document it twice.
87-
block_params <- setdiff(block_params, "learn_rate")
88-
}
8983
if (length(block_params) > 0) {
9084
# Sort block names by length descending to handle overlapping names
9185
# (e.g., "dense" and "dense_layer")

R/step_collapse.R

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ prep.step_collapse <- function(x, training, info = NULL, ...) {
107107

108108
#' @export
109109
bake.step_collapse <- function(object, new_data, ...) {
110+
if (object$skip) {
111+
return(new_data)
112+
}
113+
if (length(object$columns) == 0) {
114+
return(new_data)
115+
}
110116
recipes::check_new_data(object$columns, object, new_data)
111117

112118
rows_list <- apply(
@@ -126,15 +132,15 @@ bake.step_collapse <- function(object, new_data, ...) {
126132

127133
#' @export
128134
print.step_collapse <- function(x, ...) {
129-
if (is.null(x$columns)) {
130-
cat("Collapse predictors into list-column (unprepped)\\n")
135+
if (is.null(x$columns) || length(x$columns) == 0) {
136+
cat("Collapse predictors into list-column (unprepped)\n")
131137
} else {
132138
cat(
133139
"Collapse predictors into list-column:",
134140
paste(x$columns, collapse = ", "),
135141
" -> ",
136142
x$new_col,
137-
"\\n"
143+
"\n"
138144
)
139145
}
140146
invisible(x)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# print method works
2+
3+
Code
4+
print(rec$steps[[1]])
5+
Output
6+
Collapse predictors into list-column (unprepped)
7+
8+
---
9+
10+
Code
11+
print(prepped_rec$steps[[1]])
12+
Output
13+
Collapse predictors into list-column: x1, x2 -> predictor_matrix
14+
15+
# print method works for prepped recipe
16+
17+
Code
18+
print(rec$steps[[1]])
19+
Output
20+
Collapse predictors into list-column: x1, x2 -> predictor_matrix
21+

tests/testthat/test_e2e_func_classification.R

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ test_that("E2E: Functional spec tuning (including repetition) works", {
9999
tune_wf <- workflows::workflow(rec, tune_spec)
100100

101101
folds <- rsample::vfold_cv(iris, v = 2)
102-
params <- extract_parameter_set_dials(tune_wf) |>
102+
params <- extract_parameter_set_dials(tune_wf) |>
103103
update(
104104
num_dense_path = num_terms(c(1, 2)),
105105
dense_path_units = hidden_units(c(4, 8))
@@ -192,4 +192,107 @@ test_that("E2E: Multi-input, single-output functional classification works", {
192192
expect_equal(names(preds), c(".pred_class"))
193193
expect_equal(nrow(preds), 5)
194194
expect_true(is.factor(preds$.pred_class))
195+
})
196+
197+
test_that("E2E: Functional spec with pre-constructed optimizer works", {
198+
skip_if_no_keras()
199+
200+
# Define blocks for a simple forked functional model
201+
input_block <- function(input_shape) keras3::layer_input(shape = input_shape)
202+
path_block <- function(tensor, units = 16) {
203+
tensor |> keras3::layer_dense(units = units, activation = "relu")
204+
}
205+
concat_block <- function(input_a, input_b) {
206+
keras3::layer_concatenate(list(input_a, input_b))
207+
}
208+
output_block_class <- function(tensor, num_classes) {
209+
tensor |> keras3::layer_dense(units = num_classes, activation = "softmax")
210+
}
211+
212+
model_name <- "e2e_func_class_optimizer"
213+
on.exit(suppressMessages(remove_keras_spec(model_name)), add = TRUE)
214+
215+
# Create a spec with two parallel paths that are then concatenated
216+
create_keras_functional_spec(
217+
model_name = model_name,
218+
layer_blocks = list(
219+
main_input = input_block,
220+
path_a = inp_spec(path_block, "main_input"),
221+
path_b = inp_spec(path_block, "main_input"),
222+
concatenated = inp_spec(
223+
concat_block,
224+
c(path_a = "input_a", path_b = "input_b")
225+
),
226+
output = inp_spec(output_block_class, "concatenated")
227+
),
228+
mode = "classification"
229+
)
230+
231+
# Define a pre-constructed optimizer
232+
my_optimizer <- keras3::optimizer_sgd(learning_rate = 0.001)
233+
234+
spec <- e2e_func_class_optimizer(
235+
path_a_units = 8,
236+
path_b_units = 4,
237+
fit_epochs = 2,
238+
compile_optimizer = my_optimizer
239+
) |>
240+
set_engine("keras")
241+
242+
data <- iris
243+
rec <- recipe(Species ~ ., data = data)
244+
wf <- workflows::workflow(rec, spec)
245+
246+
expect_no_error(fit_obj <- parsnip::fit(wf, data = data))
247+
expect_s3_class(fit_obj, "workflow")
248+
})
249+
250+
test_that("E2E: Functional spec with string loss works", {
251+
skip_if_no_keras()
252+
253+
# Define blocks for a simple forked functional model
254+
input_block <- function(input_shape) keras3::layer_input(shape = input_shape)
255+
path_block <- function(tensor, units = 16) {
256+
tensor |> keras3::layer_dense(units = units, activation = "relu")
257+
}
258+
concat_block <- function(input_a, input_b) {
259+
keras3::layer_concatenate(list(input_a, input_b))
260+
}
261+
output_block_class <- function(tensor, num_classes) {
262+
tensor |> keras3::layer_dense(units = num_classes, activation = "softmax")
263+
}
264+
265+
model_name <- "e2e_func_class_loss_string"
266+
on.exit(suppressMessages(remove_keras_spec(model_name)), add = TRUE)
267+
268+
# Create a spec with two parallel paths that are then concatenated
269+
create_keras_functional_spec(
270+
model_name = model_name,
271+
layer_blocks = list(
272+
main_input = input_block,
273+
path_a = inp_spec(path_block, "main_input"),
274+
path_b = inp_spec(path_block, "main_input"),
275+
concatenated = inp_spec(
276+
concat_block,
277+
c(path_a = "input_a", path_b = "input_b")
278+
),
279+
output = inp_spec(output_block_class, "concatenated")
280+
),
281+
mode = "classification"
282+
)
283+
284+
spec <- e2e_func_class_loss_string(
285+
path_a_units = 8,
286+
path_b_units = 4,
287+
fit_epochs = 2,
288+
compile_loss = "categorical_crossentropy"
289+
) |>
290+
set_engine("keras")
291+
292+
data <- iris
293+
rec <- recipe(Species ~ ., data = data)
294+
wf <- workflows::workflow(rec, spec)
295+
296+
expect_no_error(fit_obj <- parsnip::fit(wf, data = data))
297+
expect_s3_class(fit_obj, "workflow")
195298
})

tests/testthat/test_e2e_func_regression.R

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,49 @@ test_that("E2E: Functional spec (regression) works", {
5151
expect_true(is.numeric(preds$.pred))
5252
})
5353

54+
test_that("E2E: Functional regression works with named predictors in formula", {
55+
skip_if_no_keras()
56+
57+
input_block <- function(input_shape) keras3::layer_input(shape = input_shape)
58+
path_block <- function(tensor, units = 8) {
59+
tensor |> keras3::layer_dense(units = units, activation = "relu")
60+
}
61+
concat_block <- function(input_a, input_b) {
62+
keras3::layer_concatenate(list(input_a, input_b))
63+
}
64+
output_block_reg <- function(tensor) keras3::layer_dense(tensor, units = 1)
65+
66+
model_name <- "e2e_func_reg_named"
67+
on.exit(suppressMessages(remove_keras_spec(model_name)), add = TRUE)
68+
69+
create_keras_functional_spec(
70+
model_name = model_name,
71+
layer_blocks = list(
72+
main_input = input_block,
73+
path_a = inp_spec(path_block, "main_input"),
74+
path_b = inp_spec(path_block, "main_input"),
75+
concatenated = inp_spec(
76+
concat_block,
77+
c(path_a = "input_a", path_b = "input_b")
78+
),
79+
output = inp_spec(output_block_reg, "concatenated")
80+
),
81+
mode = "regression"
82+
)
83+
84+
spec <- e2e_func_reg_named(
85+
fit_epochs = 1
86+
) |>
87+
set_engine("keras")
88+
89+
data <- mtcars
90+
# Use named predictors to cover the x <- data[, x_names, drop = FALSE] line
91+
expect_no_error(
92+
fit_obj <- fit(spec, mpg ~ cyl + disp, data = data)
93+
)
94+
expect_s3_class(fit_obj, "model_fit")
95+
})
96+
5497
test_that("E2E: Block repetition works for functional models", {
5598
skip_if_no_keras()
5699

tests/testthat/test_e2e_seq_regression.R

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,44 @@ test_that("E2E: Regression spec generation, fitting, and prediction works", {
4949
expect_equal(nrow(preds), 5)
5050
expect_true(is.numeric(preds$.pred))
5151
})
52+
53+
test_that("E2E: Sequential regression works with named predictors in formula", {
54+
skip_if_no_keras()
55+
56+
input_block_reg <- function(model, input_shape) {
57+
keras3::keras_model_sequential(input_shape = input_shape)
58+
}
59+
dense_block_reg <- function(model, units = 16, dropout = 0.1) {
60+
model |>
61+
keras3::layer_dense(units = units, activation = "relu") |>
62+
keras3::layer_dropout(rate = dropout)
63+
}
64+
output_block_reg <- function(model) {
65+
model |> keras3::layer_dense(units = 1)
66+
}
67+
68+
model_name <- "e2e_mlp_reg_named"
69+
on.exit(suppressMessages(remove_keras_spec(model_name)), add = TRUE)
70+
71+
create_keras_sequential_spec(
72+
model_name = model_name,
73+
layer_blocks = list(
74+
input = input_block_reg,
75+
dense = dense_block_reg,
76+
output = output_block_reg
77+
),
78+
mode = "regression"
79+
)
80+
81+
spec <- e2e_mlp_reg_named(
82+
fit_epochs = 1
83+
) |>
84+
set_engine("keras")
85+
86+
data <- mtcars
87+
# Use named predictors to cover the x <- data[, x_names, drop = FALSE] line
88+
expect_no_error(
89+
fit_obj <- fit(spec, mpg ~ cyl + disp, data = data)
90+
)
91+
expect_s3_class(fit_obj, "model_fit")
92+
})

tests/testthat/test_empty_args.R

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
test_that("collect_spec_args handles empty keras arg names", {
2+
testthat::with_mocked_bindings(
3+
.env = as.environment("package:kerasnip"),
4+
keras_fit_arg_names = character(0),
5+
keras_compile_arg_names = character(0),
6+
{
7+
args_info <- kerasnip:::collect_spec_args(
8+
layer_blocks = list(dense = function(model, units = 10) {}),
9+
functional = FALSE
10+
)
11+
# Expect only num_dense, dense_units, and learn_rate
12+
expected_args <- c("num_dense", "dense_units", "learn_rate")
13+
expect_equal(sort(names(args_info$all_args)), sort(expected_args))
14+
expect_equal(sort(args_info$parsnip_names), sort(expected_args))
15+
}
16+
)
17+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
test_that("generate_roxygen_docs handles fallback case for block params", {
2+
# This test covers the case where a parameter is passed in `all_args`
3+
# that is not a num_, fit_, or compile_ param, and does not match
4+
# any of the layer_blocks. This is a fallback case that should not
5+
# happen in normal operation but is tested for completeness.
6+
doc_string <- kerasnip:::generate_roxygen_docs(
7+
model_name = "test_model",
8+
layer_blocks = list(
9+
dense = function(units = 10) {}
10+
),
11+
all_args = list(
12+
unmatched_param = 1
13+
),
14+
functional = FALSE
15+
)
16+
expect_true(grepl("@param unmatched_param A model parameter.", doc_string))
17+
})

tests/testthat/test_inp_spec.R

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
test_that("inp_spec throws error for block with no arguments", {
2+
block_no_args <- function() {}
3+
expect_error(
4+
kerasnip:::inp_spec(block_no_args, "input"),
5+
"The 'block' function must have at least one argument."
6+
)
7+
})
8+
9+
test_that("inp_spec throws error for mismatched input_map names", {
10+
block_with_args <- function(a, b) {}
11+
input_map_bad <- c(new_a = "a", new_c = "c") # 'c' does not exist
12+
expect_error(
13+
kerasnip:::inp_spec(block_with_args, input_map_bad),
14+
class = "simpleError"
15+
)
16+
})
17+
18+
19+
test_that("inp_spec throws error for invalid input_map type", {
20+
block_with_args <- function(a) {}
21+
expect_error(
22+
kerasnip:::inp_spec(block_with_args, 123),
23+
"`input_map` must be a single string or a named character vector."
24+
)
25+
expect_error(
26+
kerasnip:::inp_spec(block_with_args, list(a = "b")),
27+
"`input_map` must be a single string or a named character vector."
28+
)
29+
})

tests/testthat/test_keras_tools.R

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
test_that("keras_evaluate throws error for missing processing functions", {
2+
# Create a dummy fit object that is missing the processing functions
3+
dummy_fit_object <- list(
4+
fit = list(
5+
fit = "dummy_keras_model"
6+
# process_x and process_y are missing
7+
)
8+
)
9+
class(dummy_fit_object) <- "model_fit"
10+
11+
expect_error(
12+
keras_evaluate(dummy_fit_object, x = mtcars[, -1], y = mtcars$mpg),
13+
"Could not find processing functions in the model fit object."
14+
)
15+
})

0 commit comments

Comments
 (0)