Standard Categorical Tests
Executive Summary

Executive Summary

Are Subscription Plan and Churned related?

Observations
600
Table Size
3 × 2
Chi-Square
34.61
P Value
3.05e-08
Cramér's V
0.24
Association
associated (weak)
Across 600 observations in a 3 × 2 table: Subscription Plan and Churned ARE related: the chi-square test rejects independence (p = 3.05e-08), and Cramér's V = 0.24 grades the association as weak. The combination driving it most is Basic × yes, which appears 90 times vs 62.5 expected under independence; Basic × no is correspondingly under-represented (210 vs 237.5 expected).
Interpretation

Yes, churn is associated with plan. The chi-square test (χ² = 34.61, p = 3.05e-08) rejects independence across 600 observations. However, Cramér's V = 0.24 indicates a weak association. The pattern is driven by two opposing deviations: Basic plans show 90 churn cases versus 62.5 expected (over-represented by 5.53 standard deviations), while Enterprise plans show only 5 churn cases versus 20.8 expected (under-represented by 4.27 standard deviations). Churn risk is highest in Basic (30%) and lowest in Enterprise (5%), with Pro intermediate at 15%.

Overview

Analysis Overview

Association test between Subscription Plan and Churned across 600 observations.

N Observations600
N Levels A3
N Levels B2
N Cells6
Interpretation

Yes, Subscription Plan and Churned are statistically associated. A chi-square test of independence on 600 observations across a 3 × 2 contingency table (3 plan types, 2 churn outcomes) rejects the null hypothesis of independence with p = 3.05e-08. This strong p-value indicates the distribution of churn across plans deviates significantly from random variation. Cramér's V = 0.24 quantifies this association as weak—meaning while the relationship is real and detectable, the plan type alone explains only a modest portion of churn variation. Association does not imply causation; unmeasured factors (customer tenure, support quality, pricing sensitivity) could jointly influence both plan choice and churn behavior.

Data Preparation

Data Quality

Missing-value recoding, category lumping, and sparse-count handling.

Initial Rows600
Final Rows600
Rows Removed0
Pct Cells Low Expected0
Interpretation

All 600 rows were retained; no observations were dropped during cleaning. Expected cell counts ranged from 20.8 to 237.5, well above the threshold for sparse-data concerns, so the chi-square test remained valid without requiring Fisher's exact alternative. Both Subscription Plan and Churned columns were complete with no missing values recoded. The dataset required no category lumping or rare-level consolidation, as neither column exceeded 8 levels. Data quality was adequate for reliable association testing.

Visualization

Contingency Table

Counts of every Subscription Plan × Churned combination.

Interpretation

The contingency table reveals a clear gradient in churn by plan tier. Basic plans account for 300 of 600 rows (50%), split 210 non-churned and 90 churned. Pro plans comprise 200 rows (33%), with 170 non-churned and 30 churned. Enterprise plans form the smallest group at 100 rows (17%), with 95 non-churned and only 5 churned. The largest single cell is Basic × no (210 rows, 35% of all data), while the smallest is Enterprise × yes (5 rows, <1%). This concentration pattern—more Basic churn, fewer Enterprise churn—anchors the observed association.

Visualization

Composition by Group

Share of each Churned category within every Subscription Plan level (rows sum to 100%).

Interpretation

Within each plan level, the share of non-churn (yes) varies substantially: Basic retains 70% of customers, Pro 85%, and Enterprise 95%. Conversely, churn (yes) ranges from 30% in Basic to 15% in Pro to 5% in Enterprise. This 25-percentage-point swing in churn rate from Basic (30%) to Enterprise (5%) is the visual signature of the association. If plans were independent of churn, all three groups would show identical churn proportions. The divergence is what the chi-square test detected as statistically significant.

Data Table

Statistical Tests

Chi-square, Fisher's exact (when run), and Cramér's V.

TestStatisticDfP ValueInterpretation
Chi-square test34.6123.05e-08Significant association at p < 0.05.
Cramér's V (effect size)0.24Association strength is weak (0 = none, 1 = perfect; <0.1 negligible, 0.1–0.3 weak, 0.3–0.5 moderate, >0.5 strong).
Interpretation

The chi-square test yielded χ² = 34.61 on 2 degrees of freedom, with p = 3.05e-08, far below the conventional α = 0.05 threshold. This p-value is highly significant, indicating strong evidence against independence. Cramér's V = 0.24 provides essential context: it classifies the effect size as weak (0.1–0.3 range). A large sample (n = 600) can detect even small associations; reading significance and strength together shows that while the plan–churn relationship is real, it is modest in practical magnitude.

Data Table

Largest Deviations from Independence

The cells that depart most from the counts independence would predict.

CellObservedExpectedStd Residual
Basic × yes9062.55.53
Basic × no210237.5-5.53
Enterprise × no9579.24.27
Enterprise × yes520.8-4.27
Pro × no170158.32.49
Pro × yes3041.7-2.49
Interpretation

Two cells dominate the deviation pattern. Basic × yes shows 90 observed churn cases against 62.5 expected, a standardized residual of +5.53—the largest positive deviation. Correspondingly, Basic × no shows 210 cases against 237.5 expected (residual −5.53). Enterprise follows with opposite signs: Enterprise × no is over-represented (95 vs 79.2 expected; residual +4.27) and Enterprise × yes is severely under-represented (5 vs 20.8 expected; residual −4.27). Pro's deviations are smaller (residuals ±2.49), suggesting it occupies a middle ground. These four cells—Basic × yes, Basic × no, Enterprise × no, Enterprise × yes—are where the plan–churn relationship concentrates.

Methodology

Methodology

Statistical methodology and diagnostics for Categorical Association — Chi-Square & Fisher

Statistical Method

Categorical Association — Chi-Square & Fisher

Standard-library analysis: are two categorical columns related? Builds the full contingency table, runs the chi-square test of independence (with Fisher's exact test whenever counts are sparse or the table is 2×2), and sizes the relationship with Cramér's V — plus a heatmap of every combination, composition bars, and the specific cells that deviate most from independence. Works on any dataset: map two categorical columns.

Data
N = 600 observations
Key Results
Observations 600
Table Size 3 × 2
Chi-Square 34.61
P Value 3.05e-08
Cramér'S V 0.2400
Association associated (weak)
Assumptions
  • Observations are independent (each row is one unit, counted once)
  • Both columns are categorical (or usable as categories)
  • Chi-square needs adequate expected counts — when >20% of cells fall below 5, Fisher's exact test is run automatically
Limitations
  • Association is not causation — a third factor can drive both columns
  • Very rare categories are lumped into 'Other' beyond 8 levels, which can blur fine-grained patterns
  • With large datasets, tiny practically-irrelevant associations become significant — read Cramér's V alongside the p-value
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

Categorical Association — Chi-Square & Fisher

Tests whether two categorical columns are related: contingency table, chi-square test of independence (Fisher's exact test when counts are sparse), Cramér's V effect size, and the cells that deviate most from independence.

Why This Method?

The chi-square test is the standard answer to "are these two categories related?" — one p-value for the whole table. Fisher's exact test covers the sparse-count cases where chi-square is unreliable, and Cramér's V turns the verdict into a 0-1 strength score so significance is never confused with importance.

What This Analysis Covers

  • Contingency table heatmap (all combinations, including zeros)
  • Row-share bars: composition of one column within each level of the other
  • Chi-square + Fisher exact + Cramér's V, tabulated with interpretations
  • The most over- and under-represented combinations (standardized residuals)

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {var_a, var_b}. 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  $ row accounting
  #   name_a / name_b     $ humanized user names of var_a / var_b
  #   levels_a / levels_b $ ordered level labels used in the table
  #   n_missing_a/_b      $ blanks/NA recoded to "Missing"
  #   n_lumped_a/_b       $ levels folded into "Other" (beyond 8)
  #   tab                 $ contingency table (var_a rows x var_b cols)
  #   contingency_df      $ long format incl. zeros: level_a, level_b, count
  #   proportions_df      $ level_a, share_pct, level_b (row shares x100)
  #   test_results_df     $ test, statistic, df, p_value, interpretation
  #   deviations_df       $ cell, observed, expected, std_residual (top 10)
  #   chi_stat/chi_df/chi_p $ chi-square results
  #   fisher_p            $ Fisher exact p (NA when not run)
  #   fisher_ran          $ logical — was Fisher run
  #   pct_low_expected    $ % of cells with expected count < 5
  #   cramers_v / strength_band $ effect size + verdict band
  #   p_effective / test_used   $ the p-value the verdict rests on
  #   significant         $ logical — p_effective < 0.05
  #   top_over / top_under $ 1-row data.frames (may be NULL) — extreme cells
  #   metrics / json_output
  # === /SHARED EXPORTS ===

Step 1: Discover the two mapped columns

initial_rows <- nrow(df)
  for (k in c("var_a", "var_b")) {
    if (!k %in% names(df)) {
      stop(sprintf("column_mapping must map &#x27;%s' to a categorical column.", k))
    }
  }
  name_a <- humanize_semantic("var_a", col_map)
  name_b <- humanize_semantic("var_b", col_map)

Step 2: Clean both columns — character, "Missing", lump beyond 8 levels

prep_categorical <- function(v, max_levels = 8) {
    x <- trimws(as.character(v))
    n_missing <- sum(is.na(x) | x == "")
    x[is.na(x) | x == ""] <- "Missing"
    freq <- sort(table(x), decreasing = TRUE)
    lumped <- character(0)
    if (length(freq) > max_levels) {
      keep <- names(freq)[seq_len(max_levels)]
      lumped <- setdiff(names(freq), keep)
      x[x %in% lumped] <- "Other"
    }

Levels ordered by frequency; "Other"/"Missing" pushed last for readability

freq2 <- sort(table(x), decreasing = TRUE)
    lv <- names(freq2)
    special <- intersect(c("Other", "Missing"), lv)
    lv <- c(setdiff(lv, special), special)
    list(x = factor(x, levels = lv), n_missing = n_missing,
         n_lumped = length(lumped))
  }
  pa <- prep_categorical(df[["var_a"]])
  pb <- prep_categorical(df[["var_b"]])

  if (nlevels(pa$x) < 2) {
    stop(sprintf("&#x27;%s' has only one distinct value (%s) — an association test needs at least two categories in each column.",
                 name_a, levels(pa$x)[1]))
  }
  if (nlevels(pb$x) < 2) {
    stop(sprintf("&#x27;%s' has only one distinct value (%s) — an association test needs at least two categories in each column.",
                 name_b, levels(pb$x)[1]))
  }

  final_rows <- length(pa$x)
  rows_removed <- initial_rows - final_rows
  if (final_rows < 20) {
    stop(sprintf("Only %d usable rows — the association test between &#x27;%s' and '%s' needs at least 20.",
                 final_rows, name_a, name_b))
  }

Step 3: Contingency table + chi-square test

tab <- table(pa$x, pb$x)
  n <- sum(tab)
  is_2x2 <- nrow(tab) == 2 && ncol(tab) == 2

2x2 keeps the default Yates continuity correction; larger tables use the uncorrected statistic. Low-expected-count warnings are suppressed here and narrated (Fisher covers that case below).

chi <- suppressWarnings(
    if (is_2x2) chisq.test(tab) else chisq.test(tab, correct = FALSE)
  )
  chi_stat <- as.numeric(chi$statistic)
  chi_df   <- as.numeric(chi$parameter)
  chi_p    <- as.numeric(chi$p.value)
  expected <- chi$expected
  pct_low_expected <- 100 * mean(expected < 5)

Range of the EXPECTED counts matrix (not the observed table) — narrated in Data Quality so any "expected cell counts ranged from X to Y" claim is computed from the right matrix.

min_expected <- round(min(expected), 1)
  max_expected <- round(max(expected), 1)

Step 4: Fisher's exact test when chi-square is unreliable (or 2x2)

fisher_ran <- FALSE
  fisher_p <- NA_real_
  fisher_or <- NA_real_
  if (is_2x2 || pct_low_expected > 20) {
    fr <- tryCatch(
      if (is_2x2) fisher.test(tab)
      else fisher.test(tab, simulate.p.value = TRUE, B = 10000),
      error = function(e) NULL
    )
    if (!is.null(fr)) {
      fisher_ran <- TRUE
      fisher_p <- as.numeric(fr$p.value)
      if (is_2x2 && !is.null(fr$estimate)) fisher_or <- as.numeric(fr$estimate)
    }
  }

The verdict rests on Fisher when it ran (it is the reliable test in exactly the situations that triggered it), else on chi-square.

if (fisher_ran && !is.na(fisher_p)) {
    p_effective <- fisher_p
    test_used <- "Fisher&#x27;s exact test"
  } else {
    p_effective <- chi_p
    test_used <- "chi-square test"
  }
  significant <- !is.na(p_effective) && p_effective < 0.05

Step 5: Cramér's V effect size

cramers_v <- sqrt(chi_stat / (n * (min(dim(tab)) - 1)))
  strength_band <- if (is.na(cramers_v)) "unknown"
    else if (cramers_v < 0.1) "negligible"
    else if (cramers_v < 0.3) "weak"
    else if (cramers_v < 0.5) "moderate"
    else "strong"

Step 6: Standardized residuals — which cells drive the association

obs_long <- as.data.frame(tab, stringsAsFactors = FALSE)
  names(obs_long) <- c("level_a", "level_b", "count")
  contingency_df <- obs_long
  contingency_df$level_a <- as.character(contingency_df$level_a)
  contingency_df$level_b <- as.character(contingency_df$level_b)

  exp_long <- as.data.frame(as.table(expected), stringsAsFactors = FALSE)
  std_long <- as.data.frame(as.table(chi$stdres), stringsAsFactors = FALSE)
  dev_all <- data.frame(
    cell = paste0(as.character(obs_long$level_a), " × ",
                  as.character(obs_long$level_b)),
    observed = obs_long$count,
    expected = round(exp_long$Freq, 1),
    std_residual = round(std_long$Freq, 2),
    stringsAsFactors = FALSE
  )
  dev_all <- dev_all[is.finite(dev_all$std_residual), , drop = FALSE]

Ties in |residual| (guaranteed in 2-column tables) list the over-represented cell first — it reads better in the narrative.

dev_all <- dev_all[order(-abs(dev_all$std_residual), -dev_all$std_residual), , drop = FALSE]
  rownames(dev_all) <- NULL
  deviations_df <- head(dev_all, 10)

Most over/under-represented cells (guard: never index into empty/all-NA)

top_over <- if (nrow(dev_all) > 0 && max(dev_all$std_residual) > 0)
    dev_all[which.max(dev_all$std_residual), , drop = FALSE] else NULL
  top_under <- if (nrow(dev_all) > 0 && min(dev_all$std_residual) < 0)
    dev_all[which.min(dev_all$std_residual), , drop = FALSE] else NULL

Step 7: Row shares — composition of var_b within each var_a level

prop <- prop.table(tab, 1) * 100
  prop_long <- as.data.frame(as.table(prop), stringsAsFactors = FALSE)
  proportions_df <- data.frame(
    level_a = as.character(prop_long$Var1),
    share_pct = round(prop_long$Freq, 1),
    level_b = as.character(prop_long$Var2),
    stringsAsFactors = FALSE
  )

Step 8: Test-results table

chi_name <- if (is_2x2) "Chi-square test(Yates-corrected)" else "Chi-square test"
  chi_interp <- paste0(
    if (!is.na(chi_p) && chi_p < 0.05) "Significant association at p < 0.05."
    else "No significant association at p < 0.05.",
    if (pct_low_expected > 20) paste0(" Caution: ", round(pct_low_expected),
                                      "% of cells have expected counts below 5 — prefer Fisher&#x27;s row.")
    else ""
  )
  test_rows <- list(data.frame(
    test = chi_name, statistic = round(chi_stat, 2), df = chi_df,
    p_value = signif(chi_p, 3), interpretation = chi_interp,
    stringsAsFactors = FALSE
  ))
  if (fisher_ran) {
    test_rows[[length(test_rows) + 1]] <- data.frame(
      test = if (is_2x2) "Fisher&#x27;s exact test" else "Fisher's exact test (simulated, B=10,000)",
      statistic = if (is_2x2 && !is.na(fisher_or)) round(fisher_or, 2) else NA_real_,
      df = NA_real_, p_value = signif(fisher_p, 3),
      interpretation = paste0(
        if (!is.na(fisher_p) && fisher_p < 0.05) "Significant association at p < 0.05."
        else "No significant association at p < 0.05.",
        if (is_2x2 && !is.na(fisher_or)) sprintf(" Statistic shown is the odds ratio(%.2f).", fisher_or)
        else " Exact test — reliable with sparse cell counts."
      ),
      stringsAsFactors = FALSE
    )
  }
  test_rows[[length(test_rows) + 1]] <- data.frame(
    test = "Cramér&#x27;s V (effect size)", statistic = round(cramers_v, 3),
    df = NA_real_, p_value = NA_real_,
    interpretation = paste0("Association strength is ", strength_band,
                            " (0 = none, 1 = perfect; <0.1 negligible, 0.10.3 weak, 0.30.5 moderate, >0.5 strong)."),
    stringsAsFactors = FALSE
  )
  test_results_df <- do.call(rbind, test_rows)
  rownames(test_results_df) <- NULL

  metrics <- list(
    `Observations`       = as.integer(n),
    `Table Size`         = paste0(nrow(tab), " × ", ncol(tab)),
    `Chi-Square`         = round(chi_stat, 2),
    `P Value`            = signif(p_effective, 3),
    `Cramér&#x27;s V`         = round(cramers_v, 3),
    `Association`        = if (significant) paste0("associated(", strength_band, ")") else "not associated"
  )

  json_output <- list(
    answer = paste0(
      name_a, " and ", name_b,
      if (significant) " are statistically associated(" else " show no statistically significant association(",
      test_used, " p = ", signif(p_effective, 3),
      "; Cramér&#x27;s V = ", round(cramers_v, 3), ", ", strength_band, ") across ",
      format(n, big.mark = ","), " rows.",
      if (significant && !is.null(top_over)) paste0(
        " The most over-represented combination is ", top_over$cell,
        " (", top_over$observed, " observed vs ", top_over$expected, " expected).")
      else ""
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "contingency_heatmap",
        "proportions_bar", "test_results", "largest_deviations"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows = initial_rows, final_rows = final_rows,
    rows_removed = rows_removed,
    name_a = name_a, name_b = name_b,
    levels_a = levels(pa$x), levels_b = levels(pb$x),
    n_missing_a = pa$n_missing, n_missing_b = pb$n_missing,
    n_lumped_a = pa$n_lumped, n_lumped_b = pb$n_lumped,
    tab = tab, contingency_df = contingency_df,
    proportions_df = proportions_df, test_results_df = test_results_df,
    deviations_df = deviations_df,
    chi_stat = chi_stat, chi_df = chi_df, chi_p = chi_p,
    fisher_p = fisher_p, fisher_ran = fisher_ran, is_2x2 = is_2x2,
    pct_low_expected = pct_low_expected,
    min_expected = min_expected,
    max_expected = max_expected,
    cramers_v = cramers_v, strength_band = strength_band,
    p_effective = p_effective, test_used = test_used,
    significant = significant,
    top_over = top_over, top_under = top_under,
    metrics = metrics, json_output = json_output
  )
}

Find the level_b whose share varies most across level_a (the visual story)

spread_by_b <- tapply(pdf$share_pct, pdf$level_b, function(s) max(s) - min(s))
  spread_by_b <- spread_by_b[is.finite(spread_by_b)]
  spread_txt <- ""
  if (length(spread_by_b) > 0) {
    b_star <- names(spread_by_b)[which.max(spread_by_b)]
    sub <- pdf[pdf$level_b == b_star, , drop = FALSE]
    hi <- sub[which.max(sub$share_pct), ]
    lo <- sub[which.min(sub$share_pct), ]
    spread_txt <- paste0(
      " The share of \"", b_star, "\" varies most: from ", lo$share_pct,
      "% of ", lo$level_a, " to ", hi$share_pct, "% of ", hi$level_a, ".",
      if (shared$significant) " That spread is what the association test is detecting."
      else " Even this largest spread is within what chance would produce."
    )
  }
  list(
    title = "Composition by Group",
    description = paste0("Share of each ", shared$name_b, " category within every ",
                         shared$name_a, " level(rows sum to 100%)."),
    text = paste0(
      "Each group of bars is one ", shared$name_a, " level, split into the ",
      "percentage of rows falling in each ", shared$name_b, " category. If the ",
      "two columns were independent, every group would show the same profile.",
      spread_txt
    ),
    chart_labels = list(level_a = shared$name_a, share_pct = "Share(%)"),
    data = list(proportions = pdf)
  )
}

# Card: test_results (table)
card_test_results <- function(shared, df, params) {
  list(
    title = "Statistical Tests",
    description = "Chi-square, Fisher&#x27;s exact (when run), and Cramér's V.",
    text = paste0(
      "The chi-square statistic is ", round(shared$chi_stat, 2), " on ",
      shared$chi_df, " degrees of freedom(p = ", signif(shared$chi_p, 3), ").",
      if (shared$fisher_ran) paste0(
        " Fisher&#x27;s exact test",
        if (shared$is_2x2) "" else " (p-value simulated with 10,000 replicates)",
        " gives p = ", signif(shared$fisher_p, 3),
        if (shared$pct_low_expected > 20) paste0(" and is the reliable verdict here, since ",
          round(shared$pct_low_expected), "% of cells have expected counts under 5.")
        else ".") else "",
      " Cramér&#x27;s V = ", round(shared$cramers_v, 3), " grades the strength as ",
      shared$strength_band, ": a tiny p-value on a large dataset can still mean ",
      "a practically ", if (shared$cramers_v < 0.3) "small" else "substantial",
      " relationship, so read significance and strength together."
    ),
    data = list(test_results = shared$test_results_df)
  )
}

# Card: largest_deviations (table)
card_largest_deviations <- function(shared, df, params) {
  over_txt <- if (!is.null(shared$top_over) && shared$top_over$std_residual > 2) {
    paste0(shared$top_over$cell, " is the most over-represented combination(",
           shared$top_over$observed, " observed vs ", shared$top_over$expected,
           " expected; standardized residual ", shared$top_over$std_residual, ")")
  } else NULL
  under_txt <- if (!is.null(shared$top_under) && shared$top_under$std_residual < -2) {
    paste0(shared$top_under$cell, " is the most under-represented(",
           shared$top_under$observed, " vs ", shared$top_under$expected,
           " expected; residual ", shared$top_under$std_residual, ")")
  } else NULL
  hot_txt <- if (!is.null(over_txt) || !is.null(under_txt)) {
    paste0(paste(c(over_txt, under_txt), collapse = ", and "), ". ")
  } else {
    "No individual cell deviates strongly from independence(all standardized residuals are within ±2). "
  }
  list(
    title = "Largest Deviations from Independence",
    description = "The cells that depart most from the counts independence would predict.",
    text = paste0(
      "Standardized residuals measure how far each cell&#x27;s observed count sits ",
      "from its expected count, in standard-deviation units — beyond ±2 is a ",
      "meaningful departure. ", hot_txt,
      "These cells are where the ", shared$name_a, "–", shared$name_b,
      " relationship actually lives, and the natural place to focus follow-up."
    ),
    data = list(largest_deviations = shared$deviations_df)
  )
}
Your data has more stories to tell. Run any analysis on your own data — 60+ validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No Signup Sign Up Free

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