Executive Summary
Missingness, mechanism evidence, and how much the handling rule moves the answer
The short answer
Missing data is material and not random: 10% of cells are missing across 1,200 rows, with Systolic BP missing in 30% of cases. A formal test rejects the random-missingness hypothesis (p < 0.001), and the analysis found that missingness is associated with Patient Age—strong evidence that the pattern is systematic. Estimates move noticeably depending on how you handle the gaps.
The detail
Of 1,200 rows and 4 columns, 10% of cells are missing, concentrated in 2 columns. Systolic BP is worst at 30% missing. Listwise deletion would discard 431 rows, retaining 769 (64.08%). The most common incomplete pattern is Systolic BP missing alone, affecting 311 rows (25.9%). Little's MCAR test p < 0.001 rejects the hypothesis that missingness is random. Among 6 tests for association between missingness and observed values, 1 survived multiple-comparison correction: Patient Age mean is 61.282 when Systolic BP is missing versus 47.954 when present (Welch t-test, p < 0.001). Patient Age estimates shift by 4.054 (0.37 standard deviations) across deletion, mean imputation, and regression. Mean imputation reduces variance in the worst-affected column to 83.7% of its original spread.
What this can't tell you
The data cannot distinguish between MAR (missingness depends on an observed variable, as suggested by the Patient Age association) and MNAR (missingness depends on the unobserved value itself). Bias direction and magnitude under MNAR are not identifiable without external information or formal sensitivity analysis.
Analysis Overview
Missingness audit across 4 columns and 1,200 rows.
Missingness is material and systematic, not random. The analysis audits 1,200 rows and 4 numeric columns, classifying every cell as present or missing, then tests whether the pattern is MCAR (missing completely at random), looks for observable predictors of missingness, and re-computes estimates under four different handling rules. The core finding is that Little's test rejects MCAR at p < 0.001, meaning the rows with gaps differ systematically from complete rows. MCAR, MAR, and MNAR cannot be fully separated from data alone—a test can reject MCAR and an association with observed values is evidence for MAR, but nothing rules out MNAR (missingness depending on the unobserved value itself).
Data Quality
How cells were classified, and which columns carry the statistical tests.
All 1,200 rows are retained; no rows are dropped because missingness itself is the measurement. A cell counts as missing when empty, blank, or null. Of 4,800 cells, 480 (10.0%) are missing. All 4 mapped numeric columns (Patient Age, Systolic BP, Cholesterol, Body Mass Index) carry observed variation and are used in full. These 4 columns drive Little's MCAR test and the estimate comparison. Categorical columns are audited for missingness but the estimate comparison covers numeric quantities only.
Missing Data by Column
Share of rows with a missing value, per mapped column.
Two columns have gaps; two are complete. Systolic BP carries 360 missing values (30.0% of rows). Cholesterol carries 120 missing values (10.0% of rows). Patient Age and Body Mass Index are complete. Per-column rates alone do not reveal whether the same rows are affected—that pattern is shown separately.
Missingness Patterns
The distinct combinations of columns that go missing together.
| Pattern | Columns Missing | N Rows | PCT Of Rows | N Columns Missing |
|---|---|---|---|---|
| 0000 | (complete row — nothing missing) | 769 | 64.08 | 0 |
| 0100 | Systolic BP | 311 | 25.92 | 1 |
| 0010 | Cholesterol | 71 | 5.92 | 1 |
| 0110 | Systolic BP + Cholesterol | 49 | 4.08 | 2 |
Four distinct patterns occur across 1,200 rows. The complete pattern (nothing missing) covers 769 rows (64.1%). The dominant incomplete pattern is Systolic BP alone, affecting 311 rows (25.9%). Cholesterol alone affects 71 rows (5.9%). Both Systolic BP and Cholesterol missing together occur in 49 rows (4.1%). One pattern involves two columns at once. The concentration in a single dominant incomplete pattern (Systolic BP) suggests a structural cause rather than scattered non-response.
Co-Missingness Map
Percent of all rows in which each pair of columns is missing together.
The strongest coupling is Systolic BP with Cholesterol: they are missing together in 4.08% of rows (49 rows) against 36.0 expected under independence, a ratio of 1.36 to 1. This is close to what independence alone would produce, so the co-missingness is modest. Patient Age and Body Mass Index show zero co-missingness with any other column. The pattern is not a tightly coupled block; instead, Systolic BP dominates, with Cholesterol occasionally accompanying it.
Little's MCAR Test
A direct test of the hypothesis that values went missing completely at random.
| Item | Value |
|---|---|
| Little's chi-square statistic | 375.61 |
| Degrees of freedom | 8 |
| p-value | < 0.001 |
| Rows used | 1,200 |
| Numeric columns tested | 4 |
| Missingness patterns compared | 4 |
| EM iterations to converge | 30 |
| Verdict at the 5 percent level | MCAR rejected |
Little's chi-square statistic is 375.61 on 8 degrees of freedom across 4 patterns and 4 numeric columns, giving p < 0.001. MCAR is rejected at the 5 percent level—the rows with missing values differ systematically from complete rows. With 1,200 rows the test has moderate power. The test assumes joint normality of the numeric columns within each pattern; heavy skew inflates it. Rejection means the missingness is systematic; non-rejection would be a failure to find evidence, not evidence of absence.
Is Missingness Related To What You Can See?
Per-column tests of missingness against the values of the other columns.
| Missing In | Tested Against | Test | Statistic | Effect | P Value | P Adjusted | Direction |
|---|---|---|---|---|---|---|---|
| Systolic BP | Patient Age | Welch t-test on group means | 23.21 | 13.33 | < 0.001 | < 0.001 | mean Patient Age is higher when Systolic BP is missing (61.282) than when it is present (47.954) |
| Systolic BP | Cholesterol | Welch t-test on group means | 2.252 | 3.932 | 0.025 | 0.123 | mean Cholesterol is higher when Systolic BP is missing (233.383) than when it is present (229.451) |
| Cholesterol | Patient Age | Welch t-test on group means | 1.419 | 1.493 | 0.158 | 0.632 | mean Patient Age is higher when Cholesterol is missing (53.297) than when it is present (51.804) |
| Cholesterol | Systolic BP | Welch t-test on group means | 1.119 | 1.493 | 0.266 | 0.799 | mean Systolic BP is higher when Cholesterol is missing (120.417) than when it is present (118.924) |
| Cholesterol | Body Mass Index | Welch t-test on group means | 0.452 | 0.1933 | 0.652 | 1.000 | mean Body Mass Index is higher when Cholesterol is missing (27.273) than when it is present (27.079) |
| Systolic BP | Body Mass Index | Welch t-test on group means | 0.344 | 0.099 | 0.731 | 1.000 | mean Body Mass Index is higher when Systolic BP is missing (27.168) than when it is present (27.069) |
Six association tests were run; 1 survives Holm correction. The strongest is that mean Patient Age is 61.282 when Systolic BP is missing versus 47.954 when present (Welch t-test, p < 0.001, Holm-adjusted p < 0.001). This is practical evidence for MAR: missingness is predictable from recorded data, so a model using that data can partly correct for it. The other five associations do not survive multiple-comparison correction (Holm-adjusted p ≥ 0.123).
How Much The Handling Rule Moves The Answer
The same means and correlations recomputed under four handling rules.
| Quantity | Complete Case | Available Case | Mean Imputed | Regression Imputed | Spread |
|---|---|---|---|---|---|
| Mean of Patient Age | 47.9 | 51.95 | 51.95 | 51.95 | 4.054 |
| Mean of Systolic BP | 118.9 | 119.1 | 119.1 | 121.1 | 2.158 |
| Mean of Cholesterol | 229.5 | 230.6 | 230.6 | 230.7 | 1.254 |
| Mean of Body Mass Index | 27.04 | 27.1 | 27.1 | 27.1 | 0.0605 |
| Correlation Patient Age with Systolic BP | 0.4595 | 0.4624 | 0.324 | 0.5735 | 0.2495 |
| Correlation Patient Age with Cholesterol | 0.2388 | 0.223 | 0.2116 | 0.2301 | 0.0272 |
| Correlation Patient Age with Body Mass Index | 0.0834 | 0.071 | 0.071 | 0.071 | 0.0124 |
| Correlation Systolic BP with Cholesterol | 0.4231 | 0.4231 | 0.3495 | 0.4668 | 0.1173 |
| Correlation Systolic BP with Body Mass Index | 0.0097 | 0.0201 | 0.0166 | 0.0219 | 0.0122 |
| Correlation Cholesterol with Body Mass Index | -0.0382 | -0.0287 | -0.0273 | -0.0259 | 0.0124 |
The same means and correlations shift across handling rules. Mean Patient Age spans 4.0539 across complete-case deletion (47.899), available-case (51.953), and both imputation methods (51.953)—a difference of 0.37 standard deviations. Correlation between Patient Age and Systolic BP ranges from 0.324 (mean imputation) to 0.5735 (regression imputation), a spread of 0.2495. Mean imputation reproduces the observed mean by construction and cannot correct bias in it. Regression imputation corrects only the explained part and still understates uncertainty because fitted values carry no residual.
What Imputation Does To The Spread
Standard deviation retained under each rule, as a percent of the observed spread.
Mean imputation is worst on Systolic BP, retaining 83.65% of the observed standard deviation. Regression imputation does better, retaining 92.53% on Systolic BP and 95.44% on Cholesterol. Complete-case deletion shifts the spread even on columns with no gaps—Patient Age retains 84.02% of its observed spread despite having no missing values. This selection effect occurs because deletion removes entire rows. Mean imputation shrinks variance because filled values carry no residual scatter; the retained spread follows the square root of the observed fraction.
Methodology
Statistical methodology and diagnostics for Missing Data Audit
Statistical Method
Standard-library analysis: how bad is your missing data, and does it bias your results? Map the columns you care about and get the per-column missing rates, the pattern analysis (which columns go missing together, which is where the mechanism shows itself), a co-missingness map, how many rows survive listwise deletion, Little's MCAR test computed directly, per-column tests of whether missingness on one column is associated with the values of another — and then the impact half: the same means and correlations recomputed under complete-case deletion, available-case, mean imputation, and regression imputation, so you can see how far the answer moves when you change the handling rule.
- A cell is missing when it is empty, blank, or an explicit null — coded sentinels such as -999 or "unknown" are treated as present values unless you clean them first
- Little's MCAR test assumes the numeric columns are jointly normal within each missingness pattern; heavy skew inflates the statistic
- Rows are independent observations
- Regression imputation assumes the missing values are linearly related to the other numeric columns you mapped
- MCAR, MAR and MNAR cannot be fully distinguished from the data: a test can reject MCAR, and an association with an observed column is evidence for MAR, but nothing in the dataset can confirm MAR over MNAR, because MNAR means the missingness depends on the values that were never recorded
- Little's test has little power at small sample sizes and rejects on trivial differences at large ones, so the verdict must always be read alongside n
- Mean imputation preserves the mean exactly and therefore cannot correct a biased one; it also shrinks variance and pulls correlations toward zero, which is quantified here rather than merely warned about
- Deterministic regression imputation fills in fitted values with no residual, so it still understates variance and standard errors — it is not a substitute for multiple imputation
Analysis Code
Complete R source code for this analysis
Missing Data Audit — How Bad Is It, And Does It Bias The Answer?
Audits missingness across the columns the user maps: per-column rates, the pattern analysis (which columns go missing together), a co-missingness map, complete-case retention under listwise deletion, Little's MCAR test computed directly, per-column tests of whether missingness on one column is associated with the values of another, and a side-by-side comparison of what complete-case deletion, mean imputation, and regression imputation do to the estimates.
Why This Method?
Missingness is rarely uniform. The pattern of what goes missing together, and whether it lines up with the values of the columns you can see, is the only evidence the data itself carries about the mechanism. The imputation comparison turns "does this matter?" into a number: how far the answer moves when you change the handling rule.
What This Analysis Covers
- Per-column missing counts and rates
- Missingness pattern analysis and co-missingness map
- Complete-case retention under listwise deletion
- Little's MCAR test (implemented directly via EM)
- Per-column MAR evidence: does missingness on A track the values of B?
- Estimate shift under complete-case, available-case, mean, and regression imputation
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {column_1..column_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))Formatting + numerical helpers
Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting (no rows are dropped)
# disp $ named character — semantic -> humanized column names
# audit_cols $ character — every mapped column (the audit set)
# miss_by_col_df $ data.frame(column, column_type, n_missing, missing_pct)
# patterns_df $ data.frame(pattern, columns_missing, n_rows, pct_of_rows, n_columns_missing)
# cooccur_df $ data.frame(column_a, column_b, co_missing_pct)
# mcar_df $ data.frame(item, value) — Little's test disclosure
# mar_df $ data.frame(missing_in, tested_against, test, statistic, p_value, p_adjusted, direction)
# estimates_df $ data.frame(quantity, complete_case, available_case, mean_imputed, regression_imputed, spread)
# sd_df $ data.frame(column, method, sd_retained_pct)
# structural_notes $ character — suspected not-applicable (structural) missingness
# metrics / json_output
# === /SHARED EXPORTS ===Step 1: Discover the mapped columns and guard the minimum size
initial_rows <- nrow(df)
audit_cols <- grep("^column_[0-9]+$", names(df), value = TRUE)
audit_cols <- audit_cols[order(as.integer(sub("^column_", "", audit_cols)))]
if (length(audit_cols) < 2) {
stop("A missing-data audit needs at least two mapped columns(column_1, column_2).")
}
disp <- setNames(humanize_semantic(audit_cols, col_map), audit_cols)
n <- initial_rows
k <- length(audit_cols)
MIN_ROWS <- 20
if (n < MIN_ROWS) {
stop(sprintf(
paste0("A missing-data audit needs at least %d rows to describe a missingness pattern; ",
"the data mapped to %s has only %d."),
MIN_ROWS, paste(unname(disp), collapse = ", "), n))
}Step 2: Missingness indicator matrix + column typing
A cell counts as missing when it is NA or blank/whitespace-only.
M <- matrix(FALSE, nrow = n, ncol = k, dimnames = list(NULL, audit_cols))
for (cc in audit_cols) M[, cc] <- .is_missing(df[[cc]])
col_type <- setNames(rep("categorical", k), audit_cols)
num_vals <- list(); cat_vals <- list()
for (cc in audit_cols) {
v <- df[[cc]]; miss <- M[, cc]
obs <- v[!miss]
nv <- NULL
if (is.numeric(v)) {
nv <- as.numeric(v); nv[miss] <- NA_real_
} else if (length(obs) > 0) {
conv <- suppressWarnings(as.numeric(as.character(obs)))
if (sum(!is.na(conv)) >= 0.95 * length(obs)) {
nv <- rep(NA_real_, n); nv[!miss] <- conv
}
}
if (!is.null(nv)) {
col_type[cc] <- "numeric"; num_vals[[cc]] <- nv
} else {
sv <- rep(NA_character_, n)
if (length(obs) > 0) sv[!miss] <- as.character(obs)
cat_vals[[cc]] <- sv
}
}
n_missing <- colSums(M)[audit_cols]
n_observed <- n - n_missing
n_distinct <- vapply(audit_cols, function(cc) {
v <- if (col_type[cc] == "numeric") num_vals[[cc]] else cat_vals[[cc]]
length(unique(v[!is.na(v)]))
}, integer(1))
names(n_distinct) <- audit_cols
is_allmiss <- n_observed == 0
is_constant <- (!is_allmiss) & (n_distinct <= 1)
is_ident <- (col_type == "categorical") & (!is_allmiss) &
(n_observed >= 10) & (n_distinct >= 0.95 * n_observed)
col_type[is_allmiss] <- "entirely missing"Columns that can carry evidence: they need observed variation.
test_cols <- audit_cols[!is_allmiss & !is_constant & !is_ident & n_distinct >= 2]
excluded_cols <- setdiff(audit_cols, test_cols)
excl_reason <- setNames(vapply(excluded_cols, function(cc) {
if (is_allmiss[[cc]]) "no observed values at all"
else if (is_constant[[cc]]) "a single repeated value"
else if (is_ident[[cc]]) "one value per row, so it behaves as a row identifier"
else "fewer than two distinct observed values"
}, character(1)), excluded_cols)
num_cols <- test_cols[col_type[test_cols] == "numeric"]
if (length(num_cols) > 10) num_cols <- num_cols[seq_len(10)]Step 3: Per-column missingness
total_cells <- n * k
total_missing_cells <- sum(M)
overall_miss_pct <- 100 * total_missing_cells / total_cells
miss_by_col_df <- data.frame(
column = unname(disp[audit_cols]),
column_type = unname(col_type[audit_cols]),
n_missing = as.integer(unname(n_missing)),
missing_pct = round(100 * unname(n_missing) / n, 2),
stringsAsFactors = FALSE
)
miss_by_col_df <- miss_by_col_df[order(-miss_by_col_df$missing_pct,
miss_by_col_df$column), , drop = FALSE]
rownames(miss_by_col_df) <- NULL
n_cols_with_missing <- sum(n_missing > 0)
worst_i <- if (nrow(miss_by_col_df) > 0) 1L else NA_integer_
worst_col <- if (!is.na(worst_i)) miss_by_col_df$column[worst_i] else "none"
worst_pct <- if (!is.na(worst_i)) miss_by_col_df$missing_pct[worst_i] else 0Step 4: Pattern analysis — which columns go missing together
pat_key <- apply(M, 1, function(r) paste0(as.integer(r), collapse = ""))
pat_tab <- sort(table(pat_key), decreasing = TRUE)
n_patterns <- length(pat_tab)
complete_key <- paste0(rep("0", k), collapse = "")
n_complete_all <- as.integer(sum(pat_key == complete_key))
retention_pct <- 100 * n_complete_all / n
pat_label <- function(key) {
bits <- as.integer(strsplit(key, "")[[1]])
if (sum(bits) == 0) return("(complete row — nothing missing)")
paste(unname(disp[audit_cols[bits == 1]]), collapse = " + ")
}
keep_pat <- head(names(pat_tab), 12)
patterns_df <- data.frame(
pattern = keep_pat,
columns_missing = vapply(keep_pat, pat_label, character(1)),
n_rows = as.integer(pat_tab[keep_pat]),
pct_of_rows = round(100 * as.integer(pat_tab[keep_pat]) / n, 2),
n_columns_missing = vapply(keep_pat, function(kk) sum(as.integer(strsplit(kk, "")[[1]])), integer(1)),
stringsAsFactors = FALSE
)
rownames(patterns_df) <- NULL
if (n_patterns > length(keep_pat)) {
rest_n <- n - sum(patterns_df$n_rows)
patterns_df <- rbind(patterns_df, data.frame(
pattern = "other",
columns_missing = sprintf("(%d further patterns combined)", n_patterns - length(keep_pat)),
n_rows = as.integer(rest_n),
pct_of_rows = round(100 * rest_n / n, 2),
n_columns_missing = NA_integer_,
stringsAsFactors = FALSE))
}The most common pattern that actually has something missing.
inc_pat <- patterns_df[patterns_df$pattern != complete_key & patterns_df$pattern != "other", , drop = FALSE]
top_missing_pattern <- if (nrow(inc_pat) > 0) inc_pat[1, ] else NULLStep 5: Co-missingness map, and which pairs exceed independence
co_rows <- list(); lift_rows <- list()
for (i in seq_len(k)) {
for (j in seq_len(k)) {
both <- sum(M[, i] & M[, j])
co_rows[[length(co_rows) + 1]] <- data.frame(
column_a = unname(disp[audit_cols[i]]),
column_b = unname(disp[audit_cols[j]]),
co_missing_pct = round(100 * both / n, 2),
stringsAsFactors = FALSE)
if (j > i && n_missing[i] > 0 && n_missing[j] > 0) {
expected <- as.numeric(n_missing[i]) * as.numeric(n_missing[j]) / n
lift_rows[[length(lift_rows) + 1]] <- data.frame(
a = unname(disp[audit_cols[i]]), b = unname(disp[audit_cols[j]]),
observed = as.integer(both), expected = expected,
lift = if (expected > 0) both / expected else NA_real_,
stringsAsFactors = FALSE)
}
}
}
cooccur_df <- do.call(rbind, co_rows)
rownames(cooccur_df) <- NULL
lift_df <- if (length(lift_rows) > 0) do.call(rbind, lift_rows) else NULL
top_lift <- NULL
if (!is.null(lift_df)) {
ok <- which(is.finite(lift_df$lift))
if (length(ok) > 0) top_lift <- lift_df[ok[which.max(lift_df$lift[ok])], ]
}Step 6: Little's MCAR test, computed directly (EM + chi-square)
mcar <- list(ok = FALSE, reason = "no usable numeric columns were mapped")
if (length(num_cols) >= 2) {
Xn <- do.call(cbind, lapply(num_cols, function(cc) num_vals[[cc]]))
colnames(Xn) <- num_cols
mcar <- .little_mcar(Xn)
} else if (length(num_cols) == 1) {
mcar <- list(ok = FALSE, reason = "only one numeric column was mapped, and the test compares at least two")
}
if (total_missing_cells == 0) {
mcar <- list(ok = FALSE, reason = "there are no missing values, so there is no MCAR hypothesis to test")
}
mcar_verdict <- if (isTRUE(mcar$ok)) {
if (mcar$p_value < 0.05) "MCAR rejected" else "MCAR not rejected"
} else "not testable"
mcar_df <- if (isTRUE(mcar$ok)) {
data.frame(
item = c("Little's chi-square statistic", "Degrees of freedom", "p-value",
"Rows used", "Numeric columns tested", "Missingness patterns compared",
"EM iterations to converge", "Verdict at the 5 percent level"),
value = c(.fmt_f(mcar$statistic, 2), .fmt_n(mcar$df), .fmt_p(mcar$p_value),
.fmt_n(mcar$n), .fmt_n(mcar$k), .fmt_n(mcar$n_patterns),
.fmt_n(mcar$em_iterations), mcar_verdict),
stringsAsFactors = FALSE)
} else {
data.frame(
item = c("Little's chi-square statistic", "Verdict at the 5 percent level", "Why"),
value = c("not computed", "not testable", mcar$reason),
stringsAsFactors = FALSE)
}Step 7: MAR evidence — is missingness on A associated with the values of B?
target_cols <- audit_cols[n_missing >= 5 & n_observed >= 5]
mar_rows <- list()
for (a in target_cols) {
ind <- M[, a]
for (b in setdiff(test_cols, a)) {
keep <- !M[, b]
g <- ind[keep]
if (sum(g) < 5 || sum(!g) < 5) next
if (col_type[b] == "numeric") {
bv <- num_vals[[b]][keep]
tt <- tryCatch(stats::t.test(bv[g], bv[!g]), error = function(e) NULL)
if (is.null(tt) || !is.finite(tt$p.value)) next
m1 <- mean(bv[g]); m0 <- mean(bv[!g]); dmean <- m1 - m0
dir <- sprintf("mean %s is %s when %s is missing(%s) than when it is present(%s)",
unname(disp[b]),
if (dmean > 0) "higher" else "lower",
unname(disp[a]), .fmt_f(m1, 3), .fmt_f(m0, 3))
mar_rows[[length(mar_rows) + 1]] <- data.frame(
missing_in = unname(disp[a]), tested_against = unname(disp[b]),
test = "Welch t-test on group means", statistic = round(as.numeric(tt$statistic), 3),
p_raw = as.numeric(tt$p.value), effect = round(dmean, 4),
direction = dir, stringsAsFactors = FALSE)
} else {
bv <- cat_vals[[b]][keep]
lv <- names(sort(table(bv), decreasing = TRUE))
if (length(lv) > 10) {
bv[!(bv %in% head(lv, 9))] <- "Other"
}
tab <- table(bv, g)
if (nrow(tab) < 2 || ncol(tab) < 2) next
cs <- tryCatch(suppressWarnings(stats::chisq.test(tab, correct = FALSE)),
error = function(e) NULL)
if (is.null(cs) || !is.finite(cs$p.value)) next
rate <- prop.table(tab, margin = 1)[, "TRUE", drop = TRUE]
hi <- names(rate)[which.max(rate)]; lo <- names(rate)[which.min(rate)]
dir <- sprintf("%s is missing for %s of rows where %s is %s, against %s where it is %s",
unname(disp[a]), .fmt_pct(100 * max(rate)), unname(disp[b]), hi,
.fmt_pct(100 * min(rate)), lo)
mar_rows[[length(mar_rows) + 1]] <- data.frame(
missing_in = unname(disp[a]), tested_against = unname(disp[b]),
test = "Chi-square test of independence", statistic = round(as.numeric(cs$statistic), 3),
p_raw = as.numeric(cs$p.value), effect = round(.cramers_v(tab), 4),
direction = dir, stringsAsFactors = FALSE)
}
}
}
n_mar_tests <- length(mar_rows)
if (n_mar_tests > 0) {
mar_all <- do.call(rbind, mar_rows)
mar_all$p_holm <- stats::p.adjust(mar_all$p_raw, method = "holm")
mar_all <- mar_all[order(mar_all$p_raw), , drop = FALSE]
rownames(mar_all) <- NULL
n_mar_sig <- sum(mar_all$p_holm < 0.05)
mar_df <- data.frame(
missing_in = mar_all$missing_in, tested_against = mar_all$tested_against,
test = mar_all$test, statistic = mar_all$statistic,
effect = mar_all$effect,
p_value = vapply(mar_all$p_raw, .fmt_p, character(1)),
p_adjusted = vapply(mar_all$p_holm, .fmt_p, character(1)),
direction = mar_all$direction, stringsAsFactors = FALSE)
mar_df <- head(mar_df, 15)
top_mar <- if (n_mar_sig > 0) mar_all[1, ] else NULL
} else {
mar_all <- NULL; n_mar_sig <- 0L; top_mar <- NULL
mar_df <- data.frame(
missing_in = character(0), tested_against = character(0), test = character(0),
statistic = numeric(0), effect = numeric(0), p_value = character(0),
p_adjusted = character(0), direction = character(0), stringsAsFactors = FALSE)
}Step 8: Structural (not-applicable) missingness — a heuristic guess
structural_notes <- character(0)
cat_cols <- audit_cols[col_type == "categorical" & !is_ident & !is_allmiss &
n_distinct >= 2 & n_distinct <= 12]
for (a in audit_cols[n_missing > 0]) {
for (b in setdiff(cat_cols, a)) {
bv <- cat_vals[[b]]
for (lev in unique(bv[!is.na(bv)])) {
inlev <- !is.na(bv) & bv == lev
outlev <- !is.na(bv) & bv != lev
if (sum(inlev) < 10 || sum(outlev) < 10) next
r_in <- mean(M[inlev, a]); r_out <- mean(M[outlev, a])
if (r_in >= 0.95 && r_out <= 0.05) {
structural_notes <- c(structural_notes, sprintf(
paste0("%s is missing for %s of the %s rows where %s is \"%s\", but only %s ",
"of the %s rows where it is not — a shape that usually means ",
"\"not applicable\" rather than \"not recorded\". That reading is a guess ",
"from the pattern, not something the data can prove."),
unname(disp[a]), .fmt_pct(100 * r_in), .fmt_n(sum(inlev)), unname(disp[b]), lev,
.fmt_pct(100 * r_out), .fmt_n(sum(outlev))))
}
}
}
}
structural_notes <- head(unique(structural_notes), 3)Step 9: Estimate impact — complete case vs available case vs two imputations
est_ok <- length(num_cols) >= 1
estimates_df <- data.frame(
quantity = character(0), complete_case = numeric(0), available_case = numeric(0),
mean_imputed = numeric(0), regression_imputed = numeric(0), spread = numeric(0),
stringsAsFactors = FALSE)
sd_df <- data.frame(column = character(0), method = character(0),
sd_retained_pct = numeric(0), stringsAsFactors = FALSE)
n_complete_num <- NA_integer_; num_retention_pct <- NA_real_
worst_mean_shift <- NULL; worst_corr_shift <- NULL
mean_sd_retention <- NA_real_; cc_basis_note <- ""
if (est_ok) {
Xa <- do.call(cbind, lapply(num_cols, function(cc) num_vals[[cc]]))
colnames(Xa) <- num_cols
cc_mask <- stats::complete.cases(Xa)
n_complete_num <- as.integer(sum(cc_mask))
num_retention_pct <- 100 * n_complete_num / n
Xmean <- Xa
for (j in seq_len(ncol(Xmean))) {
mu <- mean(Xmean[, j], na.rm = TRUE)
if (!is.finite(mu)) mu <- 0
Xmean[is.na(Xmean[, j]), j] <- mu
}
Xreg <- .regression_impute(Xa)
est_rows <- list()
for (j in seq_along(num_cols)) {
cc <- num_cols[j]
cc_v <- if (n_complete_num >= 3) mean(Xa[cc_mask, j]) else NA_real_
av_v <- mean(Xa[, j], na.rm = TRUE)
mi_v <- mean(Xmean[, j])
ri_v <- mean(Xreg[, j])
vals <- c(cc_v, av_v, mi_v, ri_v)
est_rows[[length(est_rows) + 1]] <- data.frame(
quantity = paste0("Mean of ", unname(disp[cc])),
complete_case = round(cc_v, 4), available_case = round(av_v, 4),
mean_imputed = round(mi_v, 4), regression_imputed = round(ri_v, 4),
spread = round(max(vals, na.rm = TRUE) - min(vals, na.rm = TRUE), 4),
stringsAsFactors = FALSE)
}
corr_cols <- if (length(num_cols) > 8) num_cols[seq_len(8)] else num_cols
if (length(corr_cols) >= 2) {
for (i in seq_len(length(corr_cols) - 1)) {
for (j2 in (i + 1):length(corr_cols)) {
ai <- match(corr_cols[i], num_cols); bi <- match(corr_cols[j2], num_cols)
cc_r <- if (n_complete_num >= 5)
suppressWarnings(stats::cor(Xa[cc_mask, ai], Xa[cc_mask, bi])) else NA_real_
av_r <- suppressWarnings(stats::cor(Xa[, ai], Xa[, bi], use = "pairwise.complete.obs"))
mi_r <- suppressWarnings(stats::cor(Xmean[, ai], Xmean[, bi]))
ri_r <- suppressWarnings(stats::cor(Xreg[, ai], Xreg[, bi]))
vals <- c(cc_r, av_r, mi_r, ri_r)
if (all(!is.finite(vals))) next
est_rows[[length(est_rows) + 1]] <- data.frame(
quantity = paste0("Correlation ", unname(disp[corr_cols[i]]), " with ",
unname(disp[corr_cols[j2]])),
complete_case = round(cc_r, 4), available_case = round(av_r, 4),
mean_imputed = round(mi_r, 4), regression_imputed = round(ri_r, 4),
spread = round(max(vals, na.rm = TRUE) - min(vals, na.rm = TRUE), 4),
stringsAsFactors = FALSE)
}
}
}
estimates_df <- do.call(rbind, est_rows)
rownames(estimates_df) <- NULLSpread of the mean estimates expressed in standard deviations, so it is comparable across columns on different scales.
mean_rows <- estimates_df[grepl("^Mean of ", estimates_df$quantity), , drop = FALSE]
if (nrow(mean_rows) > 0) {
sd_ref <- vapply(num_cols, function(cc) stats::sd(num_vals[[cc]], na.rm = TRUE), numeric(1))
in_sd <- mean_rows$spread / ifelse(is.finite(sd_ref) & sd_ref > 0, sd_ref, NA_real_)
okk <- which(is.finite(in_sd))
if (length(okk) > 0) {
w <- okk[which.max(in_sd[okk])]
worst_mean_shift <- list(quantity = mean_rows$quantity[w], spread = mean_rows$spread[w],
in_sd = in_sd[w])
}
}
corr_rows <- estimates_df[grepl("^Correlation ", estimates_df$quantity), , drop = FALSE]
if (nrow(corr_rows) > 0) {
okk <- which(is.finite(corr_rows$spread))
if (length(okk) > 0) {
w <- okk[which.max(corr_rows$spread[okk])]
worst_corr_shift <- corr_rows[w, ]
}
}Variance retention, the concrete cost of mean imputation.
sd_rows <- list()
for (j in seq_along(num_cols)) {
cc <- num_cols[j]
base <- stats::sd(Xa[, j], na.rm = TRUE)
if (!is.finite(base) || base <= 0) next
s_cc <- if (n_complete_num >= 3) stats::sd(Xa[cc_mask, j]) else NA_real_
s_mi <- stats::sd(Xmean[, j])
s_ri <- stats::sd(Xreg[, j])
for (pr in list(c("Complete case", s_cc), c("Mean imputation", s_mi),
c("Regression imputation", s_ri))) {
val <- as.numeric(pr[2])
if (!is.finite(val)) next
sd_rows[[length(sd_rows) + 1]] <- data.frame(
column = unname(disp[cc]), method = pr[1],
sd_retained_pct = round(100 * val / base, 2), stringsAsFactors = FALSE)
}
}
if (length(sd_rows) > 0) {
sd_df <- do.call(rbind, sd_rows)
rownames(sd_df) <- NULL
mi_rows <- sd_df[sd_df$method == "Mean imputation", , drop = FALSE]
if (nrow(mi_rows) > 0) mean_sd_retention <- min(mi_rows$sd_retained_pct)
}
cc_basis_note <- sprintf(
paste0("Complete-case figures below drop every row missing any of the %d numeric ",
"columns in this comparison(%s), which leaves %s of %s rows."),
length(num_cols), paste(unname(disp[num_cols]), collapse = ", "),
.fmt_n(n_complete_num), .fmt_n(n))
}
final_rows <- n
rows_removed <- 0L
metrics <- list(
`Rows` = n,
`Columns Audited` = k,
`Cells Missing %` = round(overall_miss_pct, 2),
`Columns With Missing Values`= as.integer(n_cols_with_missing),
`Worst Column` = worst_col,
`Worst Column Missing %` = round(worst_pct, 2),
`Complete Rows` = n_complete_all,
`Complete-Case Retention %` = round(retention_pct, 2),
`Missingness Patterns` = as.integer(n_patterns),
`Little MCAR p-value` = if (isTRUE(mcar$ok)) .fmt_p(mcar$p_value) else "not computed",
`MCAR Verdict` = mcar_verdict
)
answer <- paste0(
.fmt_pct(overall_miss_pct), " of the cells across the ", k, " columns audited are missing, ",
"spread over ", .fmt_n(n_patterns), " distinct missingness pattern(s). ",
if (n_cols_with_missing == 0)
"No column has a single missing value, so nothing here can bias an estimate through missingness. "
else paste0(
worst_col, " is worst at ", .fmt_pct(worst_pct), " missing. ",
"Listwise deletion across all mapped columns would leave ", .fmt_n(n_complete_all),
" of ", .fmt_n(n), " rows(", .fmt_pct(retention_pct), "). "),
if (isTRUE(mcar$ok))
paste0("Little's MCAR test gives chi-square ", .fmt_f(mcar$statistic, 1), " on ",
.fmt_n(mcar$df), " degrees of freedom, p ", .fmt_p(mcar$p_value), ", so MCAR is ",
if (mcar$p_value < 0.05) "rejected" else "not rejected", " at the 5 percent level. ")
else paste0("Little's MCAR test was not computed: ", mcar$reason, ". "),
if (!is.null(worst_mean_shift))
paste0("The handling rule moves the estimates: ", tolower(worst_mean_shift$quantity),
" spans ", .fmt_f(worst_mean_shift$spread, 3), " across the four rules, which is ",
.fmt_f(worst_mean_shift$in_sd, 2), " standard deviations. ") else "",
"MCAR, MAR and MNAR cannot be fully separated from the data: a test can reject MCAR, ",
"but nothing in a dataset can confirm MAR over MNAR, because MNAR means the missingness ",
"depends on the values you cannot see."
)
json_output <- list(
answer = answer,
cards = lapply(
c("tldr", "overview", "preprocessing", "missing_by_column", "missing_patterns",
"cooccurrence", "mcar_test", "mar_evidence", "estimate_impact", "variance_impact"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows, rows_removed = rows_removed,
disp = disp, audit_cols = audit_cols, test_cols = test_cols, excluded_cols = excluded_cols,
excl_reason = excl_reason,
num_cols = num_cols, col_type = col_type, n_missing = n_missing, n_observed = n_observed,
n = n, k = k,
total_missing_cells = total_missing_cells, total_cells = total_cells,
overall_miss_pct = overall_miss_pct, n_cols_with_missing = n_cols_with_missing,
worst_col = worst_col, worst_pct = worst_pct,
miss_by_col_df = miss_by_col_df, patterns_df = patterns_df, cooccur_df = cooccur_df,
n_patterns = n_patterns, n_complete_all = n_complete_all, retention_pct = retention_pct,
top_missing_pattern = top_missing_pattern, top_lift = top_lift,
mcar = mcar, mcar_df = mcar_df, mcar_verdict = mcar_verdict,
mar_df = mar_df, mar_all = mar_all, n_mar_tests = n_mar_tests, n_mar_sig = n_mar_sig,
top_mar = top_mar, structural_notes = structural_notes,
estimates_df = estimates_df, sd_df = sd_df, est_ok = est_ok,
n_complete_num = n_complete_num, num_retention_pct = num_retention_pct,
worst_mean_shift = worst_mean_shift, worst_corr_shift = worst_corr_shift,
mean_sd_retention = mean_sd_retention, cc_basis_note = cc_basis_note,
metrics = metrics, json_output = json_output
)
}Only columns that actually have gaps say anything about imputation.
gap_names <- unname(shared$disp[shared$num_cols[shared$n_missing[shared$num_cols] > 0]])
mi_g <- mi[mi$column %in% gap_names, , drop = FALSE]
ri_g <- ri[ri$column %in% gap_names, , drop = FALSE]
reg_txt <- if (nrow(ri_g) > 0 && nrow(mi_g) > 0) {
med_ri <- stats::median(ri_g$sd_retained_pct); med_mi <- stats::median(mi_g$sd_retained_pct)
paste0("Across the ", nrow(mi_g), " column(s) that actually have gaps, regression ",
"imputation keeps ", .fmt_pct(med_ri), " of the spread at the median column ",
"against ", .fmt_pct(med_mi), " for mean imputation",
if (med_ri > med_mi)
" — better, but still short of 100 percent, because fitted values carry no residual scatter. "
else " here, which is unusual and worth checking against the fit. ")
} else ""
cc_txt <- {
cc <- sd_df[sd_df$method == "Complete case", , drop = FALSE]
if (nrow(cc) > 0 && max(abs(cc$sd_retained_pct - 100)) > 0.5) {
far <- cc[which.max(abs(cc$sd_retained_pct - 100)), ]
paste0("Complete-case deletion changes the spread too: on ", far$column,
" the surviving rows retain ", .fmt_pct(far$sd_retained_pct),
" of the observed spread, which is a selection effect even on a column with ",
"no missing values of its own. ")
} else ""
}
list(
title = "What Imputation Does To The Spread",
description = "Standard deviation retained under each rule, as a percent of the observed spread.",
text = paste0(
"Each bar is one column's standard deviation under one handling rule, as a percent of ",
"the standard deviation of that column's observed values. 100 percent means the spread ",
"is preserved. ", worst_txt, reg_txt, cc_txt,
"This is why a mean-imputed dataset produces confidence intervals that are too narrow ",
"and correlations pulled toward zero: the imputed rows carry no variation of their own."
),
chart_labels = list(column = "Column", sd_retained_pct = "Percent of observed spread kept",
method = "Handling rule"),
data = list(sd_retention = sd_df)
)
}