Executive Summary
How well Glucose separates '1' from '0'
Glucose alone identifies diabetes with fair discrimination: an AUC of 0.788 (95% CI 0.752 to 0.824) across 768 observations. At the Youden-optimal cutoff of 124, the test catches 70.1% of true cases while correctly clearing 73.2% of non-cases, for 72.1% overall accuracy. Of rows flagged positive at this cutoff, 58.4% truly have the outcome; of rows cleared, 82.1% truly do not. Glucose ranks cases reasonably well but is not a standalone diagnostic.
Analysis Overview
ROC evaluation of Glucose against Outcome across 768 observations.
Glucose separates the two outcome classes with fair ranking quality. The ROC curve measures how well glucose scores order the cases: a random person with outcome '1' has an 0.788 probability of scoring higher on glucose than a random person with outcome '0'. This AUC of 0.788 is threshold-independent — it describes the overall ranking ability, not any single decision rule. The curve bows noticeably above the chance diagonal (0.5), indicating that glucose carries genuine discriminative signal, though not perfect separation. This is neither random nor excellent; it sits in the fair band.
Data Quality
Positive-class definition, dropped rows, and class balance.
All 768 rows retained complete data: no records were dropped for missing outcome or glucose values. The dataset contains 268 rows (34.9%) with outcome '1' (positive class) and 500 rows (65.1%) with outcome '0' (negative class). Glucose was treated as a continuous score where higher values indicate higher likelihood of the positive class. This class imbalance—roughly one positive for every two negatives—is typical for screening problems and does not bias the AUC calculation, though it shapes the PPV and NPV at any chosen cutoff.
ROC Curve
True positive rate vs false positive rate across every cutoff on Glucose.
The curve bows clearly above the diagonal, reaching a true positive rate of 0.701 at a false positive rate of 0.268 (the Youden-optimal operating point at glucose = 124). This steady upward bow—rather than a tight cluster or loose scatter—shows that glucose consistently ranks positive cases above negative ones across the full range of cutoffs. The curve does not approach the top-left corner (perfect sensitivity and specificity), confirming that glucose alone leaves room for misclassification. The area beneath the curve, 0.788, quantifies how much the ranking exceeds chance.
Cutoff Performance
AUC, confidence interval, and every operating-point metric at the Youden cutoff.
| Metric | Value | Interpretation |
|---|---|---|
| AUC | 0.788 | Probability a random '1' row scores above a random '0' row — fair discrimination. |
| AUC 95% CI | 0.752 to 0.824 | Hanley-McNeil 95% confidence interval for the AUC. |
| Youden cutoff on Glucose | 124.0000 | Flag a row positive when Glucose is at or above this value (maximizes sensitivity + specificity). |
| Sensitivity | 70.1% | Share of actual '1' rows caught at this cutoff. |
| Specificity | 73.2% | Share of actual '0' rows correctly cleared. |
| PPV (precision) | 58.4% | Of rows flagged positive, the share that truly are '1'. |
| NPV | 82.1% | Of rows cleared, the share that truly are '0'. |
| Accuracy | 72.1% | Overall share of rows classified correctly at this cutoff. |
The short answer
At the recommended cutoff of 124, glucose catches 70.1% of diabetics and correctly clears 73.2% of non-diabetics, for 72.1% overall accuracy. The AUC of 0.788 (95% CI 0.752 to 0.824) confirms fair discrimination, but the 58.4% positive predictive value shows that more than two in five positive flags are false alarms.
The detail
The AUC of 0.788 (95% CI 0.752 to 0.824) indicates that a randomly selected diabetic case scores above a randomly selected non-diabetic case 78.8% of the time. At the Youden-optimal cutoff of 124.0000, sensitivity is 70.1% and specificity is 73.2%, yielding 72.1% accuracy. The confusion matrix at this cutoff contains 188 true positives, 134 false positives, 366 true negatives, and 80 false negatives. The PPV of 58.4% and NPV of 82.1% reflect both the test's operating point and the underlying prevalence: the high NPV indicates that glucose clears non-diabetics reliably, while the lower PPV signals substantial false-positive risk.
What this can't tell you
These metrics apply to the present sample; generalization to other populations or settings depends on whether their glucose distributions and diabetes prevalence match. Consider whether the 58.4% PPV is acceptable for your clinical or operational workflow, particularly given the 134 false positives at this cutoff.
Threshold Sweep
Sensitivity, specificity, and Youden's J across candidate cutoffs.
| Threshold | Sensitivity | Specificity | Youden J |
|---|---|---|---|
| 134 | 0.567 | 0.832 | 0.399 |
| 133 | 0.575 | 0.826 | 0.401 |
| 132 | 0.582 | 0.82 | 0.402 |
| 131 | 0.593 | 0.816 | 0.409 |
| 130 | 0.604 | 0.808 | 0.412 |
| 128 | 0.649 | 0.782 | 0.431 |
| 127 | 0.649 | 0.772 | 0.421 |
| 126 | 0.657 | 0.758 | 0.415 |
| 125 | 0.683 | 0.744 | 0.427 |
| 124 | 0.701 | 0.732 | 0.433 |
| 123 | 0.709 | 0.718 | 0.427 |
| 122 | 0.72 | 0.7 | 0.42 |
| 121 | 0.728 | 0.692 | 0.42 |
| 120 | 0.739 | 0.676 | 0.415 |
| 118 | 0.761 | 0.654 | 0.415 |
| 117 | 0.769 | 0.636 | 0.405 |
| 116 | 0.772 | 0.624 | 0.396 |
| 115 | 0.795 | 0.616 | 0.411 |
| 114 | 0.802 | 0.598 | 0.4 |
The short answer
The Youden-optimal cutoff of 124 achieves the best balance, with a J statistic of 0.433. Lowering the threshold increases sensitivity (catching more diabetics) at the cost of specificity; raising it does the reverse. At 124, you gain sensitivity of 0.701 and specificity of 0.732.
The detail
Across 19 candidate cutoffs, Youden's J (sensitivity + specificity − 1) peaks at 0.433 at the threshold of 124.0000, the recommended operating point. Moving lower — to 123, the threshold yields sensitivity of 0.709 and specificity of 0.718 (J = 0.427); at 125, sensitivity drops to 0.683 while specificity rises to 0.744 (J = 0.427). The trade-off is consistent: at the lower end of the sweep (114), sensitivity reaches 0.802 but specificity falls to 0.598 (J = 0.4); at the upper end (134), specificity climbs to 0.832 while sensitivity declines to 0.567 (J = 0.399).
What this can't tell you
The Youden criterion assumes equal cost for false positives and false negatives; if your clinical or operational setting weights these errors differently, a different cutoff may be preferable. The sweep covers only 19 discrete points — a finer-grained threshold grid would reveal whether the peak J is stable or sensitive to small changes in cutoff.
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
)
}