Executive Summary
Missingness, mechanism evidence, and how much the handling rule moves the answer
The short answer
Missingness is not random: 7.23% of cells are missing, mostly in company (94.31%), and the pattern is predictable from other variables you can see. Deleting incomplete rows costs 99.8% of the dataset. Mean imputation shrinks variance to 23.9% of its original spread, distorting correlations.
The detail
7.23% of cells are missing across 119,390 rows and 15 columns. Four columns carry all the gaps: company (94.31%), agent (13.69%), country (0.41%), and children (0%). Complete-case retention is 217 rows (0.18%). Little's MCAR test: p < 0.001, rejecting random missingness. The most common incomplete pattern is company alone, affecting 102,677 rows (86.0%). Of 42 association tests, 39 survive Holm correction: strongest is country missing for 3.5% of Corporate market segment rows versus 0.0% of Aviation rows (p < 0.001). Mean of agent shifts by 115.924 (1.05 standard deviations) across deletion, mean imputation, and regression handling. Mean imputation preserves only 23.9% of the spread in the worst-affected column.
What this can't tell you
Whether missingness depends on the missing values themselves (MNAR) cannot be answered from the data. Associations with observed columns support MAR, but MNAR is observationally equivalent for the unobserved gaps.
Analysis Overview
Missingness audit across 15 columns and 119,390 rows.
The short answer
Missingness in this bookings export is material and concentrated: 7.2% of cells across 4 of 15 columns, with company absent from 94.3% of rows. The pattern is dominated by a single cause—company missing alone accounts for 86% of all incomplete rows—suggesting a structural reason (field not applicable or not collected for certain booking types) rather than random non-response.
The detail
The audit covers 119,390 rows and 15 columns; 129,425 of 1,790,850 cells are missing. Four columns carry all the gaps: company (112,593 rows, 94.31%), agent (16,340 rows, 13.69%), country (488 rows, 0.41%), and children (4 rows, 0%). The complete-case count is stark: only 217 rows (0.18%) survive listwise deletion across all 15 mapped columns. The pattern table reveals 9 distinct combinations, with company alone dominating at 102,677 rows (86.0%), followed by agent + company together at 9,598 rows (8.04%). This concentration in one or two fields is the key signal: if missingness were scattered across many column pairs, it would look like sporadic non-response; instead, the shape points toward a single upstream cause.
What this can't tell you
The data cannot distinguish whether the missingness is completely random (MCAR), depends on other observed values (MAR), or depends on the missing values themselves (MNAR). A test can reject one hypothesis and an association test can provide evidence for another, but the structure of the data alone cannot confirm any mechanism.
Data Quality
How cells were classified, and which columns carry the statistical tests.
The short answer
All 119,390 rows are retained in this analysis; no rows are dropped because measuring missingness is the point. A cell counts as missing when it is empty, blank, or explicitly null. Of the 15 mapped columns, 7 numeric ones (is canceled, lead time, arrival date year, agent, company, adr, children) drive the statistical tests; categorical columns are audited for missingness but are used only in association tests, not in estimate comparisons.
The detail
The dataset contains 119,390 rows and 15 columns with 1,790,850 total cells; 129,425 cells (7.2%) are missing. Every mapped column carries observed variation and is included in full. No rows are removed because row deletion is the very phenomenon being measured. The 7 numeric columns are used to compute Little's MCAR test and to re-estimate means and correlations under four handling rules (complete case, available case, mean imputation, regression imputation). Categorical columns (hotel, is canceled, meal, country, market segment, distribution channel, reserved room type, deposit type, arrival date month, arrival date year) are audited for missing counts and used in Chi-square tests of association between missingness and observed values.
What this can't tell you
This analysis does not model the missing values themselves or estimate what the missing entries would have been. The comparison of estimates under different handling rules shows how sensitive conclusions are to the choice of rule, but does not identify which rule is correct.
Missing Data by Column
Share of rows with a missing value, per mapped column.
The short answer
Four of 15 columns have gaps. Company is the clear outlier at 94.31% missing (112,593 rows), followed by agent at 13.69% (16,340 rows). Country has only 488 missing rows (0.41%). The other 11 columns are complete. Per-column rates do not reveal whether the same rows are affected—that pattern is shown separately.
The detail
Missing values by column: company 94.31% (n=112,593), agent 13.69% (n=16,340), country 0.41% (n=488), children 0% (n=4). Eleven columns are complete: adr, arrival date month, arrival date year, deposit type, distribution channel, hotel, is canceled, lead time, market segment, meal, reserved room type. A per-column rate describes how much of one variable is missing; it says nothing about whether the same rows are affected. That is determined by the pattern card.
What this can't tell you
Whether the same rows drive all four columns' missingness or whether different rows are missing different fields. The cooccurrence and pattern cards address that.
Missingness Patterns
The distinct combinations of columns that go missing together.
| Pattern | Columns Missing | N Rows | PCT Of Rows | N Columns Missing |
|---|---|---|---|---|
| 000000000010000 | company | 102677 | 86 | 1 |
| 000000000110000 | agent + company | 9598 | 8.04 | 2 |
| 000000000100000 | agent | 6406 | 5.37 | 1 |
| 000000000000000 | (complete row — nothing missing) | 217 | 0.18 | 0 |
| 000000100100000 | country + agent | 174 | 0.15 | 2 |
| 000000100110000 | country + agent + company | 160 | 0.13 | 3 |
| 000000100010000 | country + company | 154 | 0.13 | 2 |
| 000000000010100 | company + children | 2 | 0 | 2 |
| 000000000110100 | agent + company + children | 2 | 0 | 3 |
The short answer
Nine distinct patterns of joint missingness occur. One pattern dominates: company missing alone accounts for 102,677 rows (86.0%). The second largest is agent + company together at 9,598 rows (8.04%). The remaining patterns are scattered, with the largest covering only 174 rows. This concentration in one dominant pattern suggests a structural cause—a field that does not apply to certain booking types—rather than random non-response scattered across many column pairs.
The detail
The 9 patterns, ranked by frequency:
1. company alone: 102,677 rows (86.0%)
2. agent + company: 9,598 rows (8.04%)
3. agent alone: 6,406 rows (5.37%)
4. Complete (nothing missing): 217 rows (0.18%)
5. country + agent: 174 rows (0.15%)
6. country + agent + company: 160 rows (0.13%)
7. country + company: 154 rows (0.13%)
8. company + children: 2 rows (0%)
9. agent + company + children: 2 rows (0%)
The shape of this distribution is diagnostic. A single dominant pattern that lines up with a categorical field usually means the field did not apply to those records. Here, company missing in 86% of rows with no other gaps suggests company is not collected for direct bookings or a major booking channel. The remaining 14% of incomplete rows are scattered across six other patterns, none large. This is consistent with a primary structural cause (company) plus secondary sporadic gaps (country, agent, children).
What this can't tell you
The data cannot confirm why company is missing for 86% of rows. A structural reason (the field does not apply) is more likely than random non-response, but the data alone does not prove it. Linking to booking channel, source system, or booking method would clarify whether the missingness is truly structural.
Co-Missingness Map
Percent of all rows in which each pair of columns is missing together.
The short answer
Missingness is not independent across columns—some pairs fail together far more often than chance would predict. Country and agent co-occur missing 334 times, versus 66.8 expected if independent, a 5.00-to-1 ratio. This clustering suggests shared upstream causes, like a skipped form section or failed database join.
The detail
Co-missingness is strongest between country and agent: observed 334 rows with both missing, expected 66.8 under independence, ratio 5.00. This coupling is evidence of a common failure mode. Blocks of coupled missingness matter because listwise deletion removes entire blocks at once. The cooccurrence map shows the diagonal (each column paired with itself) equals that column's own missing rate; off-diagonal cells reveal pairs that fail together more than random overlap would produce.
What this can't tell you
The upstream cause of the coupling—whether it is a form skip, a join failure, a data collection protocol, or something else—cannot be inferred from cooccurrence patterns alone. Consider inspecting the raw data where both country and agent are missing to trace the source.
Little's MCAR Test
A direct test of the hypothesis that values went missing completely at random.
| Item | Value |
|---|---|
| Little's chi-square statistic | 9,183.27 |
| Degrees of freedom | 26 |
| p-value | < 0.001 |
| Rows used | 119,390 |
| Numeric columns tested | 7 |
| Missingness patterns compared | 6 |
| EM iterations to converge | 300 |
| Verdict at the 5 percent level | MCAR rejected |
The short answer
Little's test rejects MCAR at p < 0.001: the rows with missing values differ systematically from the rows without them on the numeric columns tested. With 119,390 rows, the test is powerful enough to detect differences too small to change any decision, so the rejection is real—but the test also assumes joint normality of the numeric columns, and heavy skew inflates it.
The detail
Little's chi-square statistic is 9,183.27 on 26 degrees of freedom, based on 6 missingness patterns and 7 numeric columns (is canceled, lead time, arrival date year, agent, company, adr, children). The test compares the observed means within each pattern against the maximum-likelihood means estimated from all the data by EM. Under MCAR those differences are noise; here they are large enough to reject at the 5 percent level (p < 0.001). The test used 119,390 rows and converged in 300 EM iterations. The verdict: MCAR is rejected. At this sample size, the test has high power to detect even small differences. The test also assumes the numeric columns are jointly normal within each pattern; departure from normality (skew, heavy tails) inflates the chi-square statistic, so the rejection may be partly due to non-normality rather than a true violation of MCAR. However, the practical evidence from the association tests (see mar_evidence) confirms that missingness is predictable from observed values.
What this can't tell you
Rejection of MCAR does not identify whether the missingness is MAR (depends on observed values) or MNAR (depends on the missing values themselves). The association tests provide evidence for MAR, but nothing in the dataset can rule out MNAR.
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 |
|---|---|---|---|---|---|---|---|
| country | market segment | Chi-square test of independence | 1647 | 0.1175 | < 0.001 | < 0.001 | country is missing for 3.5% of rows where market segment is Corporate, against 0.0% where it is Aviation |
| country | reserved room type | Chi-square test of independence | 2081 | 0.132 | < 0.001 | < 0.001 | country is missing for 83.3% of rows where reserved room type is P, against 0.0% where it is B |
| agent | hotel | Chi-square test of independence | 2364 | 0.1407 | < 0.001 | < 0.001 | agent is missing for 20.5% of rows where hotel is Resort Hotel, against 10.2% where it is City Hotel |
| agent | lead time | Welch t-test on group means | -73.56 | -55.97 | < 0.001 | < 0.001 | mean lead time is lower when agent is missing (55.697) than when it is present (111.672) |
| agent | arrival date month | Chi-square test of independence | 1877 | 0.1254 | < 0.001 | < 0.001 | agent is missing for 20.0% of rows where arrival date month is Other, against 8.3% where it is July |
| agent | meal | Chi-square test of independence | 2126 | 0.1334 | < 0.001 | < 0.001 | agent is missing for 28.9% of rows where meal is Undefined, against 3.0% where it is SC |
| agent | country | Chi-square test of independence | 6143 | 0.2273 | < 0.001 | < 0.001 | agent is missing for 22.6% of rows where country is PRT, against 4.0% where it is DEU |
| agent | market segment | Chi-square test of independence | 5.275e+04 | 0.6647 | < 0.001 | < 0.001 | agent is missing for 100.0% of rows where market segment is Undefined, against 0.6% where it is Online TA |
| agent | distribution channel | Chi-square test of independence | 5.451e+04 | 0.6757 | < 0.001 | < 0.001 | agent is missing for 83.0% of rows where distribution channel is Corporate, against 1.0% where it is GDS |
| agent | adr | Welch t-test on group means | -48.26 | -20.37 | < 0.001 | < 0.001 | mean adr is lower when agent is missing (84.250) than when it is present (104.619) |
| company | is canceled | Welch t-test on group means | 42.82 | 0.207 | < 0.001 | < 0.001 | mean is canceled is higher when company is missing (0.382) than when it is present (0.175) |
| company | lead time | Welch t-test on group means | 54.92 | 58.24 | < 0.001 | < 0.001 | mean lead time is higher when company is missing (107.327) than when it is present (49.088) |
| company | meal | Chi-square test of independence | 1530 | 0.1132 | < 0.001 | < 0.001 | company is missing for 99.4% of rows where meal is SC, against 92.9% where it is BB |
| company | country | Chi-square test of independence | 2543 | 0.1462 | < 0.001 | < 0.001 | company is missing for 99.3% of rows where country is BRA, against 90.7% where it is PRT |
| company | market segment | Chi-square test of independence | 7.099e+04 | 0.7711 | < 0.001 | < 0.001 | company is missing for 100.0% of rows where market segment is Undefined, against 10.5% where it is Aviation |
The short answer
Missingness is predictable from other columns you can see, not random. Country is missing for 3.5% of Corporate market segment rows but 0.0% of Aviation rows. Agent is missing for 20.5% of Resort Hotel rows but 10.2% of City Hotel rows. These associations survive multiple-testing correction and are evidence that a model using observed data can partly account for the gaps.
The detail
Of 42 association tests, 39 survive Holm correction for multiple comparisons. Strongest associations: (1) country missing by market segment (Chi-square, p < 0.001, Holm-adjusted p < 0.001): 3.5% missing where Corporate, 0.0% where Aviation; (2) country missing by reserved room type (Chi-square, p < 0.001): 83.3% missing where type is P, 0.0% where type is B; (3) agent missing by hotel (Chi-square, p < 0.001): 20.5% missing for Resort Hotel, 10.2% for City Hotel; (4) agent missing by lead time (Welch t-test, p < 0.001): mean lead time 55.697 when agent missing, 111.672 when present. These patterns are evidence for MAR—missingness depends on observed values.
What this can't tell you
MCAR, MAR, and MNAR cannot be fully separated from data alone. An association with an observed column is evidence for MAR, but MNAR (where missingness depends on the unobserved value itself) is consistent with any observed pattern. A finding of no association is equally consistent with MCAR and MNAR.
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 is canceled | 0.0783 | 0.3704 | 0.3704 | 0.3704 | 0.2921 |
| Mean of lead time | 40.52 | 104 | 104 | 104 | 63.49 |
| Mean of arrival date year | 2015 | 2016 | 2016 | 2016 | 0.6911 |
| Mean of agent | 202.6 | 86.69 | 86.69 | 87.62 | 115.9 |
| Mean of company | 262.1 | 189.3 | 189.3 | 209.3 | 72.84 |
| Mean of adr | 63.79 | 101.8 | 101.8 | 101.8 | 38.04 |
| Mean of children | 0.0369 | 0.1039 | 0.1039 | 0.1039 | 0.067 |
| Correlation is canceled with lead time | -0.0398 | 0.2931 | 0.2931 | 0.2931 | 0.3329 |
| Correlation is canceled with arrival date year | -0.0695 | 0.0167 | 0.0167 | 0.0167 | 0.0862 |
| Correlation is canceled with agent | -0.0345 | -0.0831 | -0.078 | -0.0879 | 0.0535 |
| Correlation is canceled with company | -0.0438 | -0.0206 | -0.0039 | -0.0365 | 0.0399 |
| Correlation is canceled with adr | -0.0571 | 0.0476 | 0.0476 | 0.0476 | 0.1046 |
| Correlation is canceled with children | 0.0276 | 0.005 | 0.005 | 0.005 | 0.0225 |
| Correlation lead time with arrival date year | 0.2321 | 0.0401 | 0.0401 | 0.0401 | 0.192 |
| Correlation lead time with agent | -0.3698 | -0.0697 | -0.0653 | -0.069 | 0.3045 |
| Correlation lead time with company | 0.2496 | 0.1515 | 0.0282 | 0.2535 | 0.2253 |
| Correlation lead time with adr | 0.2401 | -0.0631 | -0.0631 | -0.0631 | 0.3032 |
| Correlation lead time with children | -0.0612 | -0.0376 | -0.0376 | -0.0376 | 0.0236 |
| Correlation arrival date year with agent | -0.1751 | 0.0635 | 0.0589 | 0.0818 | 0.2569 |
| Correlation arrival date year with company | -0.0379 | 0.2591 | 0.0624 | 0.4734 | 0.5113 |
| Correlation arrival date year with adr | 0.4953 | 0.1976 | 0.1976 | 0.1976 | 0.2977 |
| Correlation arrival date year with children | 0.1457 | 0.0546 | 0.0546 | 0.0546 | 0.0911 |
| Correlation agent with company | 0.3507 | 0.3507 | 0.007 | 0.8339 | 0.8269 |
| Correlation agent with adr | -0.425 | -0.0247 | -0.0227 | -0.0303 | 0.4023 |
| Correlation agent with children | -0.0719 | 0.0411 | 0.0394 | 0.0409 | 0.1129 |
| Correlation company with adr | -0.0417 | 0.0864 | 0.0139 | 0.1727 | 0.2144 |
| Correlation company with children | 0.0643 | 0.0309 | 0.0029 | 0.1541 | 0.1512 |
| Correlation adr with children | 0.3971 | 0.3249 | 0.3248 | 0.3249 | 0.0722 |
Complete case drops any row missing one of the numeric columns; available case uses every observed value for each figure separately; mean imputation fills each gap with that column's observed mean; regression imputation predicts each gap from the other numeric columns with a linear fit. Complete-case figures below drop every row missing any of the 7 numeric columns in this comparison (is canceled, lead time, arrival date year, agent, company, adr, children), which leaves 217 of 119,390 rows. The widest disagreement among the means is mean of agent, which spans 115.9241 across the four rules — 1.05 standard deviations of that column. Among the correlations, correlation agent with company ranges from 0.007 to 0.834 depending on the rule. Mean imputation gives 0.007 against 0.351 on the observed pairs alone, the attenuation toward zero that mean imputation always produces. Mean imputation reproduces the observed mean exactly by construction — it can never correct a bias in that mean, only hide the gap. Regression imputation can correct one, but only for the part of the missingness explained by the columns it uses, and because it fills in fitted values with no residual it still understates the true uncertainty. Treat the spread column as a lower bound on how much this choice matters.
What Imputation Does To The Spread
Standard deviation retained under each rule, as a percent of the observed spread.
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. Mean imputation is worst on company, where it keeps 23.9% of the observed standard deviation. That shortfall is not an accident of this dataset: filling a share of the rows with a single constant removes exactly that share of the squared deviations, so the retained spread follows the square root of the observed fraction. Across the 3 column(s) that actually have gaps, regression imputation keeps 95.5% of the spread at the median column against 92.9% for mean imputation — better, but still short of 100 percent, because fitted values carry no residual scatter. Complete-case deletion changes the spread too: on is canceled the surviving rows retain 55.8% of the observed spread, which is a selection effect even on a column with no missing values of its own. 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.
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)
)
}