Skip to content

Commit ff79412

Browse files
kaipingyangclaudeshajoezhugithub-actions[bot]Copilot
authored
feat: add factor_level_method, factor_as_factor, factor_level_last_pattern to df_explicit_na (#1487)
## Summary Adds three new arguments to `df_explicit_na()` to give users control over factor level ordering. Motivated by #1322. ### New arguments - **`factor_level_method`** (`"sort_auto"` / `"sort_radix"` / `"data"`) — controls how levels are ordered when character or logical columns are converted to factors (or existing factors are re-encoded via `factor_as_factor`): - `"sort_auto"` (default): `sort(unique(x))` — locale-aware, identical to the original behaviour. No existing code is affected. - `"sort_radix"`: `sort(unique(x), method = "radix")` — byte-order (ASCII) sort, not locale-sensitive. Uppercase letters always sort before lowercase, which matches the behaviour of SAS `PROC SORT` on mixed-case data. - `"data"`: `unique(x)` — levels follow first-appearance order in the data. - **`factor_as_factor`** (`FALSE` by default) — when `TRUE`, existing factor columns are also re-encoded using `factor_level_method`. The default `FALSE` preserves the original behaviour (existing factor levels are kept as-is). - **`factor_level_last_pattern`** (`NULL` by default) — a regular expression; any levels matching it are moved to the end of the level list, immediately before `na_level`. Useful for consistently placing catch-all categories (e.g. `"Other"`, `"Unknown"`) last. Has no effect when `NULL`. ### Backward compatibility All three arguments have defaults that exactly reproduce the original function behaviour, so no existing code needs to change. ## Test plan - [ ] `factor_level_method = "sort_auto"` produces identical results to the unmodified function - [ ] `factor_level_method = "sort_radix"` sorts uppercase before lowercase on mixed-case data - [ ] `factor_level_method = "data"` preserves first-appearance order - [ ] `factor_as_factor = TRUE` re-encodes existing factor levels; `FALSE` leaves them unchanged - [ ] `factor_level_last_pattern` moves matching levels to end; `NULL` is a no-op - [ ] `na_level` remains the last level in all cases - [ ] Input validation errors for invalid argument types Fixes #1322 --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Joe Zhu <joe.zhu@roche.com> Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 1303be4 commit ff79412

6 files changed

Lines changed: 181 additions & 4 deletions

File tree

NEWS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
# tern 0.9.10.9017
22

33
### Enhancements
4+
* Added `factor_level_method` argument to `df_explicit_na()` to control factor level ordering
5+
when converting character or logical columns. Supported methods: `"sort_auto"` (default,
6+
locale-aware, preserves original behavior), `"sort_radix"` (byte-order / ASCII sort), and
7+
`"data"` (first-appearance order). (#1322)
8+
* Added `factor_as_factor` argument to `df_explicit_na()` to allow re-encoding of existing
9+
factor columns using `factor_level_method`. Defaults to `FALSE` to preserve original behavior.
10+
* Added `factor_level_last_pattern` argument to `df_explicit_na()` to move factor levels
11+
matching a regular expression to the end (before `na_level`).
412
* Added `alternative` argument to `s_coxph_pairwise()` to allow one-sided hypothesis testing.
513
* Added `lr_stat_df` to the parameters return list of `s_coxph_pairwise()`.
614
* Added `uncond_exact_diff` method to `estimate_proportion_diff()` for the unconditional exact confidence interval for the difference in proportions by inverting one-sided tail tests over a nuisance parameter.

R/df_explicit_na.R

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,28 @@
2121
#' in `data` to factors.
2222
#' @param na_level (`string`)\cr string used to replace all `NA` or empty
2323
#' values inside non-`omit_columns` columns.
24+
#' @param factor_as_factor (`flag`)\cr whether to re-encode existing factor variables
25+
#' using `factor_level_method`. When `FALSE` (default), existing factor levels are
26+
#' preserved as-is (original behavior).
27+
#' @param factor_level_method (`string`)\cr method used to order factor levels when
28+
#' converting character or logical variables (or existing factors when
29+
#' `factor_as_factor = TRUE`). One of:
30+
#' \describe{
31+
#' \item{`"sort_auto"`}{`sort(unique(x))` — default R sort, locale-aware (default).
32+
#' Preserves the original behavior of this function.}
33+
#' \item{`"sort_radix"`}{`sort(unique(x), method = "radix")` — byte-order (ASCII) sort.
34+
#' Unlike `"sort_auto"`, this is not locale-sensitive: uppercase letters always sort
35+
#' before lowercase. On data where all values share the same case (e.g. all-caps
36+
#' ADaM variables) the two methods produce identical results.}
37+
#' \item{`"data"`}{`unique(x)` — levels in order of first appearance in the data.}
38+
#' }
39+
#' @param factor_level_last_pattern (`string` or `NULL`)\cr regular expression. Any
40+
#' factor levels matching this pattern are moved to the end (before `na_level`).
41+
#' `NULL` (default) disables this behaviour. Note: this parameter only takes effect
42+
#' when factor levels are being re-encoded (i.e. for character/logical columns with
43+
#' `char_as_factor`/`logical_as_factor`, or for existing factor columns with
44+
#' `factor_as_factor = TRUE`). Existing factor columns where `factor_as_factor = FALSE`
45+
#' are not affected.
2446
#'
2547
#' @return A `data.frame` with the chosen modifications applied.
2648
#'
@@ -66,17 +88,34 @@
6688
#' adsl$AGE[adsl$AGE < 30] <- NA
6789
#' adsl <- df_explicit_na(adsl)
6890
#'
91+
#' # Example 4: Control factor level ordering
92+
#' # Use radix sort to match SAS PROC SORT behavior.
93+
#' df_explicit_na(my_data, factor_level_method = "sort_radix")
94+
#' # Use data order (first appearance).
95+
#' df_explicit_na(my_data, factor_level_method = "data")
96+
#'
97+
#' # Example 5: Move matching levels to the end
98+
#' # Levels matching "^Other" are placed last (before na_level).
99+
#' df_explicit_na(my_data, factor_level_last_pattern = "^Other")
100+
#'
69101
#' @export
70102
df_explicit_na <- function(data,
71103
omit_columns = NULL,
72104
char_as_factor = TRUE,
73105
logical_as_factor = FALSE,
74-
na_level = "<Missing>") {
106+
na_level = "<Missing>",
107+
factor_as_factor = FALSE,
108+
factor_level_method = c("sort_auto", "sort_radix", "data"),
109+
factor_level_last_pattern = NULL) {
75110
checkmate::assert_character(omit_columns, null.ok = TRUE, min.len = 1, any.missing = FALSE)
76111
checkmate::assert_data_frame(data)
77112
checkmate::assert_flag(char_as_factor)
78113
checkmate::assert_flag(logical_as_factor)
114+
checkmate::assert_flag(factor_as_factor)
79115
checkmate::assert_string(na_level)
116+
checkmate::assert_string(factor_level_last_pattern, null.ok = TRUE)
117+
factor_level_method <- factor_level_method[[1]]
118+
checkmate::assert_choice(factor_level_method, c("sort_auto", "sort_radix", "data"))
80119

81120
target_vars <- if (is.null(omit_columns)) {
82121
names(data)
@@ -99,6 +138,7 @@ df_explicit_na <- function(data,
99138
# Determine whether to convert character or logical input.
100139
do_char_conversion <- is.character(xi) && char_as_factor
101140
do_logical_conversion <- is.logical(xi) && logical_as_factor
141+
do_factor_conversion <- is.factor(xi) && factor_as_factor
102142

103143
# Pre-convert logical to character to deal correctly with replacing NA
104144
# values below.
@@ -112,8 +152,23 @@ df_explicit_na <- function(data,
112152

113153
# Convert to factors if requested for the original type,
114154
# set na_level as the last value.
115-
if (do_char_conversion || do_logical_conversion) {
116-
levels_xi <- setdiff(sort(unique(xi)), na_level)
155+
if (do_char_conversion || do_logical_conversion || do_factor_conversion) {
156+
if (do_factor_conversion) {
157+
xi <- as.character(xi)
158+
}
159+
160+
sort_xi <- switch(factor_level_method,
161+
"data" = unique(xi),
162+
"sort_radix" = sort(unique(xi), method = "radix"),
163+
sort(unique(xi))
164+
)
165+
166+
if (!is.null(factor_level_last_pattern)) {
167+
last_levels <- grep(factor_level_last_pattern, sort_xi, value = TRUE)
168+
sort_xi <- c(setdiff(sort_xi, last_levels), last_levels)
169+
}
170+
171+
levels_xi <- setdiff(sort_xi, na_level)
117172
if (na_level %in% unique(xi)) {
118173
levels_xi <- c(levels_xi, na_level)
119174
}

inst/WORDLIST

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
ADaM
12
ADAE
23
ADLB
34
ADPP
@@ -37,6 +38,8 @@ Satterthwaite
3738
Schouten
3839
TLG
3940
TLGs
41+
Tord
42+
behaviour
4043
binom
4144
biomarker
4245
biomarkers

man/df_explicit_na.Rd

Lines changed: 39 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/tern-package.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-df_explicit_na.R

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,75 @@ testthat::test_that("df_explicit_na just returns unmodified data if all columns
9090
))
9191
testthat::expect_identical(result, my_data)
9292
})
93+
94+
testthat::test_that("factor_level_method = 'sort_auto' gives same result as default", {
95+
my_data <- example_data
96+
result_default <- df_explicit_na(my_data, char_as_factor = TRUE)
97+
result_auto <- df_explicit_na(my_data, char_as_factor = TRUE, factor_level_method = "sort_auto")
98+
testthat::expect_identical(result_default, result_auto)
99+
})
100+
101+
testthat::test_that("factor_level_method = 'sort_radix' uses byte-order sort (uppercase before lowercase)", {
102+
# Mixed-case values: sort_auto is locale-aware (case-insensitive on en_US.UTF-8),
103+
# sort_radix is byte-order (uppercase < lowercase, consistent with SAS PROC SORT).
104+
my_data <- data.frame(x = c("UNKNOWN", "Unknown", "unknown", NA), stringsAsFactors = FALSE)
105+
result_radix <- df_explicit_na(my_data, factor_level_method = "sort_radix")
106+
result_auto <- df_explicit_na(my_data, factor_level_method = "sort_auto")
107+
# radix: UNKNOWN < Unknown < unknown (ASCII byte order)
108+
testthat::expect_equal(levels(result_radix$x), c("UNKNOWN", "Unknown", "unknown", "<Missing>"))
109+
})
110+
111+
testthat::test_that("factor_level_method = 'data' preserves first-appearance order", {
112+
my_data <- data.frame(x = c("C", "A", "B", NA), stringsAsFactors = FALSE)
113+
result <- df_explicit_na(my_data, factor_level_method = "data")
114+
testthat::expect_equal(levels(result$x), c("C", "A", "B", "<Missing>"))
115+
})
116+
117+
testthat::test_that("factor_as_factor re-encodes existing factor levels using factor_level_method", {
118+
my_data <- data.frame(
119+
x = factor(c("C", "A", "B"), levels = c("C", "A", "B")),
120+
stringsAsFactors = FALSE
121+
)
122+
result <- df_explicit_na(my_data, factor_as_factor = TRUE, factor_level_method = "sort_auto")
123+
testthat::expect_equal(levels(result$x), c("A", "B", "C"))
124+
})
125+
126+
testthat::test_that("factor_as_factor = FALSE preserves existing factor levels (original behavior)", {
127+
my_data <- data.frame(
128+
x = factor(c("C", "A", "B"), levels = c("C", "A", "B")),
129+
stringsAsFactors = FALSE
130+
)
131+
result <- df_explicit_na(my_data, factor_as_factor = FALSE)
132+
testthat::expect_equal(levels(result$x), c("C", "A", "B"))
133+
})
134+
135+
testthat::test_that("factor_level_last_pattern moves matching levels to end", {
136+
my_data <- data.frame(x = c("Other", "A", "B", "Other2"), stringsAsFactors = FALSE)
137+
result <- df_explicit_na(my_data, factor_level_last_pattern = "^Other")
138+
lvls <- levels(result$x)
139+
# "A", "B" before "Other*"
140+
testthat::expect_true(which(lvls == "A") < which(lvls == "Other"))
141+
testthat::expect_true(which(lvls == "B") < which(lvls == "Other2"))
142+
})
143+
144+
testthat::test_that("factor_level_last_pattern = NULL does not change level order", {
145+
my_data <- data.frame(x = c("C", "A", "B"), stringsAsFactors = FALSE)
146+
result_with <- df_explicit_na(my_data, factor_level_last_pattern = NULL)
147+
result_without <- df_explicit_na(my_data)
148+
testthat::expect_identical(levels(result_with$x), levels(result_without$x))
149+
})
150+
151+
testthat::test_that("factor_level_last_pattern combined with na_level: na_level stays last", {
152+
my_data <- data.frame(x = c("Other", "A", NA), stringsAsFactors = FALSE)
153+
result <- df_explicit_na(my_data, factor_level_last_pattern = "^Other")
154+
lvls <- levels(result$x)
155+
testthat::expect_equal(tail(lvls, 1), "<Missing>")
156+
testthat::expect_true(which(lvls == "Other") < which(lvls == "<Missing>"))
157+
})
158+
159+
testthat::test_that("Check new parameter errors", {
160+
my_data <- example_data
161+
testthat::expect_error(df_explicit_na(my_data, factor_as_factor = "TRUE"), "logical")
162+
testthat::expect_error(df_explicit_na(my_data, factor_level_last_pattern = 123), "string")
163+
testthat::expect_error(df_explicit_na(my_data, factor_level_method = "invalid"), "Must be element")
164+
})

0 commit comments

Comments
 (0)