Standard Proportions
Executive Summary

Executive Summary

Did the Yes rate really differ across SeniorCitizen?

Observations
7043
Rate
0 23.6% vs 1 41.7%
Difference (pp)
-18.08
P-value
< 0.001
Verdict
Significant difference
Test Used
two-sample proportion z-test
The Yes rate in Churn genuinely differs between the two SeniorCitizen groups: 0 runs at 23.6% vs 1 at 41.7% — 0 is 18.1 percentage points lower (two-sample proportion z-test p = < 0.001). The Newcombe 95% interval on the difference runs -21.15 to -15.04 percentage points. Statistical significance measures detectability, not importance: the observed gap is 18.1 percentage points (a relative change of 76.6% higher for 1 vs 0) — a sizeable gap in absolute terms. Whether a 18.1-point shift in Churn matters is a business judgment about stakes and volume, not a statistical one.
What this means

Senior citizens churn at a significantly higher rate than other customers: 41.7% vs 23.6%, a difference of 18.08 percentage points (p < 0.001, two-sample proportion z-test). The Newcombe 95% confidence interval on the difference spans -21.15 to -15.04 percentage points, confirming the gap is real. In relative terms, senior citizens' churn rate is 76.6% higher than non-senior citizens'. This is a statistically significant and sizeable absolute difference; whether it justifies specific business action depends on customer volume and retention economics.

Overview

Analysis Overview

Comparison of Yes rates in Churn between the SeniorCitizen groups 0 and 1 (7,043 observations).

N Observations7043
N Groups2
Difference Pp-18.08
What this means

This analysis compares churn rates between two customer groups — senior citizens (group 1) and non-senior citizens (group 0) — using a two-sample proportion z-test with a chi-square cross-check. Each group's rate carries a Wilson 95% confidence interval; the difference between them carries a Newcombe interval. The test identifies whether the observed gap in churn rates is real or explainable by sampling variation alone. 'Yes' in the Churn column is treated as the success outcome. All 7,043 rows were complete and both groups exceeded the minimum size threshold.

Data Preparation

Data Quality

Row cleaning, group filtering, and how the binary outcome was read.

Initial Rows7043
Final Rows7043
Rows Removed0
Groups Dropped0
What this means

All 7,043 rows loaded without missing values in either Churn or SeniorCitizen. No rows were removed and no groups were excluded. The outcome Churn was read as binary: 'Yes' = success (churn), 'No' = non-success (retention). The analysis compares exactly two groups: SeniorCitizen = 0 (n = 5,901) and SeniorCitizen = 1 (n = 1,142). The groups are noticeably unbalanced in size, with non-senior citizens outnumbering senior citizens by a factor of more than 5.

Visualization

Rates with Confidence Intervals

Per-group Yes rates with Wilson 95% confidence intervals.

What this means

Senior citizens (group 1) churn at 41.7% (Wilson 95% CI: 38.9% to 44.6%), while non-senior citizens (group 0) churn at 23.6% (CI: 22.5% to 24.7%). The confidence intervals do not overlap, a strong visual indicator that the gap is not due to sampling noise. The formal test confirms this: the two-sample proportion z-test yields p < 0.001, indicating the difference is detected at high confidence. The non-overlapping bars align with and reinforce the statistical verdict.

Data Table

Statistical Tests

z-test and chi-square cross-check on the Yes rate difference.

TestStatisticP ValueInterpretation
Two-sample proportion z-test-12.66< 0.001Tests whether the Yes rate differs between 0 and 1. The gap is larger than chance alone would plausibly produce.
Chi-square test (continuity-corrected)159.4< 0.001Cross-check on the same 2x2 table with the Yates continuity correction, which is slightly more conservative. The continuity-corrected chi-square cross-check agrees with the z-test verdict (p = < 0.001 vs p = < 0.001).
What this means

The two-sample proportion z-test statistic is -12.663 with p < 0.001. The continuity-corrected chi-square cross-check yields a statistic of 159.426 with p < 0.001, agreeing with the z-test verdict. Both tests confirm a significant difference in churn rates between the two groups. The smallest cell in the 2×2 contingency table is 476 (senior citizens who did not churn), well above the minimum threshold of 5, so the normal-approximation z-test is valid and carries the verdict.

Data Table

Group Counts & Rates

n, successes, failures, and Wilson 95% CI per SeniorCitizen group.

GroupNSuccessesFailuresRate PCTCI Low PCTCI High PCT
059011393450823.6122.5424.71
1114247666641.6838.8544.56
What this means

Non-senior citizens (group 0, n = 5,901) recorded 1,393 Yes (churn) and 4,508 No (retention), for a rate of 23.61% (CI: 22.54% to 24.71%). Senior citizens (group 1, n = 1,142) recorded 476 Yes and 666 No, for a rate of 41.68% (CI: 38.85% to 44.56%). The absolute gap is 18.08 percentage points; in relative terms, senior citizens' churn rate is 76.6% higher. The groups are substantially unbalanced (5,901 vs 1,142), which widens the confidence interval for the smaller group.

Data Table

Methods & Disclosure

How the rates, intervals, and verdict were computed.

ItemDetail
MethodTwo-sample comparison of proportions: z-test on the pooled standard error, continuity-corrected chi-square cross-check, Fisher's exact fallback for small cells
Success level'Yes' in Churn counts as a success; 'No' as a non-success. 'Yes' is a recognized affirmative value (like 1/TRUE/yes), so it was treated as the success level.
ComparisonDifference in Yes rate: 0 minus 1 = -18.08 percentage points
Per-group intervalsWilson score 95% confidence intervals (better small-sample and near-0/1 behavior than the naive Wald interval)
Difference intervalNewcombe hybrid 95% CI on the difference, built from the two Wilson intervals: -21.15 to -15.04 percentage points
Cell-count guardThe z-test requires every cell of the 2x2 table (successes and failures in each group) to be at least 5; the smallest cell here is 476, so the z-test is valid and used.
Test used for the verdicttwo-sample proportion z-test
Significance levelalpha = 0.05, two-sided
AssumptionsIndependent observations; each row is one trial; the two groups are independent samples
What this means

The short answer

Senior citizens (group 1) churn 18.1 percentage points more often than other customers (group 0)—a 76.6% relative increase. The 95% confidence interval around this difference is −21.15 to −15.04 percentage points, meaning the true gap likely falls within that range. This is a sizable, real-world difference, not merely a statistical artifact.

The detail

The difference in Yes (churn) rates is −18.08 percentage points (group 0 minus group 1). The Newcombe hybrid 95% confidence interval on this difference is −21.15 to −15.04 percentage points, built from Wilson score intervals for each group. The z-test carries the verdict; every cell of the 2×2 table exceeds 476 observations, satisfying the cell-count guard (minimum 5 per cell). Statistical significance measures detectability; the observed 18.1-point gap is large in absolute terms and represents a 76.6% relative change, a business judgment about stakes and volume must determine whether this magnitude matters operationally.

What this can't tell you

The analysis compares two groups but does not account for other factors that might co-vary with age (tenure, product mix, pricing). A finer-grained breakdown by cohort or service line would clarify whether the churn difference is uniform across customer segments or concentrated in specific subgroups.

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

Proportion Tests & Confidence Intervals — Did the Rate Really Differ?

Compares a binary outcome (converted / clicked / churned / passed) between exactly two groups: per-group proportions with Wilson 95% confidence intervals, a two-sample proportion z-test with a continuity-corrected chi-square cross-check, the absolute difference with a Newcombe hybrid confidence interval, and Fisher's exact test as an automatic fallback when any cell count is too small for the z-test.

Why This Method?

Rates from finite samples wobble. The z-test answers whether the observed gap between two proportions is bigger than chance alone would produce, and the Wilson/Newcombe intervals show how large or small the true rates and their difference could plausibly be — which is the decision-relevant part. Small cell counts silently break the normal approximation, so the tool checks the cells and switches to Fisher's exact test when needed.

What This Analysis Covers

  • Per-group conversion rates with Wilson 95% CIs (bar chart with error bars)
  • Two-sample proportion z-test + continuity-corrected chi-square cross-check
  • Absolute difference with a Newcombe hybrid 95% CI, relative change as computed
  • Fisher's exact fallback when any cell count is below 5, clearly disclosed

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {outcome, group}. 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))

Core Analysis Pipeline

compute_shared <- function(df, params, col_map = list()) {
  # === SHARED EXPORTS ===
  #   initial_rows/final_rows/rows_removed/n_na_rows  $ row accounting
  #   outcome_h / group_h   $ humanized user names for the mapped columns
  #   l1 / l2               $ the two group levels (factor order)
  #   success_label / failure_label  $ outcome levels (original casing)
  #   success_flagged / success_note $ how the success level was chosen
  #   n1/n2, x1/x2, p1/p2   $ per-group n, successes, proportions
  #   w1 / w2               $ Wilson 95% CIs per group (low/high)
  #   d / d_low / d_high    $ absolute difference (l1 - l2) + Newcombe 95% CI
  #   rel_change / rel_ok   $ relative change of l2 vs l1 (only when p1 > 0)
  #   min_cell / z_valid    $ smallest cell count; z-test validity guard
  #   z_stat/p_z, chi_stat/chi_df/p_chi, p_fisher/fisher_or  $ the tests
  #   p_primary / test_used / significant / verdict_label    $ the verdict
  #   dropped_groups_df     $ groups dropped for n < 3
  #   rates_df / test_df / group_summary_df / methods_df  $ card datasets
  #   metrics / json_output
  # === /SHARED EXPORTS ===

Step 1: Resolve mapped columns (humanized for all prose)

initial_rows <- nrow(df)
  outcome_h <- humanize_semantic("outcome", col_map)
  group_h   <- humanize_semantic("group", col_map)
  if (!("outcome" %in% names(df)) || !("group" %in% names(df))) {
    stop(sprintf("Proportion testing needs both &#x27;%s' (the binary outcome) and '%s' (the two groups) mapped.",
                 outcome_h, group_h))
  }

Step 2: Drop rows with a missing outcome or group; keep accounting

o_raw <- as.character(df$outcome)
  g_raw <- as.character(df$group)
  o_norm <- tolower(trimws(o_raw))
  g_clean <- trimws(g_raw)
  keep <- !is.na(o_raw) & o_norm != "" & o_norm != "na" &
          !is.na(g_raw) & g_clean != ""
  n_na_rows <- sum(!keep)
  o_raw  <- o_raw[keep];  o_norm <- o_norm[keep]
  g_clean <- g_clean[keep]
  if (length(o_norm) == 0) {
    stop(sprintf("No rows with usable values in both &#x27;%s' and '%s' remained after cleaning.",
                 outcome_h, group_h))
  }

Step 3: The outcome must be binary — detect the success level robustly

lev_norm <- sort(unique(o_norm))
  if (length(lev_norm) != 2) {
    stop(sprintf("The outcome column &#x27;%s' must be binary (exactly 2 distinct values such as 0/1, yes/no, TRUE/FALSE); it has %d distinct value(s): %s. Pick a two-valued column.",
                 outcome_h, length(lev_norm),
                 paste(head(lev_norm, 6), collapse = ", ")))
  }

Original-casing display labels (first occurrence per normalized level)

disp <- sapply(lev_norm, function(l) trimws(o_raw[match(l, o_norm)]))
  positive_tokens <- c("1", "true", "t", "yes", "y", "converted", "success",
                       "succeeded", "won", "pass", "passed", "clicked",
                       "purchased", "retained")
  negative_tokens <- c("0", "false", "f", "no", "n", "not converted",
                       "not_converted", "non-converted", "failure", "failed",
                       "lost", "fail", "none")
  in_pos <- lev_norm %in% positive_tokens
  in_neg <- lev_norm %in% negative_tokens
  success_flagged <- FALSE
  if (sum(in_pos) == 1) {
    success_norm <- lev_norm[in_pos]
    success_note <- sprintf("&#x27;%s' is a recognized affirmative value (like 1/TRUE/yes), so it was treated as the success level.",
                            disp[in_pos])
  } else if (sum(in_neg) == 1) {
    success_norm <- lev_norm[!in_neg]
    success_note <- sprintf("&#x27;%s' is a recognized negative value (like 0/FALSE/no), so the other level, '%s', was treated as the success level.",
                            disp[in_neg], disp[!in_neg])
  } else {

Ambiguous levels: prefer the RARER level as the event of interest, and flag it

cnt <- table(o_norm)[lev_norm]
    success_norm <- lev_norm[which.min(as.integer(cnt))]
    success_flagged <- TRUE
    success_note <- sprintf("Neither &#x27;%s' nor '%s' is a standard yes/no token, so the RARER level, '%s', was assumed to be the event of interest (the success level). If the other level is the one you care about, note that every rate below is simply 100%% minus the reported one.",
                            disp[1], disp[2], disp[lev_norm == success_norm])
  }
  success_label <- unname(disp[lev_norm == success_norm])
  failure_label <- unname(disp[lev_norm != success_norm])
  y <- as.integer(o_norm == success_norm)

Step 4: Clean the groups — drop n<3 (reported), require exactly 2 levels

tab <- table(g_clean)
  small <- names(tab)[tab < 3]
  dropped_groups_df <- data.frame(group = character(0), n = integer(0),
                                  stringsAsFactors = FALSE)
  if (length(small) > 0) {
    dropped_groups_df <- data.frame(group = small, n = as.integer(tab[small]),
                                    stringsAsFactors = FALSE)
    sel <- !(g_clean %in% small)
    g_clean <- g_clean[sel]
    y <- y[sel]
  }
  gf <- factor(g_clean)
  k  <- nlevels(gf)
  if (k < 2) {
    stop(sprintf("Comparing proportions needs exactly 2 groups in &#x27;%s' with 3 or more rows each; only %d usable group(s) remained after cleaning. Check that '%s' really splits the data into two groups.",
                 group_h, k, group_h))
  }
  if (k > 2) {
    stop(sprintf("This tool compares exactly two groups, but &#x27;%s' has %d usable levels (%s). Filter the data to two groups, or use a many-group categorical test instead.",
                 group_h, k, paste(levels(gf), collapse = ", ")))
  }
  final_rows <- length(y)
  rows_removed <- initial_rows - final_rows
  if (final_rows < 10) {
    stop(sprintf("Only %d usable rows remained — at least 10 are needed to compare &#x27;%s' rates across '%s'.",
                 final_rows, outcome_h, group_h))
  }
  if (sum(y) == 0 || sum(y) == final_rows) {
    stop(sprintf("Every usable row has the same &#x27;%s' outcome ('%s') — with no variation, no rate difference can be tested.",
                 outcome_h, if (sum(y) == 0) failure_label else success_label))
  }

Step 5: Per-group proportions + Wilson 95% CIs

l1 <- levels(gf)[1]; l2 <- levels(gf)[2]
  y1 <- y[gf == l1]; y2 <- y[gf == l2]
  n1 <- length(y1); n2 <- length(y2)
  x1 <- sum(y1); x2 <- sum(y2)
  p1 <- x1 / n1; p2 <- x2 / n2
  w1 <- wilson_ci(x1, n1)
  w2 <- wilson_ci(x2, n2)

Step 6: Absolute difference (l1 - l2) with the Newcombe hybrid 95% CI

d <- p1 - p2
  d_low  <- d - sqrt((p1 - w1["low"])^2  + (w2["high"] - p2)^2)
  d_high <- d + sqrt((w1["high"] - p1)^2 + (p2 - w2["low"])^2)
  d_low <- unname(max(-1, d_low)); d_high <- unname(min(1, d_high))

Relative change of l2 vs the l1 baseline — stated only when computable

rel_ok <- p1 > 0
  rel_change <- if (rel_ok) (p2 - p1) / p1 else NA_real_

Step 7: Cell-count guard, z-test, chi-square cross-check, Fisher fallback

cells <- c(x1, n1 - x1, x2, n2 - x2)
  min_cell <- min(cells)
  z_valid <- min_cell >= 5

  p_pool <- (x1 + x2) / (n1 + n2)
  se0 <- sqrt(p_pool * (1 - p_pool) * (1 / n1 + 1 / n2))
  z_stat <- if (se0 > 0) d / se0 else NA_real_
  p_z <- if (!is.na(z_stat)) 2 * stats::pnorm(-abs(z_stat)) else NA_real_

  ct <- tryCatch(
    suppressWarnings(stats::prop.test(c(x1, x2), c(n1, n2), correct = TRUE)),
    error = function(e) NULL)
  chi_stat <- if (!is.null(ct)) unname(ct$statistic) else NA_real_
  chi_df   <- if (!is.null(ct)) unname(ct$parameter) else NA_real_
  p_chi    <- if (!is.null(ct)) ct$p.value else NA_real_

  p_fisher <- NA_real_; fisher_or <- NA_real_
  if (!z_valid) {
    ft <- stats::fisher.test(matrix(c(x1, n1 - x1, x2, n2 - x2),
                                    nrow = 2, byrow = TRUE))
    p_fisher <- ft$p.value
    fisher_or <- unname(ft$estimate)
  }

  if (z_valid) {
    p_primary <- p_z
    test_used <- "two-sample proportion z-test"
  } else {
    p_primary <- p_fisher
    test_used <- "Fisher&#x27;s exact test"
  }
  significant <- !is.na(p_primary) && p_primary < 0.05
  verdict_label <- if (significant) "Significant difference" else "No significant difference"

Step 8: Computed honesty notes (magnitude vs detectability)

abs_pp <- abs(d) * 100
  magnitude_word <- if (abs_pp < 1) "small" else if (abs_pp < 5) "modest" else "sizeable"
  practical_note <- if (significant) {
    paste0(
      "Statistical significance measures detectability, not importance: the observed gap is ",
      fmt_num(abs_pp, 1), " percentage points",
      if (rel_ok) paste0(" (a relative change of ", fmt_pct(abs(rel_change)),
                         if (rel_change >= 0) " higher" else " lower",
                         " for ", l2, " vs ", l1, ")") else "",
      " — a ", magnitude_word, " gap in absolute terms. Whether a ",
      fmt_num(abs_pp, 1), "-point shift in ", outcome_h,
      " matters is a business judgment about stakes and volume, not a statistical one."
    )
  } else {
    paste0(
      "No significant difference is NOT proof the rates are equal — it means the data ",
      "cannot distinguish the observed gap of ", fmt_num(abs_pp, 1),
      " percentage points from sampling noise. The Newcombe 95% interval on the difference(",
      fmt_num(d_low * 100, 2), " to ", fmt_num(d_high * 100, 2),
      " percentage points) shows the range of true differences still compatible with this data."
    )
  }

  cross_check_note <- if (!is.na(p_z) && !is.na(p_chi)) {
    agree <- (p_z < 0.05) == (p_chi < 0.05)
    if (agree) {
      paste0("The continuity-corrected chi-square cross-check agrees with the z-test verdict(p = ",
             fmt_p(p_chi), " vs p = ", fmt_p(p_z), ").")
    } else {
      paste0("Caution: the continuity-corrected chi-square(p = ", fmt_p(p_chi),
             ") and the z-test(p = ", fmt_p(p_z),
             ") land on opposite sides of the 0.05 line — the evidence is borderline and should not be over-read.")
    }
  } else ""

Step 10: Metrics + JSON answer

metrics <- list(
    `Observations` = final_rows,
    `Rate` = paste0(l1, " ", fmt_pct(p1), " vs ", l2, " ", fmt_pct(p2)),
    `Difference(pp)` = round(d * 100, 2),
    `P-value` = fmt_p(p_primary),
    `Verdict` = verdict_label,
    `Test Used` = test_used
  )

  json_output <- list(
    answer = paste0(
      "Comparison of ", success_label, " rates in ", outcome_h, " between the two ",
      group_h, " groups(", format(final_rows, big.mark = ","), " rows): ",
      l1, " ", fmt_pct(p1), " (Wilson 95% CI ", fmt_pct(w1["low"]), " to ",
      fmt_pct(w1["high"]), ", n=", format(n1, big.mark = ","), ") vs ",
      l2, " ", fmt_pct(p2), " (", fmt_pct(w2["low"]), " to ", fmt_pct(w2["high"]),
      ", n=", format(n2, big.mark = ","), "). Absolute difference ",
      fmt_num(d * 100, 2), " percentage points(Newcombe 95% CI ",
      fmt_num(d_low * 100, 2), " to ", fmt_num(d_high * 100, 2),
      " percentage points). Verdict by the ", test_used, ": ",
      if (significant) "the rates differ beyond chance" else
        "no statistically significant difference was detected",
      " (p = ", fmt_p(p_primary), ").",
      if (success_flagged) paste0(" Note: ", success_note) else ""
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "rates_chart",
        "test_table", "group_summary", "methods"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows = initial_rows, final_rows = final_rows,
    rows_removed = rows_removed, n_na_rows = n_na_rows,
    outcome_h = outcome_h, group_h = group_h,
    l1 = l1, l2 = l2,
    success_label = success_label, failure_label = failure_label,
    success_flagged = success_flagged, success_note = success_note,
    n1 = n1, n2 = n2, x1 = x1, x2 = x2, p1 = p1, p2 = p2,
    w1 = w1, w2 = w2,
    d = d, d_low = d_low, d_high = d_high,
    rel_change = rel_change, rel_ok = rel_ok,
    min_cell = min_cell, z_valid = z_valid,
    z_stat = z_stat, p_z = p_z,
    chi_stat = chi_stat, chi_df = chi_df, p_chi = p_chi,
    p_fisher = p_fisher, fisher_or = fisher_or,
    p_primary = p_primary, test_used = test_used,
    significant = significant, verdict_label = verdict_label,
    practical_note = practical_note, cross_check_note = cross_check_note,
    dropped_groups_df = dropped_groups_df,
    rates_df = rates_df, test_df = test_df,
    group_summary_df = group_summary_df, methods_df = methods_df,
    metrics = metrics, json_output = json_output
  )
}
Your data has more stories to tell.Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

Report an Issue

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

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