Standard Classification Drivers
Executive Summary

Executive Summary

What drives DEATH EVENT toward '1' across 299 observations

Observations
299
AUC
0.797
Pseudo R Squared
0.202
Accuracy
0.759
Significant Terms
3
Top Driver
ejection fraction
Across 299 observations (32.1% '1'), ejection fraction is the strongest driver of DEATH EVENT: each additional unit of ejection fraction multiplies the odds of 1 by 0.9344 (p = 2.64e-06). age ranks second. The model's discrimination is good (AUC = 0.797), and 3 driver terms are statistically significant at p < 0.05. 3 of 8 mapped driver columns have at least one significant term.
What this means

The short answer

Ejection fraction is the strongest mortality predictor: each additional unit multiplies the odds of death by 0.9344 (p = 2.64e-06). Age and serum creatinine rank second and third. Three of eight drivers reach statistical significance, and the model's discrimination is good (AUC = 0.797).

The detail

Across 299 observations (32.1% death events), ejection fraction leads with an odds ratio of 0.9344 (95% CI: 0.9072–0.9602, p = 2.64e-06). Age follows with an odds ratio of 1.053 per year (95% CI: 1.028–1.081, p < 0.001), and serum creatinine with 1.933 per unit (95% CI: 1.428–2.778, p = 0.0001). Three driver terms are statistically significant at p < 0.05. Model accuracy is 75.9% at the 0.5 threshold versus a 67.9% baseline.

What this can't tell you

The remaining five drivers (serum sodium, anaemia, diabetes, high blood pressure, smoking) show no statistically reliable effect in this sample. Confidence intervals that include 1 (e.g., anaemia: 0.7639–2.389) are consistent with no association, though a larger cohort could detect smaller effects.

Overview

Analysis Overview

Logistic regression of DEATH EVENT on 8 driver columns across 299 observations.

N Observations299
Auc0.797
Pseudo R20.202
N Drivers8
Success Rate0.321
Top Driverejection fraction
What this means

The short answer

Logistic regression models the log-odds of heart failure death, expressing each clinical factor's independent contribution as an odds ratio—a multiplier on the odds of death per one-unit increase. The 8-factor model achieves good discrimination (AUC = 0.797) across 299 patients, with ejection fraction as the strongest predictor.

The detail

The model fits 8 drivers (age, ejection fraction, serum creatinine, serum sodium, anaemia, diabetes, high blood pressure, smoking) to predict DEATH EVENT across 299 observations, where 32.1% experienced the event. Each odds ratio describes the multiplicative change in odds of death holding all other drivers fixed. An odds ratio above 1 raises the odds; below 1 lowers them. The model separates the two classes with AUC = 0.797 and McFadden pseudo-R² = 0.202, indicating moderate explanatory power.

What this can't tell you

This is cross-sectional association, not temporal causation. The odds ratios describe which factors correlate with mortality risk in this cohort, not the biological mechanisms by which they act.

Data Preparation

Data Quality

Outcome binarization, driver typing, and exclusions.

Initial Rows299
Final Rows299
Rows Removed0
Success Rate0.321
What this means

The short answer

All 299 rows were retained with complete outcome data. Numeric clinical factors were imputed using column medians where missing; categorical factors were grouped into meaningful categories with rare levels lumped together. No rows or drivers were excluded.

The detail

299 rows loaded, 299 retained—zero rows removed. DEATH EVENT was binarized with '1' (event, 32.1% of rows) as the success class and '0' as reference. Numeric drivers (age, ejection fraction, serum creatinine, serum sodium) received median imputation for missing values. Text drivers (anaemia, diabetes, high blood pressure, smoking) were treated as categories, with blanks coded as 'Missing' and rare levels beyond 12 grouped into 'Other'. All mapped driver columns were usable.

What this can't tell you

Median imputation assumes missing values are missing at random within each driver. If missingness is systematic (e.g., sicker patients had incomplete labs), imputed estimates may be biased. A summary of the original missing-data pattern would clarify this.

Data Table

Odds Ratios

Each term's effect on the odds of '1', with 95% confidence intervals.

TermOdds RatioCI LowCI HighP ValueSignificance
Intercept52.230.00813930000.377
age1.0531.0281.0810***
ejection fraction0.93440.90720.96022.64e-06***
serum creatinine1.9331.4282.7780.0001***
serum sodium0.95190.89221.0140.129
anaemia1.3490.76392.3890.302
diabetes1.1840.66712.1030.564
high blood pressure1.5260.85032.7430.156
smoking0.95280.51241.7510.877
What this means

The short answer

Three clinical factors have odds ratios with confidence intervals that exclude 1, indicating reliable associations with death: ejection fraction (0.9344, lowers odds), age (1.053 per year, raises odds), and serum creatinine (1.933, raises odds). The remaining five drivers show no significant effect.

The detail

Ejection fraction: OR = 0.9344 (95% CI: 0.9072–0.9602, p = 2.64e-06). Age: OR = 1.053 (95% CI: 1.028–1.081, p < 0.001). Serum creatinine: OR = 1.933 (95% CI: 1.428–2.778, p = 0.0001). Serum sodium, anaemia, diabetes, high blood pressure, and smoking all have confidence intervals that cross 1 and p-values > 0.05, indicating no reliable effect at this sample size. The intercept (OR = 52.23, p = 0.377) is not significant.

What this can't tell you

The five non-significant drivers may have weak true effects that this sample cannot detect with precision. Serum sodium's OR of 0.9519 (p = 0.129) approaches but does not reach significance; a larger cohort might clarify its role.

Visualization

Driver Ranking

Relative statistical influence of each driver, scaled 0-100.

What this means

The short answer

Ejection fraction dominates, with an importance score of 100. Age (86.7) and serum creatinine (84.1) follow closely, concentrating statistical influence in the top three drivers. The remaining five drivers score 32.3 or below.

The detail

Ejection fraction: importance 100, lowers the odds, p = 2.64e-06. Age: importance 86.7, raises the odds, p < 0.001. Serum creatinine: importance 84.1, raises the odds, p = 0.0001. Serum sodium (32.3), high blood pressure (30.2), anaemia (22), diabetes (12.3), and smoking (3.3) trail substantially. Importance is scaled as the ratio of each driver's |z|-statistic to the strongest driver's, so the top three account for the vast majority of statistical signal.

What this can't tell you

Importance ranking reflects statistical influence in this sample, not clinical significance or actionability. A driver ranked fifth may still be clinically meaningful even if its statistical signal is weak.

Visualization

Predicted Probabilities

How confidently the model scores each row toward '1'.

What this means

The short answer

The model assigns predicted probabilities that moderately separate death cases from survivors. Rows with actual death average 0.488 predicted probability versus 0.242 for survivors—a gap of 0.247—indicating the drivers carry meaningful but incomplete signal.

The detail

Predicted probabilities range across the full 0–1 scale, with two visibly separated humps in the distribution. Death cases ('1') average 0.488 predicted probability; non-death cases ('0') average 0.242. The 0.247 gap between the group means reflects moderate class separation: the drivers reliably score death cases higher than non-death cases on average, but with substantial overlap. This pattern is consistent with an AUC of 0.797.

What this can't tell you

Moderate separation means the model will misclassify a meaningful share of patients at any fixed threshold. Some high-risk patients will be assigned low probabilities and vice versa. Thresholds other than 0.5 may improve sensitivity or specificity depending on clinical priorities.

Data Table

Model Quality

Discrimination, fit, and 0.5-threshold classification metrics.

MetricValueInterpretation
AUC0.797Probability a random '1' row is scored above a random '0' row — good discrimination.
McFadden pseudo-R²0.202Improvement in fit over an intercept-only model (0 = none, 1 = perfect).
Accuracy (0.5 threshold)0.759Share of rows classified correctly when predicting '1' above probability 0.5.
Sensitivity0.458Share of actual '1' rows the model catches.
Specificity0.901Share of actual '0' rows the model correctly clears.
Baseline accuracy0.679Accuracy from always predicting the majority class ('0').
What this means

The short answer

The model achieves good discrimination (AUC = 0.797) and beats a naive baseline by 8 percentage points (75.9% accuracy versus 67.9% majority-class baseline). It catches 45.8% of actual deaths while correctly clearing 90.1% of survivors.

The detail

AUC = 0.797 indicates the probability a randomly selected death case is scored higher than a randomly selected non-death case—good discrimination. McFadden pseudo-R² = 0.202 shows the model improves fit by 20.2 percentage points over an intercept-only model. At a 0.5 threshold: accuracy = 75.9%, sensitivity = 45.8%, specificity = 90.1%. The baseline (always predict '0') achieves 67.9% accuracy; the model's lift is 8 percentage points.

What this can't tell you

The 0.5 threshold is arbitrary and may not align with clinical cost–benefit trade-offs. The model's high specificity (90.1%) comes at the cost of moderate sensitivity (45.8%), meaning it misses more than half of actual deaths. Adjusting the threshold upward would catch more deaths but increase false alarms.

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

Logistic Regression — What Drives Yes vs No

Fits a logistic regression explaining a binary outcome (churned, converted, defaulted) from one or more driver columns chosen by the user. Reports each driver's effect as an odds ratio with a 95% confidence interval, ranks drivers by statistical influence, shows the predicted-probability distribution, and summarizes model quality with AUC, McFadden pseudo-R², and confusion metrics.

Why This Method?

Logistic regression is the workhorse for yes/no outcomes: each driver's effect becomes an odds ratio ("one unit of X multiplies the odds by Y"), significance tests separate signal from noise, and AUC gives an honest read on how well the drivers actually distinguish the two classes.

What This Analysis Covers

  • Odds ratios with 95% confidence intervals and significance
  • Driver ranking by statistical influence (|z|)
  • Predicted-probability distribution (class separation)
  • Model quality: AUC, pseudo-R², accuracy, sensitivity, specificity

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {outcome, driver_1..driver_N}. 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 (!"outcome" %in% names(df)) {
    stop("column_mapping must map an &#x27;outcome' column (the yes/no value to explain)")
  }
  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)")
  }

  outcome_name <- humanize_semantic("outcome", col_map)
  driver_names <- setNames(humanize_semantic(driver_cols, col_map), driver_cols)

Step 2: Binarize the outcome (robust 0/1, logical, two-level string)

v_raw <- df$outcome
  if (is.logical(v_raw)) {
    df <- df[!is.na(v_raw), , drop = FALSE]
    y <- as.integer(df$outcome)
    success_label <- "TRUE"
    failure_label <- "FALSE"
  } else {
    vc <- trimws(as.character(v_raw))
    keep <- !is.na(v_raw) & !is.na(vc) & vc != ""
    df <- df[keep, , drop = FALSE]
    vc <- vc[keep]
    lv <- sort(unique(vc))
    if (length(lv) < 2) {
      stop(sprintf(
        "The outcome column(&#x27;%s') has only one value ('%s') — a yes/no outcome needs both classes present.",
        outcome_name, if (length(lv) == 1) lv[1] else "empty"))
    }
    if (length(lv) > 2) {
      stop(sprintf(
        "The outcome column(&#x27;%s') has %d distinct values (%s%s) — a yes/no outcome must have exactly two, such as churned/retained, yes/no, or 0/1.",
        outcome_name, length(lv), paste(head(lv, 5), collapse = ", "),
        if (length(lv) > 5) ", …" else ""))
    }

Which level is the "success" (event)? Match common positive labels case-insensitively; otherwise take the alphabetically-last level.

pat <- "^(yes|true|1|churned|converted|clicked|purchased|default|fraud)$"
    hits <- lv[grepl(pat, tolower(lv))]
    success_label <- if (length(hits) == 1) hits else lv[2]
    failure_label <- setdiff(lv, success_label)[1]
    y <- as.integer(vc == success_label)
  }
  df$outcome <- y
  if (nrow(df) < 30) {
    stop(sprintf(
      "Only %d rows have a usable value in the outcome column(&#x27;%s'). At least 30 are required.",
      nrow(df), outcome_name))
  }
  if (sum(y == 1) == 0 || sum(y == 0) == 0) {
    stop(sprintf(
      "After cleaning, the outcome column(&#x27;%s') contains only one class — both outcomes must be present.",
      outcome_name))
  }
  success_rate <- mean(y)

Step 3: Type each driver — numeric if >=95% of values convert, else factor

dropped_drivers <- 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)) {

Numeric: impute NA with median

med <- median(v, na.rm = TRUE)
      if (is.na(med)) { dropped_drivers <- c(dropped_drivers, dc); next }
      v[is.na(v)] <- med
      df[[dc]] <- v
    } else {

Categorical: blank/NA -> "Missing"; lump beyond 12 levels into "Other"

v <- as.character(v)
      v[is.na(v) | trimws(v) == ""] <- "Missing"
      tab <- sort(table(v), decreasing = TRUE)
      if (length(tab) > 12) {
        keep <- names(tab)[1:12]
        v[!(v %in% keep)] <- "Other"
      }
      if (length(unique(v)) > nrow(df) / 2) {

Near-unique text column (an ID, not a driver) — exclude

dropped_drivers <- c(dropped_drivers, dc)
        next
      }
      df[[dc]] <- factor(v)
    }
  }

Step 4: Drop zero-variance drivers

for (dc in setdiff(driver_cols, dropped_drivers)) {
    v <- df[[dc]]
    zero_var <- if (is.numeric(v)) {
      isTRUE(var(v, na.rm = TRUE) == 0) || is.na(var(v, na.rm = TRUE))
    } else {
      length(unique(v)) <= 1
    }
    if (zero_var) dropped_drivers <- c(dropped_drivers, dc)
  }
  model_drivers <- setdiff(driver_cols, dropped_drivers)
  if (length(model_drivers) == 0) {
    stop("No usable driver columns remained after cleaning(all were constant, empty, or identifier-like).")
  }

  df_clean <- df[, c(model_drivers, "outcome"), drop = FALSE]
  final_rows <- nrow(df_clean)
  rows_removed <- initial_rows - final_rows

Step 5: Guard — need clearly more rows than model terms

n_terms_est <- sum(sapply(model_drivers, function(dc) {
    if (is.factor(df_clean[[dc]])) nlevels(df_clean[[dc]]) - 1 else 1
  }))
  while (n_terms_est >= final_rows - 5 && length(model_drivers) > 1) {
    drop_dc <- model_drivers[length(model_drivers)]
    dropped_drivers <- c(dropped_drivers, drop_dc)
    model_drivers <- model_drivers[-length(model_drivers)]
    df_clean <- df_clean[, c(model_drivers, "outcome"), drop = FALSE]
    n_terms_est <- sum(sapply(model_drivers, function(dc) {
      if (is.factor(df_clean[[dc]])) nlevels(df_clean[[dc]]) - 1 else 1
    }))
  }

Step 6: Fit logistic regression, capturing convergence warnings

glm_warnings <- character(0)
  model <- withCallingHandlers(
    glm(outcome ~ ., data = df_clean, family = binomial()),
    warning = function(w) {
      glm_warnings <<- c(glm_warnings, conditionMessage(w))
      invokeRestart("muffleWarning")
    }
  )
  pred <- as.numeric(fitted(model))
  yv <- df_clean$outcome

Separation / convergence diagnostics — note in narrative, never crash

sep_frac <- mean(pred < 1e-3 | pred > 1 - 1e-3)
  separation_flag <- sep_frac > 0.99
  nonconv_flag <- any(grepl("did not converge|fitted probabilities numerically 0 or 1",
                            glm_warnings))
  fit_note <- if (separation_flag) {
    paste0("Caution: one or more drivers separate the two outcome classes ",
           "almost perfectly(", round(100 * sep_frac, 1), "% of predicted ",
           "probabilities are at 0 or 1). Coefficients and odds ratios for ",
           "the separating driver are unstable and their confidence ",
           "intervals unreliable — treat the ranking, not the magnitudes, ",
           "as the finding.")
  } else if (nonconv_flag) {
    paste0("Caution: the model fit raised convergence warnings(often a ",
           "sign of near-perfect separation or sparse categories). ",
           "Estimates near the extremes should be read with care.")
  } else ""

Step 7: Odds ratios — broom::tidy + exp(); CIs fall back to NA

tidy_df <- tryCatch(
    suppressWarnings(suppressMessages(
      broom::tidy(model, conf.int = TRUE, conf.level = 0.95))),
    error = function(e) {
      td <- broom::tidy(model)
      td$conf.low <- NA_real_
      td$conf.high <- NA_real_
      td
    }
  )
  odds_ratios_df <- data.frame(
    term         = humanize_term(tidy_df$term, model_drivers, col_map),
    odds_ratio   = signif(exp(tidy_df$estimate), 4),
    ci_low       = signif(exp(tidy_df$conf.low), 4),
    ci_high      = signif(exp(tidy_df$conf.high), 4),
    p_value      = signif(tidy_df$p.value, 3),
    significance = ifelse(is.na(tidy_df$p.value), "",
                   ifelse(tidy_df$p.value < 0.001, "***",
                   ifelse(tidy_df$p.value < 0.01,  "**",
                   ifelse(tidy_df$p.value < 0.05,  "*",
                   ifelse(tidy_df$p.value < 0.1,   ".", ""))))),
    stringsAsFactors = FALSE
  )
  n_sig <- sum(tidy_df$p.value < 0.05 & tidy_df$term != "(Intercept)", na.rm = TRUE)

Step 8: Driver importance — max |z| across a driver's terms, 0-100.

Aliased (perfectly collinear) drivers have NA/absent statistics — no estimable independent effect; exclude from the ranking instead of crashing on which.max(all-NA) -> integer(0) (LAT-1445 guard).

term_raw <- tidy_df$term[tidy_df$term != "(Intercept)"]
  term_z   <- tidy_df$statistic[tidy_df$term != "(Intercept)"]
  term_est <- tidy_df$estimate[tidy_df$term != "(Intercept)"]
  term_p   <- tidy_df$p.value[tidy_df$term != "(Intercept)"]
  imp_rows <- lapply(model_drivers, function(dc) {
    idx <- which(startsWith(term_raw, dc))
    idx <- idx[!is.na(term_z[idx])]
    if (length(idx) == 0) return(NULL)
    best <- idx[which.max(abs(term_z[idx]))]
    data.frame(
      semantic  = dc,
      driver    = driver_names[[dc]],
      abs_z     = abs(term_z[best]),
      direction = if (is.numeric(df_clean[[dc]])) {
        if (term_est[best] > 0) "raises the odds" else "lowers the odds"
      } else "categorical",
      p_value   = signif(term_p[best], 3),
      top_term  = humanize_term(term_raw[best], model_drivers, col_map),
      top_or    = signif(exp(term_est[best]), 4),
      stringsAsFactors = FALSE
    )
  })
  importance_df <- do.call(rbind, Filter(Negate(is.null), imp_rows))
  if (!is.null(importance_df) && nrow(importance_df) > 0) {
    importance_df <- importance_df[order(-importance_df$abs_z), , drop = FALSE]
    max_z <- max(importance_df$abs_z, na.rm = TRUE)
    importance_df$importance_score <- if (is.finite(max_z) && max_z > 0) {
      round(100 * importance_df$abs_z / max_z, 1)
    } else 0
    rownames(importance_df) <- NULL
  } else {
    importance_df <- data.frame(semantic = character(0), driver = character(0),
                                abs_z = numeric(0), direction = character(0),
                                p_value = numeric(0), top_term = character(0),
                                top_or = numeric(0), importance_score = numeric(0),
                                stringsAsFactors = FALSE)
  }
  top_driver_name <- if (nrow(importance_df) > 0) importance_df$driver[1] else "Unknown"

Driver COLUMNS (not terms) with at least one significant term at p<0.05 — business readers think in drivers, so the tldr reports this alongside n_sig.

n_sig_drivers <- sum(vapply(model_drivers, function(dc) {
    idx <- which(startsWith(term_raw, dc))
    any(term_p[idx] < 0.05, na.rm = TRUE)
  }, logical(1)))

Plain-English odds statement for the strongest driver

top_driver_phrase <- if (nrow(importance_df) > 0) {
    top <- importance_df[1, ]
    if (top$direction == "categorical") {
      lvl <- sub("^.*: ", "", top$top_term)
      paste0("the group &#x27;", lvl, "' has ", top$top_or,
             "x the odds of ", success_label, " versus its reference group")
    } else {
      paste0("each additional unit of ", top$driver,
             " multiplies the odds of ", success_label, " by ", top$top_or)
    }
  } else "no driver had an estimable effect"

Step 9: Model quality — pseudo-R², rank-based AUC, confusion at 0.5

ll_full <- suppressWarnings(as.numeric(logLik(model)))
  null_model <- suppressWarnings(glm(outcome ~ 1, data = df_clean, family = binomial()))
  ll_null <- suppressWarnings(as.numeric(logLik(null_model)))
  mcfadden <- if (is.finite(ll_full) && is.finite(ll_null) && ll_null != 0) {
    1 - ll_full / ll_null
  } else NA_real_

  n1 <- sum(yv == 1)
  n0 <- sum(yv == 0)
  auc <- if (n1 > 0 && n0 > 0) {
    r <- rank(pred)
    (sum(r[yv == 1]) - n1 * (n1 + 1) / 2) / (as.numeric(n1) * as.numeric(n0))
  } else NA_real_

  pred_class <- as.integer(pred >= 0.5)
  tp <- sum(pred_class == 1 & yv == 1)
  tn <- sum(pred_class == 0 & yv == 0)
  fp <- sum(pred_class == 1 & yv == 0)
  fn <- sum(pred_class == 0 & yv == 1)
  accuracy    <- (tp + tn) / length(yv)
  sensitivity <- if ((tp + fn) > 0) tp / (tp + fn) else NA_real_
  specificity <- if ((tn + fp) > 0) tn / (tn + fp) else NA_real_
  baseline_acc <- max(success_rate, 1 - success_rate)

  band <- auc_band(auc)
  model_quality_df <- data.frame(
    metric = c("AUC", "McFadden pseudo-R²", "Accuracy(0.5 threshold)",
               "Sensitivity", "Specificity", "Baseline accuracy"),
    value = round(c(auc, mcfadden, accuracy, sensitivity, specificity,
                    baseline_acc), 3),
    interpretation = c(
      paste0("Probability a random &#x27;", success_label, "' row is scored above a random '",
             failure_label, "&#x27; row — ", band, " discrimination."),
      "Improvement in fit over an intercept-only model(0 = none, 1 = perfect).",
      paste0("Share of rows classified correctly when predicting &#x27;", success_label,
             "&#x27; above probability 0.5."),
      paste0("Share of actual &#x27;", success_label, "' rows the model catches."),
      paste0("Share of actual &#x27;", failure_label, "' rows the model correctly clears."),
      paste0("Accuracy from always predicting the majority class(&#x27;",
             if (success_rate >= 0.5) success_label else failure_label, "&#x27;).")
    ),
    stringsAsFactors = FALSE
  )

Step 10: Predicted-probability distribution — <=2000 sample

set.seed(42)
  sidx <- if (length(pred) > 2000) sample(length(pred), 2000) else seq_along(pred)
  probability_distribution_df <- data.frame(
    predicted_probability = round(pred[sidx], 4),
    stringsAsFactors = FALSE
  )
  mean_p1 <- mean(pred[yv == 1])
  mean_p0 <- mean(pred[yv == 0])

Step 11: KPI metrics (user-facing keys)

metrics <- list(
    `Observations`        = final_rows,
    `AUC`                 = round(auc, 3),
    `Pseudo R Squared`    = round(mcfadden, 3),
    `Accuracy`            = round(accuracy, 3),
    `Significant Terms`   = as.integer(n_sig),
    `Top Driver`          = top_driver_name
  )

Step 12: json_output machine channel

json_output <- list(
    answer = paste0(
      "Logistic regression of ", outcome_name, " (success = &#x27;", success_label,
      "&#x27;, ", round(100 * success_rate, 1), "% of rows) on ",
      length(model_drivers), " driver(s) across ",
      format(final_rows, big.mark = ","), " rows: ", top_driver_name,
      " is the strongest driver — ", top_driver_phrase, " (p = ",
      importance_df$p_value[1] %||% NA, "). AUC = ", round(auc, 3), " (",
      band, "), McFadden pseudo-R² = ", round(mcfadden, 3), "; ", n_sig,
      if (n_sig == 1) " driver term" else " driver terms",
      " significant at p<0.05.",
      if (nchar(fit_note) > 0) paste0(" ", fit_note) else ""
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "odds_ratios_table",
        "driver_importance", "probability_distribution", "model_quality"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows  = initial_rows,
    final_rows    = final_rows,
    rows_removed  = rows_removed,
    outcome_name  = outcome_name,
    success_label = success_label,
    failure_label = failure_label,
    success_rate  = success_rate,
    driver_names  = driver_names,
    model_drivers = model_drivers,
    dropped_drivers = dropped_drivers,
    df_clean      = df_clean,
    model         = model,
    fit_note      = fit_note,
    odds_ratios_df = odds_ratios_df,
    importance_df = importance_df,
    top_driver_name = top_driver_name,
    top_driver_phrase = top_driver_phrase,
    probability_distribution_df = probability_distribution_df,
    mean_p1       = mean_p1,
    mean_p0       = mean_p0,
    model_quality_df = model_quality_df,
    auc           = auc,
    mcfadden      = mcfadden,
    accuracy      = accuracy,
    sensitivity   = sensitivity,
    specificity   = specificity,
    baseline_acc  = baseline_acc,
    n_sig         = n_sig,
    n_sig_drivers = n_sig_drivers,
    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