Executive Summary
How well Model Score separates 'Yes' from 'No'
Across 600 observations, Model Score achieves an AUC of 0.874 (95% CI 0.843 to 0.905)—good discrimination. At the Youden-optimal cutoff of 0.6001, the model catches 85.0% of true 'Yes' cases (sensitivity) while correctly clearing 73.2% of 'No' cases (specificity), delivering 77.8% overall accuracy. Of rows flagged positive, 67.0% truly are 'Yes' (PPV); of rows cleared, 88.4% truly are 'No' (NPV). This represents a well-balanced operating point for practical deployment.
Analysis Overview
ROC evaluation of Model Score against Disease across 600 observations.
Your model achieves an AUC of 0.874, indicating good discrimination between the two classes. AUC represents the probability that a randomly selected 'Yes' case scores higher than a randomly selected 'No' case—a threshold-independent measure of ranking quality. Since 0.5 represents pure chance and 1.0 represents perfect separation, your score of 0.874 sits solidly in the good range. This metric isolates how well Model Score orders the two classes, independent of any specific decision rule or cutoff choice.
Data Quality
Positive-class definition, dropped rows, and class balance.
All 600 rows loaded with complete outcome and score data; no rows were removed. The dataset contains 234 positive rows (39.0%) and 366 negative rows (61.0%), with 'Yes' treated as the positive class. Model Score was read as a continuous value where higher scores indicate greater likelihood of the positive class. The class balance is reasonably healthy, with the positive class representing about two-fifths of the sample, reducing the risk of severe imbalance artifacts in performance estimation.
ROC Curve
True positive rate vs false positive rate across every cutoff on Model Score.
The ROC curve bows substantially above the chance diagonal, reflecting the model's strong ranking ability. The Youden-optimal operating point sits at a false positive rate of 0.268 and true positive rate of 0.850 (cutoff 0.6001). The curve's pronounced upward bow toward the top-left corner, combined with the AUC of 0.874, demonstrates that Model Score separates the classes far better than random guessing. The curve's trajectory shows early gains in sensitivity at low false positive rates, indicating the score has useful discriminatory power across the operating range.
Cutoff Performance
AUC, confidence interval, and every operating-point metric at the Youden cutoff.
| Metric | Value | Interpretation |
|---|---|---|
| AUC | 0.874 | Probability a random 'Yes' row scores above a random 'No' row — good discrimination. |
| AUC 95% CI | 0.843 to 0.905 | Hanley-McNeil 95% confidence interval for the AUC. |
| Youden cutoff on Model Score | 0.6001 | Flag a row positive when Model Score is at or above this value (maximizes sensitivity + specificity). |
| Sensitivity | 85.0% | Share of actual 'Yes' rows caught at this cutoff. |
| Specificity | 73.2% | Share of actual 'No' rows correctly cleared. |
| PPV (precision) | 67.0% | Of rows flagged positive, the share that truly are 'Yes'. |
| NPV | 88.4% | Of rows cleared, the share that truly are 'No'. |
| Accuracy | 77.8% | Overall share of rows classified correctly at this cutoff. |
At the Youden cutoff of 0.6001, the model delivers 85.0% sensitivity and 73.2% specificity, with 77.8% overall accuracy across 600 rows. The confusion matrix shows 199 true positives, 98 false positives, 268 true negatives, and 35 false negatives. PPV is 67.0% and NPV is 88.4%, indicating strong negative predictive value—rows cleared as 'No' are correct 88.4% of the time. The AUC 95% confidence interval (0.843 to 0.905) is reasonably tight, suggesting stable discrimination across resampling.
Threshold Sweep
Sensitivity, specificity, and Youden's J across candidate cutoffs.
| Threshold | Sensitivity | Specificity | Youden J |
|---|---|---|---|
| 0.6745 | 0.825 | 0.746 | 0.571 |
| 0.6688 | 0.825 | 0.743 | 0.568 |
| 0.667 | 0.825 | 0.74 | 0.565 |
| 0.6601 | 0.829 | 0.74 | 0.569 |
| 0.6496 | 0.829 | 0.738 | 0.567 |
| 0.6396 | 0.838 | 0.735 | 0.573 |
| 0.6318 | 0.842 | 0.735 | 0.577 |
| 0.6149 | 0.846 | 0.735 | 0.581 |
| 0.6088 | 0.846 | 0.732 | 0.578 |
| 0.6001 | 0.85 | 0.732 | 0.583 |
| 0.582 | 0.85 | 0.73 | 0.58 |
| 0.5784 | 0.85 | 0.727 | 0.577 |
| 0.558 | 0.855 | 0.727 | 0.581 |
| 0.5537 | 0.855 | 0.724 | 0.579 |
| 0.532 | 0.855 | 0.719 | 0.573 |
| 0.5313 | 0.855 | 0.716 | 0.571 |
| 0.5308 | 0.855 | 0.713 | 0.568 |
| 0.5281 | 0.855 | 0.71 | 0.565 |
| 0.5121 | 0.855 | 0.708 | 0.562 |
The threshold sweep across 19 candidate cutoffs shows a clear trade-off between sensitivity and specificity. Youden's J peaks at 0.583 at the cutoff of 0.6001 (sensitivity 0.85, specificity 0.732). Lower thresholds (e.g., 0.5121) raise sensitivity to 0.855 but reduce specificity to 0.708; higher thresholds (e.g., 0.6745) hold sensitivity at 0.825 while lifting specificity to 0.746. The flat plateau around the optimal cutoff suggests modest flexibility—small threshold adjustments incur minimal performance loss, easing practical implementation.
Methodology
Statistical methodology and diagnostics for ROC Curve & Classifier Cutoff
Statistical Method
Standard-library analysis: evaluate a binary classifier or diagnostic score against the true yes/no outcome. Draws the full ROC curve, computes the AUC with a 95% confidence interval, and finds the best operating cutoff by Youden's J — with sensitivity, specificity, PPV, NPV, accuracy, and the confusion counts at that cutoff. Works on any dataset: map the true outcome and a numeric score where higher means more likely positive.
- Higher score means more likely to be in the positive class
- The true outcome is genuinely binary (two classes)
- AUC measures ranking, not calibration — a well-ranked score can still be poorly calibrated
- The best cutoff depends on the relative cost of false positives versus false negatives; Youden's J weights them equally
- With few positives or negatives, the AUC confidence interval is wide
Analysis Code
Complete R source code for this analysis
ROC Curve & Classifier Cutoff — How Good Is Your Score
Evaluates a binary classifier or diagnostic score against the true yes/no outcome. Treats one class as positive, sweeps every candidate cutoff on the score to trace the ROC curve, computes the AUC (with a Hanley-McNeil 95% confidence interval, cross-checked against the curve's own trapezoidal area), and recommends the operating cutoff that maximizes Youden's J — reporting sensitivity, specificity, PPV, NPV, accuracy, and the confusion counts at that cutoff.
Why This Method?
AUC is the single most honest summary of a score's ranking quality: the probability that a random positive scores above a random negative, independent of any threshold. The ROC curve shows the full sensitivity/specificity trade-off, and Youden's J turns it into a concrete decision cutoff.
What This Analysis Covers
- The ROC curve (true positive rate vs false positive rate)
- AUC with a 95% confidence interval and a quality band
- The Youden-optimal cutoff with sensitivity, specificity, PPV, NPV, accuracy
- A threshold sweep around the operating range
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {actual, score}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Step 1: Row accounting + semantic column discovery
initial_rows <- nrow(df)
if (!"actual" %in% names(df)) {
stop("column_mapping must map an 'actual' column (the true yes/no outcome).")
}
if (!"score" %in% names(df)) {
stop("column_mapping must map a 'score' column (the numeric model score).")
}
actual_name <- humanize_semantic("actual", col_map)
score_name <- humanize_semantic("score", col_map)Step 2: Choose the positive class + binarize the outcome
v_raw <- df$actual
if (is.logical(v_raw)) {
keep_a <- !is.na(v_raw)
df <- df[keep_a, , drop = FALSE]
y <- as.integer(df$actual)
positive_label <- "TRUE"
negative_label <- "FALSE"
positive_rule <- "the boolean TRUE value"
} else {
vc <- trimws(as.character(v_raw))
keep_a <- !is.na(v_raw) & !is.na(vc) & vc != ""
df <- df[keep_a, , drop = FALSE]
vc <- vc[keep_a]
lv <- sort(unique(vc))
if (length(lv) < 2) {
stop(sprintf(
"The outcome column('%s') has only one value ('%s') — an ROC analysis needs both the positive and the negative class present.",
actual_name, if (length(lv) == 1) lv[1] else "empty"))
}Positive class = the level matching a conventional positive label (case-insensitive); otherwise the alphabetically-last level.
pat <- "^(1|yes|true|positive|pos|disease|churn|churned|fraud|default|defaulted|converted)$"
hits <- lv[grepl(pat, tolower(lv))]
if (length(hits) >= 1) {
positive_label <- hits[length(hits)]
positive_rule <- "it matched a conventional positive label"
} else {
positive_label <- lv[length(lv)]
positive_rule <- "no conventional positive label was found, so the alphabetically-last class was used"
}
neg_levels <- setdiff(lv, positive_label)
negative_label <- if (length(neg_levels) == 1) neg_levels[1]
else paste0("not ", positive_label)
if (length(lv) > 2) {
positive_rule <- paste0(positive_rule,
"; the outcome had more than two values, so this is a one-vs-rest evaluation")
}
y <- as.integer(vc == positive_label)
}Step 3: Coerce the score to numeric; drop NA (actual, score) pairs
sc <- df$score
if (!is.numeric(sc)) sc <- suppressWarnings(as.numeric(as.character(sc)))
keep_s <- !is.na(sc) & !is.na(y)
sc <- sc[keep_s]
y <- y[keep_s]
final_rows <- length(y)
rows_removed <- initial_rows - final_rows
if (final_rows < 20) {
stop(sprintf(
"Only %d rows have both a usable outcome('%s') and a numeric score ('%s') — at least 20 are required.",
final_rows, actual_name, score_name))
}Step 4: Guard — both classes present with at least 10 each
n1 <- sum(y == 1L)
n0 <- sum(y == 0L)
if (n1 < 10 || n0 < 10) {
stop(sprintf(
"An ROC analysis needs at least 10 of each class, but '%s' has %d '%s' and %d '%s' rows.",
actual_name, n1, positive_label, n0, negative_label))
}
if (isTRUE(var(sc) == 0) || is.na(var(sc))) {
stop(sprintf(
"The score column('%s') is constant — a single value cannot separate the two classes.",
score_name))
}Step 5: Sweep thresholds (O(n log n)) — predict positive if score >= t
o <- order(sc, decreasing = TRUE)
s_o <- sc[o]
y_o <- y[o]
cum_tp <- cumsum(y_o == 1L)
cum_fp <- cumsum(y_o == 0L)Keep the LAST index of each run of equal scores (descending sort): that index's cumulative counts are the counts for score >= that value.
run_end <- which(c(diff(s_o) != 0, TRUE))
thr <- s_o[run_end]
tp_c <- cum_tp[run_end]
fp_c <- cum_fp[run_end]
tpr <- tp_c / n1
fpr <- fp_c / n0
sens_v <- tpr
spec_v <- 1 - fpr
j_v <- sens_v + spec_v - 1Step 6: AUC — rank formula (reported) + trapezoidal cross-check
Mann-Whitney rank AUC (ties handled by average ranks).
r <- rank(sc)
auc <- (sum(r[y == 1L]) - as.numeric(n1) * (n1 + 1) / 2) /
(as.numeric(n1) * as.numeric(n0))Trapezoidal area under the ROC points (prepend the (0,0) origin; the sweep already ends at (1,1) via the lowest threshold).
roc_fpr <- c(0, fpr)
roc_tpr <- c(0, tpr)
auc_trap <- sum(diff(roc_fpr) * (head(roc_tpr, -1) + tail(roc_tpr, -1)) / 2)Hanley-McNeil standard error + 95% CI (clamped to [0, 1]).
q1 <- auc / (2 - auc)
q2 <- 2 * auc^2 / (1 + auc)
se <- sqrt((auc * (1 - auc) +
(n1 - 1) * (q1 - auc^2) +
(n0 - 1) * (q2 - auc^2)) /
(as.numeric(n1) * as.numeric(n0)))
ci_low <- max(0, auc - 1.96 * se)
ci_high <- min(1, auc + 1.96 * se)
band <- auc_band(auc)Step 7: Operating cutoffs — Youden's J and closest-to-top-left
NA-filter before which.max/which.min (LAT-1445 crash class).
j_ok <- j_v
j_ok[is.na(j_ok)] <- -Inf
j_idx <- which.max(j_ok)
youden_cutoff <- thr[j_idx]
max_j <- j_v[j_idx]
dist_tl <- sqrt(fpr^2 + (1 - tpr)^2)
dist_tl[is.na(dist_tl)] <- Inf
tl_idx <- which.min(dist_tl)
topleft_cutoff <- thr[tl_idx]Step 8: Confusion + operating metrics at the Youden cutoff
pred <- as.integer(sc >= youden_cutoff)
tp <- sum(pred == 1L & y == 1L)
fp <- sum(pred == 1L & y == 0L)
tn <- sum(pred == 0L & y == 0L)
fn <- sum(pred == 0L & y == 1L)
sensitivity <- if ((tp + fn) > 0) tp / (tp + fn) else NA_real_
specificity <- if ((tn + fp) > 0) tn / (tn + fp) else NA_real_
ppv <- if ((tp + fp) > 0) tp / (tp + fp) else NA_real_
npv <- if ((tn + fn) > 0) tn / (tn + fn) else NA_real_
accuracy <- (tp + tn) / final_rowsStep 9: ROC points dataset (sorted; sampled to <= 1000 for the chart)
roc_points_df <- data.frame(
false_positive_rate = round(roc_fpr, 4),
true_positive_rate = round(roc_tpr, 4),
stringsAsFactors = FALSE
)
roc_points_df <- roc_points_df[order(roc_points_df$false_positive_rate,
roc_points_df$true_positive_rate), ]
rownames(roc_points_df) <- NULL
if (nrow(roc_points_df) > 1000) {
set.seed(42)
idx <- sort(unique(c(1L,
as.integer(round(seq(1, nrow(roc_points_df),
length.out = 1000))),
nrow(roc_points_df))))
roc_points_df <- roc_points_df[idx, ]
rownames(roc_points_df) <- NULL
}Step 10: Cutoff-metrics table (all values fixed-notation strings)
cutoff_metrics_df <- data.frame(
metric = c("AUC", "AUC 95% CI", paste0("Youden cutoff on ", score_name),
"Sensitivity", "Specificity", "PPV(precision)",
"NPV", "Accuracy"),
value = c(
fnum(auc, 3),
paste0(fnum(ci_low, 3), " to ", fnum(ci_high, 3)),
fnum(youden_cutoff, 4),
fpct(sensitivity), fpct(specificity), fpct(ppv), fpct(npv), fpct(accuracy)
),
interpretation = c(
paste0("Probability a random '", positive_label,
"' row scores above a random '", negative_label, "' row — ",
band, " discrimination."),
"Hanley-McNeil 95% confidence interval for the AUC.",
paste0("Flag a row positive when ", score_name,
" is at or above this value(maximizes sensitivity + specificity)."),
paste0("Share of actual '", positive_label, "' rows caught at this cutoff."),
paste0("Share of actual '", negative_label, "' rows correctly cleared."),
paste0("Of rows flagged positive, the share that truly are '",
positive_label, "'."),
paste0("Of rows cleared, the share that truly are '", negative_label, "'."),
"Overall share of rows classified correctly at this cutoff."
),
stringsAsFactors = FALSE
)Step 11: Threshold sweep table (<= 20 rows around the operating range)
sweep_full <- data.frame(
threshold = round(thr, 4),
sensitivity = round(sens_v, 3),
specificity = round(spec_v, 3),
youden_j = round(j_v, 3),
stringsAsFactors = FALSE
)
n_sw <- nrow(sweep_full)
if (n_sw <= 20) {
sel <- seq_len(n_sw)
} else {
lo <- max(1L, j_idx - 10L)
hi <- min(n_sw, j_idx + 10L)
win <- lo:hi
if (length(win) <= 20) {
sel <- win
} else {19 evenly-spaced cutoffs across the window + the Youden row itself, guaranteeing the operating point is shown and total stays <= 20.
sel <- sort(unique(c(j_idx,
win[as.integer(round(seq(1, length(win),
length.out = 19L)))])))
}
}
threshold_sweep_df <- sweep_full[sel, , drop = FALSE]
rownames(threshold_sweep_df) <- NULLStep 12: KPI metrics + machine channels
metrics <- list(
`Observations` = final_rows,
`AUC` = round(auc, 3),
`AUC Quality` = band,
`Youden Cutoff` = round(youden_cutoff, 4),
`Sensitivity` = round(sensitivity, 3),
`Specificity` = round(specificity, 3)
)
roc_summary <- list(
auc = auc, auc_trap = auc_trap, ci_low = ci_low, ci_high = ci_high,
band = band, youden_cutoff = youden_cutoff, topleft_cutoff = topleft_cutoff,
sensitivity = sensitivity, specificity = specificity, ppv = ppv, npv = npv,
accuracy = accuracy, tp = tp, fp = fp, tn = tn, fn = fn,
n1 = n1, n0 = n0, max_j = max_j,
positive_label = positive_label, negative_label = negative_label
)
json_output <- list(
answer = paste0(
"ROC analysis of ", score_name, " against ", actual_name,
" (positive = '", positive_label, "', ",
fpct(n1 / final_rows), " of ", format(final_rows, big.mark = ","),
" rows): AUC = ", fnum(auc, 3), " (95% CI ", fnum(ci_low, 3), " to ",
fnum(ci_high, 3), ", ", band, "). The Youden-optimal cutoff is ",
fnum(youden_cutoff, 4), " on ", score_name, ", giving sensitivity ",
fpct(sensitivity), " and specificity ", fpct(specificity), "."
),
cards = lapply(
c("tldr", "overview", "preprocessing", "roc_curve",
"cutoff_metrics", "threshold_table"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows, rows_removed = rows_removed,
actual_name = actual_name, score_name = score_name,
positive_label = positive_label, negative_label = negative_label,
positive_rule = positive_rule, n1 = n1, n0 = n0,
auc = auc, auc_trap = auc_trap, ci_low = ci_low, ci_high = ci_high, band = band,
youden_cutoff = youden_cutoff, topleft_cutoff = topleft_cutoff,
sensitivity = sensitivity, specificity = specificity, ppv = ppv, npv = npv,
accuracy = accuracy, tp = tp, fp = fp, tn = tn, fn = fn, max_j = max_j,
roc_points_df = roc_points_df, cutoff_metrics_df = cutoff_metrics_df,
threshold_sweep_df = threshold_sweep_df,
roc_summary = roc_summary, metrics = metrics, json_output = json_output
)
}