Skip to content

Commit 166e06a

Browse files
hidekojicursoragent
andcommitted
fix(chaid): pass report_metrics for ROC AUC / PR AUC
tidy.exploratory_chaid ignored report_metrics, so the Analytics Report metrics table kept plain AUC and omitted PR AUC / Balanced Accuracy / Specificity despite the UI requesting report_metrics=TRUE. Wire the flag through like tidy.rpart, store a training probability matrix for Macro / One-vs-Rest AUCs, and score CHAID test rows in rf_evaluation_training_and_test. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b13dfd0 commit 166e06a

3 files changed

Lines changed: 135 additions & 8 deletions

File tree

R/build_chaid.R

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ exp_chaid <- function(df,
175175
model, train_all, binary_classification_threshold
176176
)
177177
model$predicted_prob <- chaid_positive_probability(model, train_all)
178+
# Full class-probability matrix for report_metrics (Macro / One-vs-Rest AUCs).
179+
# Column names are the raw class levels so multiclass_auc_by_class() can use them.
180+
model$predicted_prob_matrix <- chaid_as_probability_matrix(
181+
train_all, model$class_levels
182+
)
178183

179184
# Metadata expected by the framework.
180185
model$terms_mapping <- names(group_name_map)
@@ -317,6 +322,37 @@ chaid_positive_probability <- function(model, all_prediction) {
317322
}
318323
}
319324

325+
#' Convert CHAID probability output to a class-named matrix.
326+
#'
327+
#' `chaid_predict(type = "prob"|"all")` uses `.pred_prob_<class>` columns. The
328+
#' shared Decision Tree report helpers (`multiclass_auc_by_class`,
329+
#' `evaluate_by_class_report_metrics`) expect colnames to be the class levels.
330+
#'
331+
#' @param prediction A data frame / matrix from `chaid_predict`, or NULL.
332+
#' @param class_levels Optional class order; columns are reordered when present.
333+
#' @return A numeric matrix, or NULL when conversion is not possible.
334+
chaid_as_probability_matrix <- function(prediction, class_levels = NULL) {
335+
if (is.null(prediction)) {
336+
return(NULL)
337+
}
338+
if (is.data.frame(prediction) || is.matrix(prediction)) {
339+
mat <- as.matrix(prediction)
340+
} else {
341+
return(NULL)
342+
}
343+
if (ncol(mat) == 0) {
344+
return(NULL)
345+
}
346+
colnames(mat) <- sub("^\\.pred_prob_", "", colnames(mat))
347+
if (!is.null(class_levels) && length(class_levels) > 0) {
348+
if (!all(class_levels %in% colnames(mat))) {
349+
return(NULL)
350+
}
351+
mat <- mat[, class_levels, drop = FALSE]
352+
}
353+
mat
354+
}
355+
320356
#' Return an empty CHAID permutation-importance result.
321357
#'
322358
#' @return A data frame with the stable importance schema.
@@ -684,14 +720,17 @@ augment.exploratory_chaid <- function(x, data = NULL, newdata = NULL,
684720
#'
685721
#' @param x A fitted `exploratory_chaid` model.
686722
#' @param pretty.name Whether to use display-friendly column names.
723+
#' @param report_metrics Whether to include Decision Tree report extras
724+
#' (ROC AUC / PR AUC / …).
687725
#' @param ... Unused.
688726
#' @return A one-row model summary data frame.
689727
#' @export
690-
glance.exploratory_chaid <- function(x, pretty.name = FALSE, ...) {
728+
glance.exploratory_chaid <- function(x, pretty.name = FALSE, report_metrics = FALSE, ...) {
691729
if ("error" %in% class(x)) {
692730
return(data.frame(Note = x$message))
693731
}
694-
tidy.exploratory_chaid(x, type = "evaluation", pretty.name = pretty.name, ...)
732+
tidy.exploratory_chaid(x, type = "evaluation", pretty.name = pretty.name,
733+
report_metrics = report_metrics, ...)
695734
}
696735

697736
#' tidy for a CHAID model (broom S3 method).
@@ -703,6 +742,9 @@ glance.exploratory_chaid <- function(x, pretty.name = FALSE, ...) {
703742
#' `partial_dependence`.
704743
#' @param pretty.name Whether to use display-friendly column names.
705744
#' @param binary_classification_threshold Positive-class threshold (binary).
745+
#' @param report_metrics Whether to include Decision Tree report extras
746+
#' (ROC AUC / PR AUC / Balanced Accuracy / Specificity for binary;
747+
#' Macro AUCs for multiclass; One-vs-Rest AUCs for evaluation_by_class).
706748
#' @param ... Unused.
707749
#' @return A data frame whose shape depends on `type`.
708750
#' @export
@@ -733,12 +775,23 @@ chaid_display_node_ids <- function(df) {
733775
}
734776

735777
tidy.exploratory_chaid <- function(x, type = "evaluation", pretty.name = FALSE,
736-
binary_classification_threshold = 0.5, ...) {
778+
binary_classification_threshold = 0.5,
779+
report_metrics = FALSE, ...) {
737780
if ("error" %in% class(x) && type != "evaluation") {
738781
return(data.frame())
739782
}
740783
actual <- x$y
741-
predicted <- x$predicted_class
784+
# Re-threshold binary labels so Settings → cut point updates F1 / Accuracy etc.
785+
# (ROC / PR AUC use predicted_prob and are threshold-independent.)
786+
predicted <- if (identical(x$classification_type, "binary") &&
787+
!is.null(x$predicted_prob)) {
788+
factor(
789+
ifelse(x$predicted_prob >= binary_classification_threshold, "TRUE", "FALSE"),
790+
levels = x$class_levels
791+
)
792+
} else {
793+
x$predicted_class
794+
}
742795
chaid_display_node_ids(switch(
743796
type,
744797
evaluation = {
@@ -747,17 +800,41 @@ tidy.exploratory_chaid <- function(x, type = "evaluation", pretty.name = FALSE,
747800
}
748801
if (identical(x$classification_type, "binary")) {
749802
evaluate_binary_classification(actual, predicted, x$predicted_prob,
750-
pretty.name = pretty.name, is_rpart = FALSE)
803+
pretty.name = pretty.name, is_rpart = FALSE,
804+
report_metrics = report_metrics)
751805
} else {
752-
evaluate_multi_(data.frame(predicted = predicted, actual = actual),
753-
"predicted", "actual", pretty.name = pretty.name)
806+
ret <- evaluate_multi_(data.frame(predicted = predicted, actual = actual),
807+
"predicted", "actual", pretty.name = pretty.name)
808+
# Mirror tidy.rpart: Macro ROC/PR AUC for the Decision Tree report (#37156).
809+
if (report_metrics) {
810+
balanced_accuracy <- multiclass_balanced_accuracy(actual, predicted)
811+
auc_by_class <- multiclass_auc_by_class(actual, x$predicted_prob_matrix)
812+
macro_roc_auc <- if (nrow(auc_by_class) > 0) mean(auc_by_class$roc_auc, na.rm = TRUE) else NA_real_
813+
macro_pr_auc <- if (nrow(auc_by_class) > 0) mean(auc_by_class$pr_auc, na.rm = TRUE) else NA_real_
814+
extra <- if (pretty.name) {
815+
tibble::tibble(`Balanced Accuracy` = balanced_accuracy,
816+
`Macro ROC AUC` = macro_roc_auc,
817+
`Macro PR AUC` = macro_pr_auc)
818+
} else {
819+
tibble::tibble(balanced_accuracy = balanced_accuracy,
820+
macro_roc_auc = macro_roc_auc,
821+
macro_pr_auc = macro_pr_auc)
822+
}
823+
ret <- dplyr::bind_cols(ret, extra)
824+
}
825+
ret
754826
}
755827
},
756828
evaluation_by_class = {
757829
per_level <- function(level) {
758830
evaluate_classification(actual, predicted, level, pretty.name = pretty.name)
759831
}
760-
dplyr::bind_rows(lapply(x$class_levels, per_level))
832+
ret <- dplyr::bind_rows(lapply(x$class_levels, per_level))
833+
if (report_metrics && nrow(ret) > 0) {
834+
ret <- dplyr::bind_cols(ret, evaluate_by_class_report_metrics(
835+
actual, predicted, x$class_levels, x$predicted_prob_matrix, pretty.name))
836+
}
837+
ret
761838
},
762839
conf_mat = {
763840
calc_conf_mat(actual, predicted)

R/randomForest_tidiers.R

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,12 @@ rf_evaluation_training_and_test <- function(data, type = "evaluation", pretty.na
15141514
source_data <- df$source.data[[1]]
15151515
if (!is.null(source_data) && length(test_rows) > 0 && "rpart" %in% class(model_object)) {
15161516
predict(model_object, newdata = source_data[test_rows, , drop = FALSE], type = "prob")
1517+
} else if (!is.null(source_data) && length(test_rows) > 0 &&
1518+
inherits(model_object, "exploratory_chaid")) {
1519+
chaid_as_probability_matrix(
1520+
predict(model_object, newdata = source_data[test_rows, , drop = FALSE], type = "prob"),
1521+
model_object$class_levels
1522+
)
15171523
} else {
15181524
NULL
15191525
}
@@ -1556,6 +1562,12 @@ rf_evaluation_training_and_test <- function(data, type = "evaluation", pretty.na
15561562
source_data <- df$source.data[[1]]
15571563
if (!is.null(source_data) && length(test_rows) > 0 && "rpart" %in% class(model_object)) {
15581564
predict(model_object, newdata = source_data[test_rows, , drop = FALSE], type = "prob")
1565+
} else if (!is.null(source_data) && length(test_rows) > 0 &&
1566+
inherits(model_object, "exploratory_chaid")) {
1567+
chaid_as_probability_matrix(
1568+
predict(model_object, newdata = source_data[test_rows, , drop = FALSE], type = "prob"),
1569+
model_object$class_levels
1570+
)
15591571
} else {
15601572
NULL
15611573
}

tests/testthat/test_build_chaid.R

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,44 @@ test_that("rf_evaluation_training_and_test produces training and test metrics",
113113
expect_true("is_test_data" %in% colnames(summary) || any(grepl("Test", colnames(summary))) || nrow(summary) >= 2)
114114
})
115115

116+
test_that("exp_chaid report_metrics adds ROC AUC / PR AUC like CART", {
117+
expected_binary <- c("ROC AUC", "PR AUC", "Balanced Accuracy", "Specificity")
118+
expected_multi <- c("Balanced Accuracy", "Macro ROC AUC", "Macro PR AUC")
119+
120+
for (test_rate in c(0, 0.3)) {
121+
df <- make_binary_df(n = 500, seed = 21)
122+
model_df <- suppressWarnings(exp_chaid(df, is_churn, plan, region, tenure,
123+
test_rate = test_rate, min_split = 20, min_bucket = 5))
124+
base <- rf_evaluation_training_and_test(model_df, pretty.name = TRUE)
125+
with_metrics <- rf_evaluation_training_and_test(model_df, pretty.name = TRUE,
126+
report_metrics = TRUE)
127+
label <- paste0("binary test_rate=", test_rate)
128+
expect_true(all(expected_binary %in% colnames(with_metrics)), info = label)
129+
expect_false(any(expected_binary %in% colnames(base)), info = label)
130+
expect_false("AUC" %in% colnames(with_metrics), info = label)
131+
expect_equal(nrow(with_metrics), if (test_rate > 0) 2 else 1, info = label)
132+
expect_false(any(is.na(with_metrics[, expected_binary, drop = FALSE])), info = label)
133+
expect_equal(
134+
intersect(c("ROC AUC", "PR AUC", "F1 Score", "Balanced Accuracy", "Accuracy Rate",
135+
"Misclass. Rate", "Precision", "Recall", "Specificity"),
136+
colnames(with_metrics)),
137+
c("ROC AUC", "PR AUC", "F1 Score", "Balanced Accuracy", "Accuracy Rate",
138+
"Misclass. Rate", "Precision", "Recall", "Specificity"),
139+
info = label
140+
)
141+
}
142+
143+
df <- make_multi_df(n = 500, seed = 22)
144+
model_df <- suppressWarnings(exp_chaid(df, segment, channel, age_group,
145+
min_split = 20, min_bucket = 5))
146+
with_metrics <- rf_evaluation_training_and_test(model_df, pretty.name = TRUE,
147+
report_metrics = TRUE)
148+
expect_true(all(expected_multi %in% colnames(with_metrics)))
149+
by_class <- rf_evaluation_training_and_test(model_df, type = "evaluation_by_class",
150+
pretty.name = TRUE, report_metrics = TRUE)
151+
expect_true(all(c("Balanced Accuracy", "ROC AUC", "PR AUC", "Overall Share") %in% colnames(by_class)))
152+
})
153+
116154
test_that("confusion matrix tidy returns actual/predicted/count", {
117155
df <- make_binary_df()
118156
model_df <- suppressWarnings(exp_chaid(df, is_churn, plan, region,

0 commit comments

Comments
 (0)