Standard Roc Analysis
Executive Summary

Executive Summary

How well Glucose separates '1' from '0'

Observations
768
AUC
0.788
AUC Quality
fair
Youden Cutoff
124
Sensitivity
0.701
Specificity
0.732
Across 768 rows, Glucose ranks '1' cases of Outcome above '0' cases with an AUC of 0.788 (95% CI 0.752 to 0.824) — fair discrimination. The positive class is '1' (it matched a conventional positive label). The Youden-optimal cutoff is 124.0000 on Glucose: flagging rows at or above it catches 70.1% of true '1' cases (sensitivity) while correctly clearing 73.2% of '0' cases (specificity).
What this means

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.

Overview

Analysis Overview

ROC evaluation of Glucose against Outcome across 768 observations.

N Observations768
Auc0.788
Positive Rate0.349
Youden Cutoff124
What this means

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 Preparation

Data Quality

Positive-class definition, dropped rows, and class balance.

Initial Rows768
Final Rows768
Rows Removed0
Positive Rate0.349
What this means

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.

Visualization

ROC Curve

True positive rate vs false positive rate across every cutoff on Glucose.

What this means

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.

Data Table

Cutoff Performance

AUC, confidence interval, and every operating-point metric at the Youden cutoff.

MetricValueInterpretation
AUC0.788Probability a random '1' row scores above a random '0' row — fair discrimination.
AUC 95% CI0.752 to 0.824Hanley-McNeil 95% confidence interval for the AUC.
Youden cutoff on Glucose124.0000Flag a row positive when Glucose is at or above this value (maximizes sensitivity + specificity).
Sensitivity70.1%Share of actual '1' rows caught at this cutoff.
Specificity73.2%Share of actual '0' rows correctly cleared.
PPV (precision)58.4%Of rows flagged positive, the share that truly are '1'.
NPV82.1%Of rows cleared, the share that truly are '0'.
Accuracy72.1%Overall share of rows classified correctly at this cutoff.
What this means

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.

Data Table

Threshold Sweep

Sensitivity, specificity, and Youden's J across candidate cutoffs.

ThresholdSensitivitySpecificityYouden J
1340.5670.8320.399
1330.5750.8260.401
1320.5820.820.402
1310.5930.8160.409
1300.6040.8080.412
1280.6490.7820.431
1270.6490.7720.421
1260.6570.7580.415
1250.6830.7440.427
1240.7010.7320.433
1230.7090.7180.427
1220.720.70.42
1210.7280.6920.42
1200.7390.6760.415
1180.7610.6540.415
1170.7690.6360.405
1160.7720.6240.396
1150.7950.6160.411
1140.8020.5980.4
What this means

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.

Rate this report Was this the answer you needed?
The exact source that produced this report — yours to keep, read, and re-run.
Download PDF
How this was computed method · R source · citation
The code that did it

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 &#x27;actual' column (the true yes/no outcome).")
  }
  if (!"score" %in% names(df)) {
    stop("column_mapping must map a &#x27;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(&#x27;%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(&#x27;%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 &#x27;%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(&#x27;%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 - 1

Step 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_rows

Step 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 &#x27;", positive_label,
             "&#x27; 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 &#x27;", positive_label, "' rows caught at this cutoff."),
      paste0("Share of actual &#x27;", negative_label, "' rows correctly cleared."),
      paste0("Of rows flagged positive, the share that truly are &#x27;",
             positive_label, "&#x27;."),
      paste0("Of rows cleared, the share that truly are &#x27;", 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) <- NULL

Step 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 = &#x27;", 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
  )
}
Your data has more stories to tell.Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing