Standard Missing Data
Executive Summary

Executive Summary

Missingness, mechanism evidence, and how much the handling rule moves the answer

Rows
119390
Columns Audited
15
Cells Missing %
7.23
Columns With Missing Values
4
Worst Column
company
Worst Column Missing %
94.31
Complete Rows
217
Complete-Case Retention %
0.18
Missingness Patterns
9
Little MCAR p-value
< 0.001
MCAR Verdict
MCAR rejected
Missingness is material: 7.2% of cells, concentrated in 4 of 15 columns, with company worst at 94.3%. Listwise deletion across every mapped column would keep 217 of 119,390 rows — 0.2% retained, 99.8% discarded. The most common incomplete pattern is company, covering 102,677 rows (86.0%). Little's test of MCAR gives p < 0.001 on 119,390 rows, so the hypothesis that values went missing completely at random is rejected — the missingness is systematic in some way. 39 of 42 association tests survive a Holm correction: strongest is that country is missing for 3.5% of rows where market segment is Corporate, against 0.0% where it is Aviation (Chi-square test of independence, p < 0.001). That is evidence the missingness depends on something you can see. The handling rule is not neutral: mean of agent moves by 115.924 (1.05 standard deviations) depending on whether you delete, mean-impute, or regress. Mean imputation keeps only 23.9% of the spread of the worst-affected column: filling every gap with one number shrinks variance and pulls correlations toward zero. MCAR, MAR and MNAR cannot be fully told apart from the data. A test can reject the MCAR hypothesis, and an association between missingness and an observed column is evidence for MAR, but nothing in the dataset can confirm MAR over MNAR — MNAR means the chance of a value being missing depends on that missing value itself, which by definition was not recorded.
What this means

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.

Overview

Analysis Overview

Missingness audit across 15 columns and 119,390 rows.

N Rows119390
N Columns15
Missing Cells Pct7.23
N Patterns9
What this means

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 Preparation

Data Quality

How cells were classified, and which columns carry the statistical tests.

Initial Rows119390
Final Rows119390
Rows Removed0
What this means

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.

Visualization

Missing Data by Column

Share of rows with a missing value, per mapped column.

What this means

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.

Data Table

Missingness Patterns

The distinct combinations of columns that go missing together.

PatternColumns MissingN RowsPCT Of RowsN Columns Missing
000000000010000company102677861
000000000110000agent + company95988.042
000000000100000agent64065.371
000000000000000(complete row — nothing missing)2170.180
000000100100000country + agent1740.152
000000100110000country + agent + company1600.133
000000100010000country + company1540.132
000000000010100company + children202
000000000110100agent + company + children203
What this means

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.

Visualization

Co-Missingness Map

Percent of all rows in which each pair of columns is missing together.

What this means

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.

Data Table

Little's MCAR Test

A direct test of the hypothesis that values went missing completely at random.

ItemValue
Little's chi-square statistic9,183.27
Degrees of freedom26
p-value< 0.001
Rows used119,390
Numeric columns tested7
Missingness patterns compared6
EM iterations to converge300
Verdict at the 5 percent levelMCAR rejected
What this means

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.

Data Table

Is Missingness Related To What You Can See?

Per-column tests of missingness against the values of the other columns.

Missing InTested AgainstTestStatisticEffectP ValueP AdjustedDirection
countrymarket segmentChi-square test of independence16470.1175< 0.001< 0.001country is missing for 3.5% of rows where market segment is Corporate, against 0.0% where it is Aviation
countryreserved room typeChi-square test of independence20810.132< 0.001< 0.001country is missing for 83.3% of rows where reserved room type is P, against 0.0% where it is B
agenthotelChi-square test of independence23640.1407< 0.001< 0.001agent is missing for 20.5% of rows where hotel is Resort Hotel, against 10.2% where it is City Hotel
agentlead timeWelch t-test on group means-73.56-55.97< 0.001< 0.001mean lead time is lower when agent is missing (55.697) than when it is present (111.672)
agentarrival date monthChi-square test of independence18770.1254< 0.001< 0.001agent is missing for 20.0% of rows where arrival date month is Other, against 8.3% where it is July
agentmealChi-square test of independence21260.1334< 0.001< 0.001agent is missing for 28.9% of rows where meal is Undefined, against 3.0% where it is SC
agentcountryChi-square test of independence61430.2273< 0.001< 0.001agent is missing for 22.6% of rows where country is PRT, against 4.0% where it is DEU
agentmarket segmentChi-square test of independence5.275e+040.6647< 0.001< 0.001agent is missing for 100.0% of rows where market segment is Undefined, against 0.6% where it is Online TA
agentdistribution channelChi-square test of independence5.451e+040.6757< 0.001< 0.001agent is missing for 83.0% of rows where distribution channel is Corporate, against 1.0% where it is GDS
agentadrWelch t-test on group means-48.26-20.37< 0.001< 0.001mean adr is lower when agent is missing (84.250) than when it is present (104.619)
companyis canceledWelch t-test on group means42.820.207< 0.001< 0.001mean is canceled is higher when company is missing (0.382) than when it is present (0.175)
companylead timeWelch t-test on group means54.9258.24< 0.001< 0.001mean lead time is higher when company is missing (107.327) than when it is present (49.088)
companymealChi-square test of independence15300.1132< 0.001< 0.001company is missing for 99.4% of rows where meal is SC, against 92.9% where it is BB
companycountryChi-square test of independence25430.1462< 0.001< 0.001company is missing for 99.3% of rows where country is BRA, against 90.7% where it is PRT
companymarket segmentChi-square test of independence7.099e+040.7711< 0.001< 0.001company is missing for 100.0% of rows where market segment is Undefined, against 10.5% where it is Aviation
What this means

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.

Data Table

How Much The Handling Rule Moves The Answer

The same means and correlations recomputed under four handling rules.

QuantityComplete CaseAvailable CaseMean ImputedRegression ImputedSpread
Mean of is canceled0.07830.37040.37040.37040.2921
Mean of lead time40.5210410410463.49
Mean of arrival date year20152016201620160.6911
Mean of agent202.686.6986.6987.62115.9
Mean of company262.1189.3189.3209.372.84
Mean of adr63.79101.8101.8101.838.04
Mean of children0.03690.10390.10390.10390.067
Correlation is canceled with lead time-0.03980.29310.29310.29310.3329
Correlation is canceled with arrival date year-0.06950.01670.01670.01670.0862
Correlation is canceled with agent-0.0345-0.0831-0.078-0.08790.0535
Correlation is canceled with company-0.0438-0.0206-0.0039-0.03650.0399
Correlation is canceled with adr-0.05710.04760.04760.04760.1046
Correlation is canceled with children0.02760.0050.0050.0050.0225
Correlation lead time with arrival date year0.23210.04010.04010.04010.192
Correlation lead time with agent-0.3698-0.0697-0.0653-0.0690.3045
Correlation lead time with company0.24960.15150.02820.25350.2253
Correlation lead time with adr0.2401-0.0631-0.0631-0.06310.3032
Correlation lead time with children-0.0612-0.0376-0.0376-0.03760.0236
Correlation arrival date year with agent-0.17510.06350.05890.08180.2569
Correlation arrival date year with company-0.03790.25910.06240.47340.5113
Correlation arrival date year with adr0.49530.19760.19760.19760.2977
Correlation arrival date year with children0.14570.05460.05460.05460.0911
Correlation agent with company0.35070.35070.0070.83390.8269
Correlation agent with adr-0.425-0.0247-0.0227-0.03030.4023
Correlation agent with children-0.07190.04110.03940.04090.1129
Correlation company with adr-0.04170.08640.01390.17270.2144
Correlation company with children0.06430.03090.00290.15410.1512
Correlation adr with children0.39710.32490.32480.32490.0722
What this means

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.

Visualization

What Imputation Does To The Spread

Standard deviation retained under each rule, as a percent of the observed spread.

What this means

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.

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

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 0

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

Step 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&#x27;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&#x27;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) <- NULL

Spread 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&#x27;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&#x27;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&#x27;s standard deviation under one handling rule, as a percent of ",
      "the standard deviation of that column&#x27;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)
  )
}
Your data has more stories to tell.Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

Report an Issue

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

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