Classification
Shows which factors predict an outcome, how well the outcomes can be told apart on data set aside for testing, and where the predictions go wrong.
RUN DATE · 14 September 2026
DATA · 600 rows
Which factors predict churn, and how well can we classify it?
- SummaryHow well the outcomes are told apart, and how often the predictions are right.
- Performance in detailEvery accuracy measure, on data set aside for testing.
- Catching the right casesHow many true cases are caught for each false alarm accepted.
- Which factors matter mostEvery factor ranked by how strongly it predicts the outcome.
- Effect of each factorHow much each factor raises or lowers the chance, on one comparable scale, with its likely range.
- Can the probabilities be trustedWhether a predicted chance matches how often the outcome actually happens.
- Right and wrong predictionsHow many cases were predicted correctly, missed, or wrongly flagged.
- Full resultsEvery estimate and its uncertainty, to check the figures line by line.
- What the results rely onEach condition the analysis depends on, and whether it holds.
- How it was doneThe method, the data used, and what to keep in mind.
How well it separates
Model beats baseline with moderate separation
Classification performance exceeds guessing the majority class, though collinearity limits how much we can trust individual driver effects.
Sensitivity and specificity are roughly balanced, suggesting the threshold handles both churners and non-churners evenly without favoring one class.
Model beats baseline with moderate separation
Accuracy and AUC both fall well above the base rate, showing the model separates churners from non-churners clearly. The confidence interval for AUC stays above chance across its full range. However, strong collinearity among drivers constrains the reliability of which individual factors matter most, so readers should weight the overall separation over any single driver's apparent strength.
Sensitivity and specificity are roughly balanced, suggesting the threshold handles both churners and non-churners evenly without favoring one class.
How well it separates
Model separates churners clearly at low false alarms
The curve bows sharply above the diagonal early, catching many true churners before accepting false alarms.
The curve's steep rise from the origin to around one-third false-positive rate, where true-positive rate reaches roughly two-thirds.
Model separates churners clearly at low false alarms
The ROC curve rises steeply from the lower left, showing strong separation of churners from non-churners at low false-positive rates. The gain is concentrated in the early part of the curve, where most true cases are caught with few false alarms. This clear early separation is limited by underlying collinearity among drivers, which inflates some effect estimates but does not undermine the classification gain shown here. Readers should recognize that tenure months, support tickets, and plan carry the predictive weight, while other drivers contribute less.
The curve's steep rise from the origin to around one-third false-positive rate, where true-positive rate reaches roughly two-thirds.
What drives it
Tenure leads, support tickets follow, others weak
Tenure months predicts churn most strongly, support tickets clearly behind, others far weaker and less certain.
Tenure months shows the highest importance bar and lowers odds; support tickets is clearly shorter and raises odds.
Tenure leads, support tickets follow, others weak
Tenure months dominates prediction, with support tickets at roughly two-thirds its strength and both highly significant. Plan shows weak statistical support, while region and monthly charge are far behind and lack clear significance. Collinearity is violated, which may inflate some standard errors, but the top two drivers remain decisive for answering which factors predict churn.
Tenure months shows the highest importance bar and lowers odds; support tickets is clearly shorter and raises odds.
Tenure leads churn prediction, support tickets follow
Tenure strongly lowers churn odds; support tickets raise them; other factors show weak or mixed effects.
Tenure months interval lies entirely left of zero; support tickets interval clearly right of zero.
Tenure leads churn prediction, support tickets follow
Tenure months shows the strongest protective effect, with its interval well clear of zero on the negative side. Support tickets raises churn odds clearly, with its interval firmly right of zero. Plan type shows mixed evidence, as both enterprise and pro intervals cross zero. Region factors are negligible, all intervals tight around zero. Collinearity is violated, which may inflate some intervals; focus on tenure and support tickets as the clearest predictors.
Tenure months interval lies entirely left of zero; support tickets interval clearly right of zero.
At the threshold
Predicted chances track observed rates loosely
Predicted probabilities follow observed rates loosely, limiting confidence in risk scores across the range.
Points scatter around the diagonal, with notable gaps in the middle bins where predictions diverge from outcomes.
Predicted chances track observed rates loosely
The bins show predicted chances generally rising with observed rates, but the scatter is substantial, particularly in the middle range where predictions sit well below observed rates. The lowest bin sits well below the diagonal and the highest bin sits above it, indicating the model is neither consistently overconfident nor underconfident but rather uneven across the probability range. This loose calibration means predicted scores should not be trusted as precise risk measures, though the overall trend remains directionally sound. Check whether specific bins drive decisions, as some probability ranges are more reliable than others.
Points scatter around the diagonal, with notable gaps in the middle bins where predictions diverge from outcomes.
Model catches most churners but flags false alarms
Model catches most churners but wrongly flags many non-churners, limiting its usefulness.
False positives nearly match true positives, weakening practical value for intervention.
Model catches most churners but flags false alarms
True positives and false positives are roughly balanced in count, meaning the model identifies most actual churners but at the cost of many false alarms. This mixed performance reflects the competing drivers in the data: tenure months is a strong predictor, but support tickets, plan, region, and monthly charge contribute weakly and unevenly. The violated collinearity check suggests some drivers may be redundant or confounded, which could improve the model if resolved. Next, examine whether reducing false positives by adjusting the threshold or removing weak drivers would serve your intervention strategy better.
False positives nearly match true positives, weakening practical value for intervention.
The coefficients
Tenure and support tickets drive churn predictions
Tenure and support tickets strongly predict churn; plan and region show weak signals; monthly charge shows none.
Plan enterprise and pro both have intervals spanning no effect despite their point estimates, whereas tenure and support tickets have narrow intervals well away from one.
Tenure and support tickets drive churn predictions
Tenure months shows the strongest standardized effect, with longer tenure clearly associated with lower churn odds. Support tickets shows strong association in the opposite direction, with more tickets linked to higher churn odds. Plan levels show weak signals with wide uncertainty intervals that cross the no-effect boundary. Region shows negligible effects across all levels. Collinearity is violated, which limits confidence in separating plan and region effects, but the two dominant predictors remain clear.
Plan enterprise and pro both have intervals spanning no effect despite their point estimates, whereas tenure and support tickets have narrow intervals well away from one.
Assumptions and method
Checks: one violated, five hold
Violated: no strong collinearity.
Holding: measured on held-out rows, separation beyond chance, calibrated probabilities, enough events per term, class balance.
Checks: one violated, five hold
No strong collinearity is violated: a VIF above 5 inflates that driver's standard error. The other five hold.
Holding: measured on held-out rows, separation beyond chance, calibrated probabilities, enough events per term, class balance.
Logistic regression of churned (positive class: 1) on tenure_months, monthly_charge, support_tickets, plan, region; 600 rows with an outcome of 600. Stratified 70/30 split (seed 42): fitted on 420 rows, every performance figure measured on the 180 held-out rows. AUC by the rank formula with a Hanley-McNeil 95% interval; threshold 0.544 chosen on the training part by Youden's J and applied unchanged; odds ratios with 95% Wald intervals; driver importance = max |z| per driver scaled to 100; calibration in ten equal-count bins.
600 of 600 rows · tenure_months, monthly_charge, support_tickets, plan, region → churned
caveatCollinearity among drivers is violated; results may overstate some effects and understate others.
The logistic regression fitted on stratified training data and measured on held-out rows, with all other checks passing. The violated collinearity check means that some drivers are correlated with each other, which can distort the individual effect sizes and confidence intervals reported for each driver. A reader should compare the drivers by their relative rank rather than trust the magnitude of any single effect, and should check whether the strongest drivers remain stable if one is removed. The held-out split and Youden threshold choice are sound, but the driver importance scores may not reflect their true independent contribution to prediction.
The code behind this report
The code that produced every figure in this report, exactly as it ran. Fingerprint 92bfffaa939d5f26. The same code on the same data gives the same report.
`standard_classification_v2` <- function(pf) {
`%||%` <- function(a, b) if (!is.null(a)) a else b
#' Readable figures (LAT-3181, as LAT-3180): whole numbers from a thousand up, one decimal from a hundred, two from
#' one, three significant figures below one. A cell carries what the value needs, not what R prints.
tidy <- function(x) {
x <- as.numeric(x)
ifelse(is.na(x), NA_real_,
ifelse(abs(x) >= 1000, round(x, 0),
ifelse(abs(x) >= 100, round(x, 1),
ifelse(abs(x) >= 1, round(x, 2), signif(x, 3)))))
}
inputs <- pf$taskList$inputs
#' P-VALUES BELOW 0.0001 LEAVE AS A FLOOR (LAT-3181). The results serialize at four decimal digits, which rounds a p of
#' 1.4e-05 to 0 and 6e-05 up to 0.0001; a table printed "p-value 0". 1e-12 survives the serializer and the mapper shows
#' any p under 0.0001 as "<0.0001", which is what the reader should see. Table and tile cells only; the answer keeps p.
p_cell <- function(p) { p <- as.numeric(p); ifelse(is.na(p), NA_real_, ifelse(p < 1e-4, 1e-12, signif(p, 3))) }
params <- inputs$module_parameters %||% list()
# THE QUESTION this tool answers: the customer's objective, verbatim, when given.
question <- (inputs$userContext %||% list())$objective %||%
"Which drivers predict the outcome class, and how well does the classifier separate the classes on data it has not seen?"
#' ## Column mapping
#' The customer maps one binary `outcome` and one to eight `driver_N` columns.
#' Semantic names are used inside; the customer's own headers are carried in
#' `col_map` so every table, term and axis names the columns the reader knows.
col_map <- inputs$column_mapping %||% list()
df <- renderObject.taskFunction.init(inputs, col_map) # df has SEMANTIC names
human <- function(sem) {
v <- col_map[[sem]]
if (is.null(v) || !nzchar(as.character(v))) sem else as.character(v)
}
#' ## The outcome
#' Two classes. A numeric 0/1 column is read as is (1 = positive); a two-level
#' text column takes the LESS frequent level as positive unless
#' `module_parameters$positive` names one; yes/no, true/false, y/n are read.
#' Rows without an outcome are dropped. More than two levels is a refusal.
n_in <- nrow(df)
if (!"outcome" %in% names(df)) stop("column_mapping must map an 'outcome' column (the two-class outcome to predict)")
driver_cols <- grep("^driver_[0-9]+$", names(df), value = TRUE)
driver_cols <- driver_cols[order(as.integer(sub("^driver_", "", driver_cols)))]
if (length(driver_cols) == 0) stop("column_mapping must map at least one driver column (driver_1)")
raw <- df$outcome
raw_chr <- trimws(tolower(as.character(raw)))
raw_chr[is.na(raw) | raw_chr == "" | raw_chr == "na"] <- NA
df <- df[!is.na(raw_chr), , drop = FALSE]; raw_chr <- raw_chr[!is.na(raw_chr)]
lv <- sort(unique(raw_chr))
#' TWO CLASSES OR MANY (LAT-3077). A price tier, a grade, a segment: three or more
#' classes is an ordinary classification question and refusing it sent a correctly
#' mapped dataset away. Above two we fit ONE-VS-REST: a logistic model per class,
#' an AUC per class with a macro average, K curves on one ROC and a K by K confusion
#' table at the arg-max class. The binary path below is unchanged.
MAX_CLASSES <- 10
if (length(lv) < 2) stop(sprintf("The outcome '%s' has one value; a classifier needs at least two classes.", human("outcome")))
if (length(lv) > MAX_CLASSES) stop(sprintf("The outcome '%s' has %d distinct values; a classifier handles up to %d classes. If this is a number to predict rather than a class, use the regression tool; if it is an identifier, map a different column.", human("outcome"), length(lv), MAX_CLASSES))
multiclass <- length(lv) > 2
positive_param <- tolower(trimws(as.character(params$positive %||% "")))
yes_words <- c("1", "true", "yes", "y", "t")
if (multiclass) {
#' The NAMED class leads the report (the parameter, else the rarest, which is the
#' one a reader usually cares about); every class still gets its own AUC and row.
positive <- if (nzchar(positive_param) && positive_param %in% lv) positive_param else names(sort(table(raw_chr)))[1]
negative <- paste0("not ", positive)
y <- as.integer(raw_chr == positive)
} else {
positive <- if (nzchar(positive_param) && positive_param %in% lv) positive_param
else if (any(lv %in% yes_words)) lv[lv %in% yes_words][1]
else names(sort(table(raw_chr)))[1] # the rarer class
negative <- setdiff(lv, positive)
y <- as.integer(raw_chr == positive)
}
df$outcome <- y
df$.class <- raw_chr
#' ## Drivers
#' Numeric drivers are median-imputed; text drivers become factors with blanks as
#' "Missing" and levels beyond twelve lumped into "Other"; identifier-like and
#' constant drivers are excluded.
dropped <- character(0); why <- character(0)
for (dc in driver_cols) {
v <- df[[dc]]
if (!is.numeric(v)) {
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & as.character(v) != "")
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) df[[dc]] <- conv
}
v <- df[[dc]]
if (is.numeric(v)) {
med <- median(v, na.rm = TRUE)
if (is.na(med)) { dropped <- c(dropped, dc); why <- c(why, "empty"); next }
v[is.na(v)] <- med; df[[dc]] <- v
if (isTRUE(var(v) == 0)) { dropped <- c(dropped, dc); why <- c(why, "constant"); next }
if (length(unique(v)) == length(v) && all(v == round(v)) && length(v) > 50 && isTRUE(all(diff(sort(v)) == 1))) {
dropped <- c(dropped, dc); why <- c(why, "identifier-like (a running index)"); next }
} else {
v <- as.character(v); v[is.na(v) | trimws(v) == ""] <- "Missing"
tab <- sort(table(v), decreasing = TRUE)
if (length(tab) > 12) v[!(v %in% names(tab)[1:12])] <- "Other"
if (length(unique(v)) > nrow(df) / 2) { dropped <- c(dropped, dc); why <- c(why, "identifier-like (almost every row its own value)"); next }
if (length(unique(v)) <= 1) { dropped <- c(dropped, dc); why <- c(why, "constant"); next }
df[[dc]] <- factor(v)
}
}
model_drivers <- setdiff(driver_cols, dropped)
if (length(model_drivers) == 0) stop("No usable driver columns remained after cleaning (all constant, empty, or identifier-like).")
n_used <- nrow(df)
n_pos <- sum(y); n_neg <- n_used - n_pos
if (n_used < 30) stop(sprintf("Only %d rows carry an outcome; at least 30 are required.", n_used))
if (min(n_pos, n_neg) < 10) stop(sprintf("The rarer class ('%s') has only %d rows; at least 10 are required.", positive, min(n_pos, n_neg)))
#' ## Held-out split
#' A stratified 70/30 split (seed 42) when there are at least 100 rows and 20 of the
#' rarer class; the model is fitted on the training part and EVERY performance
#' figure below is measured on the test part. Below that size the model is fitted
#' on all rows and the figures are in-sample, which the method says plainly.
set.seed(42)
holdout <- n_used >= 100 && min(n_pos, n_neg) >= 20
if (holdout) {
idx_pos <- which(y == 1); idx_neg <- which(y == 0)
test_idx <- c(sample(idx_pos, round(0.3 * length(idx_pos))), sample(idx_neg, round(0.3 * length(idx_neg))))
train <- df[-test_idx, c(model_drivers, "outcome"), drop = FALSE]
test <- df[ test_idx, c(model_drivers, "outcome"), drop = FALSE]
# a factor level seen only in the test part cannot be scored: those rows are set aside and counted
unseen <- rep(FALSE, nrow(test))
for (dc in model_drivers) if (is.factor(train[[dc]])) {
train[[dc]] <- droplevels(train[[dc]])
unseen <- unseen | !(as.character(test[[dc]]) %in% levels(train[[dc]]))
test[[dc]] <- factor(as.character(test[[dc]]), levels = levels(train[[dc]]))
}
n_unseen <- sum(unseen); test <- test[!unseen, , drop = FALSE]
cls_train <- df$.class[-test_idx]; cls_test <- df$.class[test_idx][!unseen]
} else {
train <- df[, c(model_drivers, "outcome"), drop = FALSE]; test <- train; n_unseen <- 0L
cls_train <- df$.class; cls_test <- df$.class
}
n_train <- nrow(train); n_test <- nrow(test)
#' ## Logistic regression
#' `glm(outcome ~ ., binomial)` on the training part. Coefficients are reported as
#' odds ratios with 95% Wald intervals; a driver's importance is the largest |z|
#' across its terms, scaled so the strongest driver reads 100.
model <- suppressWarnings(glm(outcome ~ ., data = train, family = binomial()))
sm <- summary(model); co <- sm$coefficients
terms_raw <- rownames(co)
human_term <- function(t) {
if (t == "(Intercept)") return("(Intercept)")
for (dc in model_drivers) if (startsWith(t, dc)) {
lvl <- substring(t, nchar(dc) + 1)
return(if (nzchar(lvl)) paste0(human(dc), " = ", lvl) else human(dc))
}
t
}
est <- unname(co[, "Estimate"]); se <- unname(co[, "Std. Error"])
#' PER-SD EFFECT (LAT-3181, the logistic twin of LAT-3180's beta): the change in log odds for one standard deviation
#' of the term's model-matrix column, so terms in different units compare on one axis, with 0 as no effect. The
#' interval chart plotted raw odds ratios: per-month and per-dollar terms shrank to dots beside plan levels, and the
#' chart's reference line sat at 0 while an odds ratio's no-effect value is 1.
Xm <- model.matrix(model); col_sd <- apply(Xm[, -1, drop = FALSE], 2, sd)
term_sd <- vapply(terms_raw, function(t) if (t %in% names(col_sd)) unname(col_sd[t]) else NA_real_, numeric(1))
coef_df <- data.frame(
term = vapply(terms_raw, human_term, character(1), USE.NAMES = FALSE),
odds_ratio = tidy(exp(est)),
log_odds = tidy(est),
per_sd = round(est * unname(term_sd), 3),
std_error = tidy(se),
z_value = round(unname(co[, "z value"]), 2),
p_value = p_cell(unname(co[, "Pr(>|z|)"])),
low = tidy(exp(est - 1.96 * se)),
high = tidy(exp(est + 1.96 * se)),
stringsAsFactors = FALSE)
rownames(coef_df) <- NULL
slope_df <- coef_df[coef_df$term != "(Intercept)" & is.finite(coef_df$odds_ratio) & coef_df$high < 1e6, , drop = FALSE]
imp_rows <- lapply(model_drivers, function(dc) {
idx <- which(startsWith(terms_raw, dc) & terms_raw != "(Intercept)")
idx <- idx[is.finite(co[idx, "z value"])]
if (length(idx) == 0) return(NULL)
best <- idx[which.max(abs(co[idx, "z value"]))]
data.frame(driver = human(dc), abs_z = abs(unname(co[best, "z value"])),
direction = if (is.numeric(train[[dc]])) { if (co[best, "Estimate"] > 0) "raises the odds" else "lowers the odds" } else "categorical",
p_value = p_cell(unname(co[best, "Pr(>|z|)"])), stringsAsFactors = FALSE)
})
imp_df <- do.call(rbind, imp_rows)
imp_df <- imp_df[order(-imp_df$abs_z), , drop = FALSE]
imp_df$importance <- round(100 * imp_df$abs_z / max(imp_df$abs_z), 1)
imp_df$abs_z <- NULL; rownames(imp_df) <- NULL
top_driver <- imp_df$driver[1]; top_dir <- imp_df$direction[1]
#' ## One-vs-rest, when there are more than two classes
#' The model above is already the NAMED class against the rest; here every other
#' class gets the same treatment on the same split, so each class has an AUC and a
#' curve, and a row is assigned to whichever class scores it highest.
ovr <- NULL
if (multiclass) {
ovr <- list()
for (k in lv) {
tr_k <- train; tr_k$outcome <- as.integer(cls_train == k)
if (sum(tr_k$outcome) < 5 || sum(tr_k$outcome) == nrow(tr_k)) next
m_k <- suppressWarnings(glm(outcome ~ ., data = tr_k, family = binomial()))
ovr[[k]] <- list(model = m_k,
p_test = suppressWarnings(as.numeric(predict(m_k, newdata = test, type = "response"))),
y_test = as.integer(cls_test == k))
}
if (length(ovr) < 2) stop("Fewer than two classes had enough rows in the training part to fit a model; the outcome may be too sparse to classify.")
}
#' ## Performance on the held-out part
#' AUC by the rank (Mann-Whitney) formula with a Hanley-McNeil 95% interval; the
#' ROC curve at every distinct score; the threshold is chosen on the TRAINING
#' part (Youden's J) and applied unchanged to the test part; the confusion
#' counts, sensitivity, specificity, precision, accuracy, F1 and the Brier score
#' are all test-part figures; calibration compares mean predicted probability with
#' the observed rate in ten equal-count bins of the test part.
p_train <- suppressWarnings(as.numeric(predict(model, newdata = train, type = "response")))
p_test <- suppressWarnings(as.numeric(predict(model, newdata = test, type = "response")))
y_test <- test$outcome
auc_of <- function(p, yy) {
r <- rank(p); np <- sum(yy == 1); nn <- sum(yy == 0)
if (np == 0 || nn == 0) return(NA_real_)
(sum(r[yy == 1]) - np * (np + 1) / 2) / (np * nn)
}
auc <- auc_of(p_test, y_test)
np_t <- sum(y_test == 1); nn_t <- sum(y_test == 0)
q1 <- auc / (2 - auc); q2 <- 2 * auc^2 / (1 + auc)
auc_se <- sqrt((auc * (1 - auc) + (np_t - 1) * (q1 - auc^2) + (nn_t - 1) * (q2 - auc^2)) / (np_t * nn_t))
auc_low <- max(0, auc - 1.96 * auc_se); auc_high <- min(1, auc + 1.96 * auc_se)
roc_at <- function(p, yy, thr) {
pred <- p >= thr
c(tpr = sum(pred & yy == 1) / sum(yy == 1), fpr = sum(pred & yy == 0) / sum(yy == 0))
}
thr_grid <- sort(unique(c(0, p_train, 1)))
if (length(thr_grid) > 400) thr_grid <- unique(quantile(thr_grid, probs = seq(0, 1, length.out = 400), names = FALSE))
j_vals <- vapply(thr_grid, function(t) { rr <- roc_at(p_train, train$outcome, t); rr[["tpr"]] - rr[["fpr"]] }, numeric(1))
threshold <- thr_grid[which.max(j_vals)]
roc_grid <- sort(unique(c(0, p_test, 1)))
if (length(roc_grid) > 300) roc_grid <- unique(quantile(roc_grid, probs = seq(0, 1, length.out = 300), names = FALSE))
roc_df <- do.call(rbind, lapply(rev(roc_grid), function(t) { rr <- roc_at(p_test, y_test, t); data.frame(fpr = round(rr[["fpr"]], 3), tpr = round(rr[["tpr"]], 3)) }))
roc_df <- roc_df[!duplicated(roc_df), , drop = FALSE]; rownames(roc_df) <- NULL
# LAT-3146: the ROC frame carries `class` on both paths, so its columns do not depend on the number of
# classes. With two classes it names the positive class and the chart still draws one line (no series).
roc_df$class <- as.character(positive)
pred_pos <- p_test >= threshold
tp <- sum(pred_pos & y_test == 1); fp <- sum(pred_pos & y_test == 0)
fn <- sum(!pred_pos & y_test == 1); tn <- sum(!pred_pos & y_test == 0)
sens <- tp / max(1, tp + fn); spec <- tn / max(1, tn + fp); prec <- tp / max(1, tp + fp)
acc <- (tp + tn) / n_test; f1 <- if (prec + sens > 0) 2 * prec * sens / (prec + sens) else 0
brier <- mean((p_test - y_test)^2)
base_rate <- mean(y_test)
# LAT-3146: one row per actual and predicted class, the same three columns on both paths; with two
# classes that is caught, missed, wrongly flagged and correctly cleared, in that order.
lab <- c(paste0(human("outcome"), " = ", positive), paste0(human("outcome"), " = ", negative))
confusion_df <- data.frame(
actual = rep(lab, each = 2), predicted = rep(lab, times = 2),
n = c(tp, fn, fp, tn), stringsAsFactors = FALSE)
perf_df <- data.frame(
metric = c("AUC (held-out)", "AUC 95% low", "AUC 95% high", "threshold", "sensitivity (recall)", "specificity", "precision", "accuracy", "F1", "Brier score", "base rate", "training rows", "test rows"),
value = round(c(auc, auc_low, auc_high, threshold, sens, spec, prec, acc, f1, brier, base_rate, n_train, n_test), 4),
stringsAsFactors = FALSE)
bins <- cut(rank(p_test, ties.method = "first"), breaks = 10, labels = FALSE)
cal_df <- do.call(rbind, lapply(sort(unique(bins)), function(b) data.frame(
predicted_rate = round(mean(p_test[bins == b]), 3), observed_rate = round(mean(y_test[bins == b]), 3), n = sum(bins == b))))
rownames(cal_df) <- NULL
#' ## Multi-class: the same figures, per class and averaged
#' Every figure above belongs to the named class against the rest. With more than
#' two classes the report leads with the MACRO average AUC (the mean of the
#' per-class AUCs, each class weighted the same however rare it is), the ROC carries
#' one curve per class, the confusion table is K by K at the arg-max class, and
#' accuracy is the share of held-out rows whose highest-scoring class was the right
#' one. The interval on the macro AUC treats the per-class intervals as independent,
#' which the method text says.
auc_by_class <- NULL; macro_auc <- NA_real_; argmax_acc <- NA_real_
if (multiclass) {
cls <- names(ovr)
per <- lapply(cls, function(k) {
a <- auc_of(ovr[[k]]$p_test, ovr[[k]]$y_test)
npk <- sum(ovr[[k]]$y_test == 1); nnk <- sum(ovr[[k]]$y_test == 0)
if (is.na(a) || npk == 0 || nnk == 0) return(data.frame(class = k, auc = NA_real_, se = NA_real_, n = npk, stringsAsFactors = FALSE))
qq1 <- a / (2 - a); qq2 <- 2 * a^2 / (1 + a)
se <- sqrt((a * (1 - a) + (npk - 1) * (qq1 - a^2) + (nnk - 1) * (qq2 - a^2)) / (npk * nnk))
data.frame(class = k, auc = a, se = se, n = npk, stringsAsFactors = FALSE)
})
auc_by_class <- do.call(rbind, per)
ok <- !is.na(auc_by_class$auc)
macro_auc <- mean(auc_by_class$auc[ok])
macro_se <- sqrt(sum(auc_by_class$se[ok]^2, na.rm = TRUE)) / sum(ok)
auc <- macro_auc
auc_low <- max(0, macro_auc - 1.96 * macro_se); auc_high <- min(1, macro_auc + 1.96 * macro_se)
# arg-max assignment across the per-class scores
P <- do.call(cbind, lapply(cls, function(k) ovr[[k]]$p_test))
colnames(P) <- cls
pred_cls <- cls[max.col(P, ties.method = "first")]
argmax_acc <- mean(pred_cls == cls_test)
acc <- argmax_acc
# K by K confusion as one row per actual and predicted class (LAT-3146): the columns are the same as
# the two-class table's, instead of one column named after each class.
confusion_df <- do.call(rbind, lapply(cls, function(a_k) do.call(rbind, lapply(cls, function(p_k)
data.frame(actual = paste0(human("outcome"), " = ", a_k), predicted = paste0(human("outcome"), " = ", p_k),
n = sum(cls_test == a_k & pred_cls == p_k), stringsAsFactors = FALSE)))))
rownames(confusion_df) <- NULL
# one ROC curve per class, the class as the series
roc_df <- do.call(rbind, lapply(cls, function(k) {
pk <- ovr[[k]]$p_test; yk <- ovr[[k]]$y_test
g <- sort(unique(c(0, pk, 1)))
if (length(g) > 120) g <- unique(quantile(g, probs = seq(0, 1, length.out = 120), names = FALSE))
d <- do.call(rbind, lapply(rev(g), function(t) { rr <- roc_at(pk, yk, t); data.frame(fpr = round(rr[["fpr"]], 3), tpr = round(rr[["tpr"]], 3), class = k, stringsAsFactors = FALSE) }))
d[!duplicated(d[, c("fpr", "tpr")]), , drop = FALSE]
}))
rownames(roc_df) <- NULL
perf_df <- rbind(
data.frame(metric = "macro-average AUC (held-out)", value = round(macro_auc, 4), stringsAsFactors = FALSE),
data.frame(metric = "macro AUC 95% low", value = round(auc_low, 4), stringsAsFactors = FALSE),
data.frame(metric = "macro AUC 95% high", value = round(auc_high, 4), stringsAsFactors = FALSE),
data.frame(metric = paste0("AUC, ", auc_by_class$class, " vs rest"), value = round(auc_by_class$auc, 4), stringsAsFactors = FALSE),
data.frame(metric = "accuracy (arg-max class)", value = round(argmax_acc, 4), stringsAsFactors = FALSE),
data.frame(metric = "majority-class accuracy", value = round(max(table(cls_test)) / length(cls_test), 4), stringsAsFactors = FALSE),
data.frame(metric = paste0("sensitivity, ", positive, " vs rest"), value = round(sens, 4), stringsAsFactors = FALSE),
data.frame(metric = paste0("specificity, ", positive, " vs rest"), value = round(spec, 4), stringsAsFactors = FALSE),
data.frame(metric = paste0("threshold, ", positive, " vs rest"), value = round(threshold, 4), stringsAsFactors = FALSE),
data.frame(metric = "classes", value = length(cls), stringsAsFactors = FALSE),
data.frame(metric = "training rows", value = n_train, stringsAsFactors = FALSE),
data.frame(metric = "test rows", value = n_test, stringsAsFactors = FALSE))
rownames(perf_df) <- NULL
}
perf_df$value <- tidy(perf_df$value) # LAT-3181: 0.7449 reads 0.745; row counts stay whole
band <- if (is.na(auc)) "not measurable" else if (auc < 0.6) "little better than chance" else if (auc < 0.7) "weak" else if (auc < 0.8) "fair" else if (auc < 0.9) "good" else "excellent"
#' ## Method text and the answer
method <- paste0(
if (multiclass) paste0("One-vs-rest logistic regression of ", human("outcome"), " across ", length(lv), " classes (",
paste(lv, collapse = ", "), "), one model per class, reported for ", positive, " against the rest, on ")
else paste0("Logistic regression of ", human("outcome"), " (positive class: ", positive, ") on "),
paste(vapply(model_drivers, human, character(1)), collapse = ", "), "; ", n_used, " rows with an outcome of ", n_in,
if (length(dropped)) paste0("; drivers excluded: ", paste(paste0(vapply(dropped, human, character(1)), " (", why, ")"), collapse = ", ")) else "",
". ", if (holdout) paste0("Stratified 70/30 split (seed 42): fitted on ", n_train, " rows, every performance figure measured on the ", n_test, " held-out rows",
if (n_unseen > 0) paste0(" (", n_unseen, " test rows set aside for a category unseen in training)") else "", ".")
else paste0("Too few rows for a held-out split; fitted on all ", n_train, " rows and the performance figures are IN-SAMPLE, so they flatter the model."),
if (multiclass) paste0(" AUC by the rank formula per class with a Hanley-McNeil 95% interval each; the headline is the MACRO average (every class weighted the same), its interval formed by treating the per-class intervals as independent; accuracy is the share of held-out rows assigned to the right class by the highest score, against a majority-class baseline in the table; the confusion table and the ", positive, " threshold ", round(threshold, 3), " (Youden's J on the training part) describe the named class; ")
else paste0(" AUC by the rank formula with a Hanley-McNeil 95% interval; threshold ", round(threshold, 3), " chosen on the training part by Youden's J and applied unchanged; "),
"odds ratios with 95% Wald intervals; driver importance = max |z| per driver scaled to 100; calibration in ten equal-count bins.")
statement <- if (multiclass)
sprintf("Across %d classes the classifier reaches a macro-average AUC of %s on held-out data (%s), assigning %s%% of rows to the right class; %s %s most for %s",
length(lv), format(round(auc, 3), nsmall = 3), band, format(round(100 * argmax_acc, 1), nsmall = 1), top_driver, top_dir, positive)
else
sprintf("The classifier separates %s = %s from %s with AUC %s on held-out data (%s); %s %s most",
human("outcome"), positive, negative, format(round(auc, 3), nsmall = 3), band, top_driver, top_dir)
answer <- list(
auc = round(auc, 4), auc_low = round(auc_low, 4), auc_high = round(auc_high, 4), auc_band = band,
threshold = round(threshold, 4), sensitivity = round(sens, 4), specificity = round(spec, 4), precision = round(prec, 4),
accuracy = round(acc, 4), f1 = round(f1, 4), brier = round(brier, 4), base_rate = round(base_rate, 4),
positive_class = positive, top_driver = top_driver, top_driver_direction = top_dir,
n_classes = length(lv), multiclass = multiclass,
n_drivers = length(model_drivers), n_train = n_train, n_test = n_test, held_out = holdout, n = n_used)
# ── Results: one entry per place. `columns` carries column order past jsonb's key
# sort; `value_order` says which number leads a headline or verdict. ──
#' ## Summary metrics and assumption checks (LAT-3138): the figures that summarise the
#' whole analysis as one metrics row, and one verdict per assumption of the method,
#' each with the statistic behind it. holds / strained / violated are rules of thumb a
#' reader can re-derive from the statistic shown beside them. Base R throughout.
verdict_p <- function(p, soft = 0.05, hard = 0.001) if (is.na(p)) "unknown" else if (p >= soft) "holds" else if (p >= hard) "strained" else "violated"
fmt_p <- function(p) if (is.na(p)) "" else if (p < 1e-4) "<0.0001" else as.character(signif(p, 3))
summary_vals <- list(auc = round(auc, 3), accuracy = round(acc, 3), sensitivity = round(sens, 3), specificity = round(spec, 3),
precision = round(prec, 3), f1 = round(f1, 3), brier = round(brier, 3), n_test = n_test, n_train = n_train, n = n_used)
n_terms_c <- tryCatch(max(1, length(coef(model)) - 1), error = function(e) length(model_drivers))
epv <- (min(n_pos, n_neg) * (n_train / max(1, n_used))) / n_terms_c
brier_ref <- base_rate * (1 - base_rate)
Xc <- tryCatch(model.matrix(model), error = function(e) NULL)
# VIF PER DRIVER (not per column): a factor's dummy columns are collinear with each
# other by construction, so testing a dummy against its siblings reads "violated" for
# every categorical driver. Each column is regressed on the OTHER drivers' columns only.
max_vif <- if (!is.null(Xc) && ncol(Xc) > 2 && length(model_drivers) > 1) {
Xd <- Xc[, -1, drop = FALSE]; cn <- colnames(Xd)
owner <- vapply(cn, function(c) { m <- model_drivers[startsWith(c, model_drivers)]; if (length(m)) m[which.max(nchar(m))] else c }, character(1))
max(vapply(seq_len(ncol(Xd)), function(j) {
others <- which(owner != owner[j]); if (!length(others)) return(1)
r2j <- summary(lm(Xd[, j] ~ Xd[, others, drop = FALSE]))$r.squared
if (is.na(r2j) || r2j >= 1) Inf else 1 / (1 - r2j) }, numeric(1)))
} else NA_real_
checks_df <- data.frame(
check = c("Measured on held-out rows", "Separation beyond chance", "Calibrated probabilities", "Enough events per term", "Class balance", "No strong collinearity"),
statistic = c(if (holdout) paste0(n_test, " held-out rows") else "in-sample only",
paste0("AUC lower bound = ", round(auc_low, 3)),
if (multiclass) "not assessed for three or more classes" else paste0("Brier ", round(brier, 3), " against ", round(brier_ref, 3), " for guessing the base rate"),
paste0(round(epv, 1), " events per term"),
paste0("positive rate = ", round(base_rate, 3)),
if (is.na(max_vif)) "not assessed" else paste0("max VIF = ", round(max_vif, 2))),
p_value = c("", "", "", "", "", ""),
verdict = c(if (holdout) "holds" else "violated",
if (auc_low > 0.5) "holds" else if (auc > 0.5) "strained" else "violated",
if (multiclass) "unknown" else if (brier < 0.9 * brier_ref) "holds" else if (brier < brier_ref) "strained" else "violated",
if (epv >= 10) "holds" else if (epv >= 5) "strained" else "violated",
if (base_rate >= 0.1 && base_rate <= 0.9) "holds" else if (base_rate >= 0.05 && base_rate <= 0.95) "strained" else "violated",
if (is.na(max_vif)) "unknown" else if (max_vif > 10) "violated" else if (max_vif > 5) "strained" else "holds"),
note = c("in-sample performance flatters the model",
"a lower bound at or below one half means the ranking could be chance",
"a Brier score no better than the base rate means the probabilities carry no information",
"fewer than ten events per term makes the odds ratios unstable",
"a rare class makes accuracy misleading and the threshold fragile",
"a VIF above 5 inflates that driver's standard error"),
stringsAsFactors = FALSE)
results <- list()
#' THE ROW-BEARING PLACES USE THE CONSTRUCTORS (LAT-3093/3095): the frame plus the
#' ROLE of each column. `columns` is derived from names(df) inside the constructor,
#' so it cannot drift from the data, and a role naming a column the frame lacks
#' stops HERE rather than at a customer. The three value-bearing places below stay
#' long-hand on purpose: their explicit `value_order` is already correct and a
#' constructor would reorder the metric tiles for no gain.
#' The verdict and the headline are NOT places of a library tool (LAT-3130): a
#' library tool supplies data objects; the answer is written by the last mile,
#' which is the first stage that reads the objects together.
#' ## summary_metrics: the whole analysis at a glance (LAT-3138)
results$summary_metrics <- place_metric(summary_vals, lead = "auc", place = "summary_metrics")
#' ## assumption_checks: a verdict per assumption (a `checks` place, table-shaped)
results$assumption_checks <- place_table(checks_df, place = "assumption_checks")
#' ## roc_curve: false positive rate against true positive rate on the held-out part
#' A multi-class ROC carries K curves, so the class column must be DECLARED or the
#' object mapper sees two numeric columns, finds no series, and draws every class as
#' one tangled line (LAT-3077).
results$roc_curve <- place_relationship(roc_df, x = "fpr", y = "tpr",
series = if (multiclass) "class" else NULL, place = "roc_curve")
#' ## confusion_table: counts at the chosen threshold
results$confusion_table <- place_table(confusion_df, place = "confusion_table")
#' ## performance_table: every figure with the split it was measured on
results$performance_table <- place_table(perf_df, place = "performance_table")
#' ## calibration: predicted against observed per decile
results$calibration <- place_relationship(cal_df, x = "predicted_rate", y = "observed_rate",
place = "calibration")
#' ## odds_ratio_interval: each term's odds ratio with its interval (no intercept)
sel <- match(slope_df$term, coef_df$term)
or_int <- data.frame(term = slope_df$term, per_sd = slope_df$per_sd,
low = round((est[sel] - 1.96 * se[sel]) * unname(term_sd[sel]), 3),
high = round((est[sel] + 1.96 * se[sel]) * unname(term_sd[sel]), 3), stringsAsFactors = FALSE)
or_int <- or_int[is.finite(or_int$per_sd), , drop = FALSE]; rownames(or_int) <- NULL
results$odds_ratio_interval <- place_interval(or_int, term = "term", value = "per_sd", low = "low", high = "high",
place = "odds_ratio_interval")
#' ## driver_importance: ranked drivers
results$driver_importance <- place_comparison(imp_df, category = "driver", value = "importance",
series = "direction", place = "driver_importance")
#' ## coefficient_table: every term
results$coefficient_table <- place_table(coef_df, place = "coefficient_table")
#' ## fit_method: how it was done
results$fit_method <- list(kind = "metric", values = list(
method = method, n_in = n_in, n_used = n_used,
excluded = if (length(dropped)) as.list(unname(vapply(dropped, human, character(1)))) else list(),
# LAT-3181: the method card's "rows · x → y" line reads these; unset, it printed a bare arrow
x_column = paste(vapply(model_drivers, human, character(1)), collapse = ", "), y_column = human("outcome"),
assumptions = list(
"Log-odds are linear in the numeric drivers; a curved effect is understated.",
"Rows are independent; repeated rows per customer or time inflate the AUC.",
"The held-out figures are one split; a different seed moves AUC by roughly its interval width.",
if (multiclass) "One model per class against the rest: the per-class scores are not calibrated against each other, so the arg-max assignment is a ranking, not a probability over classes." else NULL,
"The threshold trades sensitivity for specificity by Youden's J, not by the cost of each error; set it to the business cost before acting.",
if (!holdout) "Performance is in-sample and flatters the model." else "Performance is measured on rows the model never saw.")))
objects <- list() # filled by the object layer, not here
list(answer = answer, method = method, n = n_used, results = results, objects = objects,
json_output = list(answer = answer, method = method, n = n_used))
}