Executive Summary
What drives DEATH EVENT toward '1' across 299 observations
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.
Analysis Overview
Logistic regression of DEATH EVENT on 8 driver columns across 299 observations.
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 Quality
Outcome binarization, driver typing, and exclusions.
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.
Odds Ratios
Each term's effect on the odds of '1', with 95% confidence intervals.
| Term | Odds Ratio | CI Low | CI High | P Value | Significance |
|---|---|---|---|---|---|
| Intercept | 52.23 | 0.0081 | 393000 | 0.377 | |
| age | 1.053 | 1.028 | 1.081 | 0 | *** |
| ejection fraction | 0.9344 | 0.9072 | 0.9602 | 2.64e-06 | *** |
| serum creatinine | 1.933 | 1.428 | 2.778 | 0.0001 | *** |
| serum sodium | 0.9519 | 0.8922 | 1.014 | 0.129 | |
| anaemia | 1.349 | 0.7639 | 2.389 | 0.302 | |
| diabetes | 1.184 | 0.6671 | 2.103 | 0.564 | |
| high blood pressure | 1.526 | 0.8503 | 2.743 | 0.156 | |
| smoking | 0.9528 | 0.5124 | 1.751 | 0.877 |
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.
Driver Ranking
Relative statistical influence of each driver, scaled 0-100.
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.
Predicted Probabilities
How confidently the model scores each row toward '1'.
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.
Model Quality
Discrimination, fit, and 0.5-threshold classification metrics.
| Metric | Value | Interpretation |
|---|---|---|
| AUC | 0.797 | Probability a random '1' row is scored above a random '0' row — good discrimination. |
| McFadden pseudo-R² | 0.202 | Improvement in fit over an intercept-only model (0 = none, 1 = perfect). |
| Accuracy (0.5 threshold) | 0.759 | Share of rows classified correctly when predicting '1' above probability 0.5. |
| Sensitivity | 0.458 | Share of actual '1' rows the model catches. |
| Specificity | 0.901 | Share of actual '0' rows the model correctly clears. |
| Baseline accuracy | 0.679 | Accuracy from always predicting the majority class ('0'). |
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.
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 '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('%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('%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('%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('%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_rowsStep 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$outcomeSeparation / 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 '", 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 '", success_label, "' row is scored above a random '",
failure_label, "' row — ", band, " discrimination."),
"Improvement in fit over an intercept-only model(0 = none, 1 = perfect).",
paste0("Share of rows classified correctly when predicting '", success_label,
"' above probability 0.5."),
paste0("Share of actual '", success_label, "' rows the model catches."),
paste0("Share of actual '", failure_label, "' rows the model correctly clears."),
paste0("Accuracy from always predicting the majority class('",
if (success_rate >= 0.5) success_label else failure_label, "').")
),
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 = '", success_label,
"', ", 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
)
}