Standard Nps
Executive Summary

Executive Summary

Net Promoter Score from Likelihood to Recommend

NPS
+15
95% CI
+9 to +21
Promoters %
40
Passives %
35
Detractors %
25
Responses
600
The Net Promoter Score across 600 responses to Likelihood to Recommend is +15 (95% CI +9 to +21) — a positive but modest score — more promoters than detractors. The mix: 40% promoters, 35% passives, 25% detractors. Pro plan (NPS +30, 95% CI +21 to +39) is genuinely ahead of Free plan (NPS +0, 95% CI -9 to +9): a gap of +30 points that is statistically significant (z = 4.7, p < 0.0001), with non-overlapping confidence intervals.
What this means

The short answer

Your overall NPS is +15 (95% CI +9 to +21) from 600 responses — a positive but modest score, with more promoters than detractors. The composition is 40% promoters, 35% passives, and 25% detractors. Pro plan customers drive the difference: they score +30, while Free plan customers score +0, a statistically significant gap of +30 points.

The detail

Overall NPS = +15 points, 95% CI +9 to +21. Promoters (9–10) comprise 40% of responses, passives (7–8) 35%, and detractors (0–6) 25%. By segment: Pro plan NPS +30 (95% CI +21 to +39, n = 300); Free plan NPS +0 (95% CI −9 to +9, n = 300). The gap is statistically significant (z = 4.7, p < 0.0001) with non-overlapping confidence intervals.

What this can't tell you

This snapshot does not reveal why the segments differ or which features or experiences drive the Pro-plan advantage. Qualitative follow-up or feature-level analysis would be needed to understand the levers.

Overview

Analysis Overview

Net Promoter Score from 600 responses to Likelihood to Recommend.

N Observations600
N Segments2
Nps Points15
Ci Width Points12.7
What this means

The short answer

This analysis measures Net Promoter Score (NPS) on the standard 0-10 likelihood-to-recommend scale, where promoters score 9–10, passives 7–8, and detractors 0–6. NPS is the percentage of promoters minus the percentage of detractors, expressed as a point score from −100 to +100. Because NPS is estimated from a sample, it carries a 95% confidence interval: here +15 points with a range of ±6 points either side. Segment-level NPS scores and intervals are computed the same way, and the largest gap between segments is tested for statistical significance using a z-test.

The detail

The classical multinomial variance formula governs the confidence interval: Var(NPS) = (p_promoter + p_detractor − (p_promoter − p_detractor)²) / n. On this dataset of 600 valid responses, the standard error is 3.2 points, yielding a 95% interval of +9 to +21. When a Customer Segment column is mapped, each segment receives its own NPS and interval. The z-test for segment differences runs only when both segments have at least 30 responses; non-overlapping intervals and p < 0.05 both indicate a genuine difference.

What this can't tell you

This is a single cross-sectional snapshot; no trend over time is visible here. NPS is a population estimate and should not be treated as a precise individual-level predictor.

Data Preparation

Data Quality

Row cleaning and scale validation applied before scoring.

Initial Rows616
Final Rows600
Rows Removed16
Out Of Range Dropped4
Non Integer Rounded0
What this means

The short answer

Of 616 rows loaded, 600 valid 0–10 responses were retained for scoring. Sixteen rows were removed: 12 had blank or non-numeric Likelihood to Recommend values, and 4 had scores outside the 0–10 range. No non-integer scores required rounding. The final dataset is clean and ready for NPS calculation.

The detail

The preprocessing pipeline drops any row with a blank or non-numeric score, then excludes any score falling outside 0–10. This export contained 12 blank-or-non-numeric rows and 4 out-of-range rows (16 total), leaving 600 valid observations. No non-integer values were rounded. Rare segment levels, if present, would be lumped into "Other" to preserve statistical power in segment comparisons.

What this can't tell you

The export does not show which specific out-of-range values were removed or why they occurred (e.g., data-entry error vs. system miscoding). If out-of-range scores cluster in a particular segment or time window, that pattern would require row-level diagnostics to detect.

Visualization

Score Distribution

How the 600 responses spread across the 0-10 scale.

What this means

The short answer

Responses cluster at the extremes: the most common single score is 9, and the distribution is polarized between a delighted block at 9–10 and a disappointed block at the low end. Exactly 40% land in the promoter zone (9–10), 35% are passive (7–8), and 25% are detractor (0–6), with 10.3% scoring 3 or below. The average NPS masks two distinct customer experiences.

The detail

Score 9 is the modal response (129 out of 600). The 9–10 band holds 240 responses; the 7–8 band holds 210; the 0–6 band holds 150. The bottom tier (0–3) accounts for 62 responses. The bimodal shape — high mass at 9–10 and again at 0–6 — indicates polarization: satisfied customers cluster at the top, dissatisfied at the bottom, with relatively few in the middle.

What this can't tell you

This cross-section does not show whether the polarization is stable over time or whether the two blocks represent distinct user cohorts. Segmenting the score distribution by other attributes (e.g., plan type, tenure, feature use) would clarify whether the bimodality is uniform across all groups.

Visualization

Promoters, Passives & Detractors

The NPS composition of each Customer Segment segment.

What this means

The short answer

Pro plan customers have a healthier NPS composition: 50% promoters, 30% passives, 20% detractors (NPS +30). Free plan customers are evenly split: 30% promoters, 40% passives, 30% detractors (NPS +0). The key difference is the promoter base — Pro customers are 20 percentage points higher — while Free plan detractors match the promoter share, pulling the score to neutral.

The detail

Pro plan: 50% promoters (9–10), 30% passives (7–8), 20% detractors (0–6). Free plan: 30% promoters, 40% passives, 30% detractors. Pro plan's NPS advantage stems from both a larger promoter block and a smaller detractor block. Free plan's NPS +0 reflects balance: the 30% promoter and 30% detractor shares cancel out, while passives (40%) form the largest single group. Overall, 40% promoters, 35% passives, 25% detractors.

What this can't tell you

The composition does not reveal which Free plan users are passives (potentially at risk of downgrading) versus which are stable, or whether Pro plan's higher promoter share reflects selection (only satisfied users upgrade) or causation (Pro features drive satisfaction). Churn or upgrade data would clarify this.

Data Table

NPS by Segment

NPS, sample size, and 95% CI per Customer Segment segment.

SegmentNNpsCIFlag
Pro plan30030+21 to +39
Free plan3000-9 to +9
What this means

The short answer

Pro plan has an NPS of +30 (95% CI +21 to +39, n = 300), while Free plan has an NPS of +0 (95% CI −9 to +9, n = 300). The +30-point gap is statistically significant (z = 4.7, p < 0.0001) with non-overlapping confidence intervals, meaning the Pro-plan advantage is real, not noise.

The detail

Pro plan leads decisively: NPS +30, 95% CI +21 to +39. Free plan trails: NPS +0, 95% CI −9 to +9. Both segments have 300 responses, meeting the 30-response threshold for the z-test. The two-proportion z-test yields z = 4.7, p < 0.0001. The confidence intervals do not overlap (Pro's lower bound +21 exceeds Free's upper bound +9), confirming a genuine difference.

What this can't tell you

The gap's size and direction are clear, but the mechanism is not. Whether Pro customers are inherently more satisfied, benefit from Pro features, or represent a self-selected cohort of higher-engagement users cannot be determined from NPS alone. Analyzing feature adoption, tenure, or support interactions by segment would help isolate drivers.

Data Table

Method & Fine Print

Formulas, validation rules, and this dataset's actual margin of error.

ItemDetail
Score categoriesPromoters score 9-10, passives 7-8, detractors 0-6 on the 0-10 likelihood-to-recommend scale.
NPS formulaNPS = % promoters minus % detractors, stated as a point score from -100 to +100.
Confidence intervalClassical multinomial variance formula: Var(NPS) = (p_promoter + p_detractor - (p_promoter - p_detractor)^2) / n on the proportion scale; SE in points = 100 x sqrt(Var); 95% CI = NPS plus or minus 1.96 x SE.
This datasetn = 600 valid responses; SE = 3.2 points; the 95% interval spans 13 points (+9 to +21).
Segment comparisonTwo-proportion-style z-test on the NPS difference between the best and worst segment, run only when both have at least 30 responses; otherwise the comparison is reported as underpowered. Gaps with overlapping 95% confidence intervals are never called differences.
Scale validationValues outside 0-10 are dropped and reported. A column whose values sit entirely within 1-5 stops the analysis — a 1-5 rating scale cannot be honestly rescaled to NPS.
Small-sample policyThe confidence interval width in points is always stated alongside the score, so a small-sample NPS reads as the rough estimate it is.
What this means

The short answer

NPS is calculated as % promoters minus % detractors on the 0–10 scale. Your dataset of 600 responses yields a standard error of 3.2 points, giving a confidence interval of ±9 to ±21. Segment comparisons use a two-proportion z-test only when both segments have at least 30 responses; overlapping confidence intervals are never called differences.

The detail

NPS = % promoters (9–10) minus % detractors (0–6), expressed as a point score from −100 to +100. The 95% confidence interval uses the classical multinomial variance formula Var(NPS) = (p_promoter + p_detractor − (p_promoter − p_detractor)²) / n. On this dataset (n = 600), the standard error is 3.2 points and the interval spans 13 points. Segment tests require both groups to have at least 30 responses; gaps with overlapping intervals are not reported as differences.

What this can't tell you

To compare NPS by segment, the segment-level data must be supplied separately. This card documents the methodology and precision; the segment breakdown requires a second card with segment-stratified counts.

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

NPS Analysis — Net Promoter Score with Honest Uncertainty

Computes the Net Promoter Score from a 0-10 likelihood-to-recommend column: promoter / passive / detractor shares, the NPS point score with a 95% confidence interval from the classical multinomial variance formula, the full 0-10 score distribution, and per-segment NPS with a statistically honest best-vs-worst comparison.

Why This Method?

NPS is usually reported as a bare number, which hides how noisy it is on small samples. Stating the confidence interval in points, and refusing to call overlapping-interval segment gaps "differences", turns the same arithmetic into a trustworthy answer.

What This Analysis Covers

  • NPS = % promoters (9-10) minus % detractors (0-6), in points
  • 95% CI via Var(NPS) = (p9 + p0_6 - (p9 - p0_6)^2) / n
  • The raw 0-10 distribution and the promoter/passive/detractor mix
  • Per-segment NPS with CIs and a gated best-vs-worst z-test

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {score, segment}. 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
  #   score_h / segment_h   $ humanized user names for the mapped columns
  #   has_segment           $ TRUE when a usable segment column is mapped
  #   n_na_score / n_out_of_range / n_non_integer  $ cleaning counts
  #   lumped_levels / segment_constant             $ segment cleaning notes
  #   overall               $ list(n, prom_pct, pass_pct, det_pct, nps, se_pts,
  #                                ci_low, ci_high, ci_half) — whole-sample NPS
  #   seg_stats             $ list of the same per segment (NULL if no segment)
  #   segment_nps_df        $ segment, n, nps, ci, flag (falls back to Overall)
  #   score_distribution_df $ score ("0".."10" in numeric order), count
  #   nps_mix_df            $ segment_label, share_pct, nps_category
  #   methods_df            $ item, detail — formulas + this dataset's numbers
  #   gap / gap_verdict / gap_text  $ best-vs-worst segment comparison
  #   wide_ci / wide_note   $ small-sample honesty (CI width in points)
  #   fmt_pts / fmt_ci / fmt_p      $ formatters (signed points, no e-notation)
  #   metrics / json_output
  # === /SHARED EXPORTS ===

Step 0: Formatters — signed point scores, no bare asterisks, no e-notation

fmt_pts <- function(x) sprintf("%+d", as.integer(round(x)))
  fmt_p <- function(p) {
    if (is.na(p)) "p not computed"
    else if (p < 0.0001) "p < 0.0001"
    else sprintf("p = %.4f", p)
  }

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

initial_rows <- nrow(df)
  score_h <- humanize_semantic("score", col_map)
  has_segment <- "segment" %in% names(df)
  segment_h <- if (has_segment) humanize_semantic("segment", col_map) else "segment"
  if (!("score" %in% names(df))) {
    stop(sprintf("NPS analysis needs the score column &#x27;%s' mapped — the 0-10 likelihood-to-recommend response.", score_h))
  }

Step 2: Coerce the score to numeric (95% rule)

v <- df$score
  if (!is.numeric(v)) {
    ch <- as.character(v)
    non_blank <- !is.na(ch) & trimws(ch) != ""
    conv <- suppressWarnings(as.numeric(ch))
    if (sum(non_blank) == 0 ||
        sum(!is.na(conv[non_blank])) < 0.95 * sum(non_blank)) {
      stop(sprintf("The score column &#x27;%s' does not look numeric — fewer than 95%% of its values parse as numbers. NPS needs the raw 0-10 responses.", score_h))
    }
    v <- conv
  }
  seg_raw <- if (has_segment) as.character(df$segment) else NULL

  keep <- !is.na(v)
  n_na_score <- sum(!keep)
  v <- v[keep]
  if (has_segment) seg_raw <- seg_raw[keep]
  if (length(v) == 0) {
    stop(sprintf("No rows with a usable numeric value in &#x27;%s' remained after cleaning.", score_h))
  }

Step 3: Scale checks: HARD. 1-5 scales stop; outside 0-10 drops.

A column whose values sit entirely in 1..5 is a 1-5 rating scale, not the 0-10 NPS question. Rescaling would fabricate promoter/detractor categories that were never asked, so the analysis stops instead.

if (min(v) >= 1 && max(v) <= 5) {
    stop(sprintf(
      paste0("The score column &#x27;%s' only contains values from %s to %s — it looks like a 1-5 rating scale. ",
             "NPS is defined on the 0-10 likelihood-to-recommend scale(detractors 0-6, passives 7-8, promoters 9-10), ",
             "and a 1-5 scale cannot be honestly rescaled onto it — the categories would be invented, not measured. ",
             "This analysis stops rather than silently rescale your data. ",
             "For 1-5 ratings, use a distribution or group-comparison analysis instead, or field the question on a 0-10 scale."),
      score_h,
      format(round(min(v), 2), scientific = FALSE),
      format(round(max(v), 2), scientific = FALSE)
    ))
  }

  in_range <- v >= 0 & v <= 10
  n_out_of_range <- sum(!in_range)
  if (n_out_of_range > 0.5 * length(v)) {
    stop(sprintf("More than half of the values in &#x27;%s' fall outside 0-10 — this does not look like the 0-10 NPS question. Map the raw 0-10 likelihood-to-recommend column.", score_h))
  }
  v <- v[in_range]
  if (has_segment) seg_raw <- seg_raw[in_range]

  n_non_integer <- sum(v != round(v))
  v <- round(v)

  final_rows <- length(v)
  rows_removed <- initial_rows - final_rows
  if (final_rows < 10) {
    stop(sprintf("Only %d usable 0-10 responses remained in &#x27;%s' — at least 10 are needed to compute an NPS.", final_rows, score_h))
  }

Step 4: Segment cleaning — blanks to Missing, lump beyond 8 levels

lumped_levels <- character(0)
  segment_constant <- FALSE
  if (has_segment) {
    seg_raw[is.na(seg_raw) | trimws(seg_raw) == ""] <- "Missing"
    tab <- sort(table(seg_raw), decreasing = TRUE)
    if (length(tab) > 8) {
      keep_lv <- names(tab)[1:8]
      lumped_levels <- setdiff(names(tab), keep_lv)
      seg_raw[seg_raw %in% lumped_levels] <- "Other"
    }
    if (length(unique(seg_raw)) < 2) {
      segment_constant <- TRUE
      has_segment <- FALSE
    }
  }

Step 5: NPS with the classical multinomial variance formula

Var(NPS proportion) = (p9 + p0_6 - (p9 - p0_6)^2) / n, where p9 is the promoter share and p0_6 the detractor share. SE in points = 100 x sqrt(Var).

nps_stats <- function(x) {
    n <- length(x)
    p_prom <- mean(x >= 9)
    p_det  <- mean(x <= 6)
    p_pass <- 1 - p_prom - p_det
    nps <- 100 * (p_prom - p_det)
    var_prop <- max(0, (p_prom + p_det - (p_prom - p_det)^2) / n)
    se_pts <- 100 * sqrt(var_prop)
    list(n = n,
         prom_pct = 100 * p_prom, pass_pct = 100 * p_pass, det_pct = 100 * p_det,
         nps = nps, se_pts = se_pts,
         ci_low = nps - 1.96 * se_pts, ci_high = nps + 1.96 * se_pts,
         ci_half = 1.96 * se_pts)
  }
  fmt_ci <- function(s) paste0(fmt_pts(s$ci_low), " to ", fmt_pts(s$ci_high))

  overall <- nps_stats(v)

Step 6: Per-segment NPS + the gated best-vs-worst comparison

seg_stats <- NULL
  gap <- NULL
  gap_verdict <- "none"     # none | underpowered | overlap | different | not_significant
  gap_text <- ""
  if (has_segment) {
    levs <- names(sort(table(seg_raw), decreasing = TRUE))
    seg_stats <- lapply(levs, function(l) c(list(segment = l), nps_stats(v[seg_raw == l])))

NA-safe best/worst pick (LAT-1445 class: never which.max over possible NAs)

nps_vals <- sapply(seg_stats, function(s) s$nps)
    ok <- which(!is.na(nps_vals))
    best  <- seg_stats[[ok[which.max(nps_vals[ok])]]]
    worst <- seg_stats[[ok[which.min(nps_vals[ok])]]]
    gap <- list(best = best, worst = worst, diff = best$nps - worst$nps,
                z = NA_real_, p = NA_real_,
                cis_overlap = best$ci_low <= worst$ci_high)

    if (best$segment != worst$segment) {
      if (best$n >= 30 && worst$n >= 30) {

Two-proportion-style z-test on the NPS difference (both n >= 30)

se_diff <- sqrt(best$se_pts^2 + worst$se_pts^2)
        if (se_diff > 0) {
          gap$z <- gap$diff / se_diff
          gap$p <- 2 * stats::pnorm(-abs(gap$z))
        }
        if (gap$cis_overlap) {
          gap_verdict <- "overlap"
          gap_text <- paste0(
            "The widest gap is ", fmt_pts(gap$diff), " points, between ", best$segment,
            " (NPS ", fmt_pts(best$nps), ", 95% CI ", fmt_ci(best), ") and ", worst$segment,
            " (NPS ", fmt_pts(worst$nps), ", 95% CI ", fmt_ci(worst),
            "), but their 95% confidence intervals overlap — this gap must not be called a real difference",
            if (!is.na(gap$p) && gap$p < 0.05) " despite the nominal test result" else "", ". ",
            "Treat the segments as statistically indistinguishable on this sample."
          )
        } else if (!is.na(gap$p) && gap$p < 0.05) {
          gap_verdict <- "different"
          gap_text <- paste0(
            best$segment, " (NPS ", fmt_pts(best$nps), ", 95% CI ", fmt_ci(best),
            ") is genuinely ahead of ", worst$segment, " (NPS ", fmt_pts(worst$nps),
            ", 95% CI ", fmt_ci(worst), "): a gap of ", fmt_pts(gap$diff),
            " points that is statistically significant(z = ",
            format(round(gap$z, 1), scientific = FALSE), ", ", fmt_p(gap$p),
            "), with non-overlapping confidence intervals."
          )
        } else {
          gap_verdict <- "not_significant"
          gap_text <- paste0(
            "The gap of ", fmt_pts(gap$diff), " points between ", best$segment,
            " and ", worst$segment, " is not statistically reliable(",
            fmt_p(gap$p), ") — treat it as noise on this sample."
          )
        }
      } else {
        gap_verdict <- "underpowered"
        small <- if (best$n < 30 && worst$n < 30) paste0(best$segment, " (n = ", best$n, ") and ", worst$segment, " (n = ", worst$n, ") both have")
                 else if (best$n < 30) paste0(best$segment, " (n = ", best$n, ") has")
                 else paste0(worst$segment, " (n = ", worst$n, ") has")
        gap_text <- paste0(
          "The observed gap between ", best$segment, " (NPS ", fmt_pts(best$nps),
          ") and ", worst$segment, " (NPS ", fmt_pts(worst$nps), ") is ",
          fmt_pts(gap$diff), " points, but the comparison is underpowered: ",
          small, " fewer than 30 responses, so no test was run and this gap should not be read as a real difference."
        )
      }
    }
  }

Step 7: Chart + table datasets (semantic column names)

cnt <- as.integer(table(factor(v, levels = 0:10)))
  score_distribution_df <- data.frame(
    score = as.character(0:10),
    count = cnt,
    stringsAsFactors = FALSE
  )

  cat_labels <- c("Promoters(9-10)", "Passives(7-8)", "Detractors(0-6)")
  mix_source <- if (has_segment) seg_stats else list(c(list(segment = "Overall"), overall))
  nps_mix_df <- do.call(rbind, lapply(mix_source, function(s) data.frame(
    segment_label = s$segment,
    nps_category = cat_labels,
    share_pct = round(c(s$prom_pct, s$pass_pct, s$det_pct), 1),
    stringsAsFactors = FALSE
  )))
  rownames(nps_mix_df) <- NULL

  table_source <- if (has_segment) seg_stats[order(-sapply(seg_stats, function(s) s$nps))]
                  else list(c(list(segment = "Overall"), overall))
  segment_nps_df <- do.call(rbind, lapply(table_source, function(s) data.frame(
    segment = s$segment,
    n = s$n,
    nps = round(s$nps, 1),
    ci = paste0(fmt_pts(s$ci_low), " to ", fmt_pts(s$ci_high)),
    flag = if (s$n < 30) "low sample(fewer than 30)" else "",
    stringsAsFactors = FALSE
  )))
  rownames(segment_nps_df) <- NULL

Step 8: Honesty notes computed from the data

wide_ci <- overall$ci_half >= 15
  wide_note <- if (wide_ci) paste0(
    "Caution: with only ", format(overall$n, big.mark = ","), " responses the NPS point score is noisy — ",
    "the 95% confidence interval spans ", as.integer(round(overall$ci_high - overall$ci_low)),
    " points(", fmt_ci(overall), "), so ", fmt_pts(overall$nps),
    " is a wide, rough estimate rather than a precise score."
  ) else ""

  methods_df <- data.frame(
    item = c(
      "Score categories",
      "NPS formula",
      "Confidence interval",
      "This dataset",
      "Segment comparison",
      "Scale validation",
      "Small-sample policy"
    ),
    detail = c(
      "Promoters score 9-10, passives 7-8, detractors 0-6 on the 0-10 likelihood-to-recommend scale.",
      "NPS = % promoters minus % detractors, stated as a point score from -100 to +100.",
      "Classical multinomial variance formula: Var(NPS) = (p_promoter + p_detractor - (p_promoter - p_detractor)^2) / n on the proportion scale; SE in points = 100 x sqrt(Var); 95% CI = NPS plus or minus 1.96 x SE.",
      paste0("n = ", format(overall$n, big.mark = ","), " valid responses; SE = ",
             format(round(overall$se_pts, 1), scientific = FALSE), " points; the 95% interval spans ",
             as.integer(round(overall$ci_high - overall$ci_low)), " points(", fmt_ci(overall), ")."),
      "Two-proportion-style z-test on the NPS difference between the best and worst segment, run only when both have at least 30 responses; otherwise the comparison is reported as underpowered. Gaps with overlapping 95% confidence intervals are never called differences.",
      "Values outside 0-10 are dropped and reported. A column whose values sit entirely within 1-5 stops the analysis — a 1-5 rating scale cannot be honestly rescaled to NPS.",
      "The confidence interval width in points is always stated alongside the score, so a small-sample NPS reads as the rough estimate it is."
    ),
    stringsAsFactors = FALSE
  )

  metrics <- list(
    `NPS` = fmt_pts(overall$nps),
    `95% CI` = fmt_ci(overall),
    `Promoters %` = round(overall$prom_pct, 1),
    `Passives %` = round(overall$pass_pct, 1),
    `Detractors %` = round(overall$det_pct, 1),
    `Responses` = overall$n
  )

  json_output <- list(
    answer = paste0(
      "NPS from ", format(overall$n, big.mark = ","), " responses to ", score_h, ": ",
      fmt_pts(overall$nps), " (95% CI ", fmt_ci(overall), "), from ",
      round(overall$prom_pct, 1), "% promoters, ", round(overall$pass_pct, 1),
      "% passives, and ", round(overall$det_pct, 1), "% detractors. ",
      if (nzchar(gap_text)) gap_text
      else if (segment_constant) paste0("The mapped ", segment_h, " column has a single value, so no segment comparison was possible.")
      else "No segment column was mapped, so the score is reported overall only.",
      if (nzchar(wide_note)) paste0(" ", wide_note) else ""
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "score_distribution",
        "nps_mix", "segment_nps", "methods"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows = initial_rows, final_rows = final_rows,
    rows_removed = rows_removed,
    score_h = score_h, segment_h = segment_h, has_segment = has_segment,
    n_na_score = n_na_score, n_out_of_range = n_out_of_range,
    n_non_integer = n_non_integer,
    lumped_levels = lumped_levels, segment_constant = segment_constant,
    overall = overall, seg_stats = seg_stats,
    segment_nps_df = segment_nps_df,
    score_distribution_df = score_distribution_df,
    nps_mix_df = nps_mix_df, methods_df = methods_df,
    gap = gap, gap_verdict = gap_verdict, gap_text = gap_text,
    wide_ci = wide_ci, wide_note = wide_note,
    fmt_pts = fmt_pts, fmt_ci = fmt_ci, fmt_p = fmt_p,
    metrics = metrics, json_output = json_output
  )
}
Your data has more stories to tell.Run any analysis on your own data: R modules you own, interactive reports, AI insights, and PDF export. 500 free credits when you finish onboarding.
Try Free — No SignupSign Up Free

Your turn

Bring your own data and the question you actually need answered.

CympleData Scientist Send me your data and question, I’ll send you the analytics. ds@mcpanalytics.ai

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