Standard Distribution Fit
Executive Summary

Executive Summary

Best-fitting shape for Response Time ms

Observations
700
Best Fitting Shape
lognormal
Best Fit AIC
4312.24
Runner-Up AIC Gap
53.06
Candidates Fitted
6
Candidates Skipped
2
Of 6 candidate distributions fitted to Response Time ms across 700 values, the lognormal fits best (meanlog = 2.0091, sdlog = 0.7042), at AIC 4,312. It leads gamma by 53.06 AIC points. The goodness-of-fit test does not reject it (p = 0.883), which means the data is not inconsistent with that shape — not that the shape is confirmed. At 700 values the goodness-of-fit tests have moderate power: they will catch a badly wrong shape but not a subtly wrong one, so a large p-value means the candidate is not contradicted rather than confirmed. A fitted distribution is the best description among the candidates offered, not a fact about the process that generated Response Time ms.
Suggested Interpretation

Response times follow a lognormal distribution best. The fitted lognormal (meanlog = 2.0091, sdlog = 0.7042) scores AIC 4,312.24, leading gamma by 53.06 points—a substantial gap indicating a real preference. The goodness-of-fit test does not reject it (p = 0.883), meaning the data is consistent with that shape, though consistency is not confirmation. At 700 observations the tests have moderate power: they catch badly wrong shapes but miss subtle mismatches. A fitted distribution ranks best among the candidates offered, not a claim about the process generating response times.

Overview

Analysis Overview

Maximum-likelihood distribution fitting on Response Time ms across 700 values.

N Observations700
N Fitted6
N Skipped2
N Failed0
Suggested Interpretation

Maximum-likelihood fitting tests 6 candidate distributions against 700 response-time observations. The method estimates the parameter values that make the observed data most probable under each shape, then ranks them by Akaike Information Criterion—a metric that rewards goodness of fit while penalizing extra parameters. Two discrete candidates (Poisson and negative binomial) were skipped because response times contain fractional values; all 6 continuous candidates converged successfully. This analysis identifies which named shape best describes the data, a separate question from whether the column is normal enough for a t-test.

Data Preparation

Data Quality

Readable values, ties, and which candidates were applicable.

Initial Rows700
Final Rows700
Rows Removed0
Values Missing0
Suggested Interpretation

All 700 rows yielded readable values; no imputation was needed. Response times range from 0.824 to 54.75 milliseconds, all strictly positive. Ten values repeat earlier observations due to rounding or discrete recording, introducing ties that make the Kolmogorov-Smirnov p-value approximate rather than exact. Because the data are fractional, Poisson and negative binomial were ruled out on domain grounds—both require whole numbers. All positive-support continuous candidates remained applicable.

Data Table

Candidate Ranking

Every fitted candidate with its parameters, log-likelihood, AIC and BIC.

DistributionFamilyParametersLog LikelihoodAicDelta AicBic
lognormalcontinuousmeanlog = 2.0091, sdlog = 0.7042-2154431204321
gammacontinuousshape = 2.1749, rate = 0.2278-2181436553.064374
weibullcontinuousshape = 1.4356, scale = 10.61-22084420107.54429
exponentialcontinuousrate = 0.1047-22794561248.64565
normalcontinuousmean = 9.5477, sd = 7.4443-23984801488.74810
uniformcontinuousmin = 0.8240, max = 54.75-2791558712745596
Suggested Interpretation

The short answer

Response times follow a lognormal distribution. Lognormal leads all competing shapes by a decisive margin: it outperforms gamma (the next-best candidate) by 53.06 AIC points, and both information criteria (AIC and BIC) agree on this ranking, confirming the result is robust.

The detail

Maximum-likelihood fitting was applied to six candidate distributions. Lognormal achieved AIC 4,312.24 with parameters meanlog = 2.0091 and sdlog = 0.7042. Gamma followed at AIC 4,365.3 (delta_aic = 53.06), weibull at AIC 4,419.71 (delta_aic = 107.46), and exponential, normal, and uniform progressively worse. BIC 4,321.34 for lognormal versus 4,374.41 for gamma confirms agreement between the two criteria within the continuous family.

What this can't tell you

AIC and BIC are comparable only within the continuous family or within the discrete family, never across them. All candidates here are continuous, so the ranking is valid. The fit quality in absolute terms — whether 53.06 AIC points represents a practically meaningful difference in predictive accuracy — depends on sample size and context; the ranking itself is definitive within this candidate set.

Visualization

Observed vs Fitted

The histogram of Response Time ms with the winning fit drawn over it, bin by bin.

Suggested Interpretation

The short answer

The lognormal fit tracks the bulk of response times well but systematically overshoots the region around 10–23 milliseconds and undershoots the tail beyond 24 milliseconds. The largest single discrepancy is 9.4 observations in the 10.81–12.81 ms bin, where the model predicts 54.43 but observes only 45. This pattern matters most if your concern is extreme response times or service-level targets in the tail.

The detail

Across 27 bins, the fitted lognormal matches observed counts closely in the early range: 0.824–2.821 ms (observed 56, fitted 58.01), 2.821–4.819 ms (observed 131, fitted 128.69), and 4.819–6.816 ms (observed 134, fitted 127.14). From 10.81 ms onward, the model consistently overestimates: 20.80–22.79 ms (observed 3, fitted 11.44), and underestimates in the far tail: 24.79–26.79 ms (observed 15, fitted 6.52). The fit describes the central tendency but does not fully capture the right-tail behavior.

What this can't tell you

Bin-by-bin comparison does not provide a formal goodness-of-fit test (such as Kolmogorov–Smirnov or Anderson–Darling). The visual gap indicates where the model diverges, but whether that divergence is statistically significant enough to reject lognormal as a whole would require an additional test. If tail behavior drives your decision (e.g., SLA enforcement), the observed tail excess warrants closer inspection.

Visualization

Q-Q Plot

Observed quantiles against the quantiles the winning fit predicts.

Suggested Interpretation

The observed and theoretical quantiles correlate at 0.9947, a very tight relationship. Points track the diagonal closely through the lower and middle sections of the distribution, indicating accurate quantile prediction where most response times cluster. At the upper tail, the plot curves away from the diagonal: the model underestimates the largest observed values. For example, the highest observed response time (54.75 ms) is paired against a theoretical quantile of approximately 45 ms, showing the lognormal predicts a less extreme right tail than the data exhibit. This upward curvature at the top end means the fitted shape understates the frequency of the longest response times—a material concern if the analysis will support capacity planning or service-level risk assessment.

Data Table

Goodness of Fit

Formal tests of each fitted candidate against the observed data.

DistributionTestStatisticP ValueAnderson DarlingAd Reading
lognormalKolmogorov-Smirnov0.02210.8830.392no evidence of misfit (below 1.93)
gammaKolmogorov-Smirnov0.07060.0025.457strong misfit (above 3.88)
weibullKolmogorov-Smirnov0.0797<0.0019.865strong misfit (above 3.88)
exponentialKolmogorov-Smirnov0.1789<0.00138.14strong misfit (above 3.88)
normalKolmogorov-Smirnov0.1555<0.00136.55strong misfit (above 3.88)
uniformKolmogorov-Smirnov0.5874<0.001544.6strong misfit (above 3.88)
Suggested Interpretation

Only the lognormal passes the 5 percent significance threshold. Its Kolmogorov-Smirnov statistic is 0.0221 (p = 0.883) and Anderson-Darling is 0.392, both indicating no evidence of misfit. Gamma fails decisively: KS statistic 0.0706 (p = 0.002), AD 5.457 (strong misfit, above the 3.88 band). Weibull, exponential, normal, and uniform all register p < 0.001 and strong AD readings. Passing does not confirm the lognormal is the true generator; several shapes can pass the same data, and the p-values are optimistic because parameters were estimated from the same values the tests then score. At 700 observations, moderate test power catches badly wrong shapes but tolerates subtle mismatches, so a large p-value signals absence of contradiction rather than positive confirmation.

Data Table

Methods & Disclosure

How the fits were estimated, ranked, and tested — and what the result does not say.

ItemDetail
EstimationMaximum likelihood. Closed-form estimators for normal, lognormal, exponential, uniform and poisson; base optim (Nelder-Mead, two passes) for gamma, weibull and the negative binomial. A search that fails to converge is reported as such, never as a fit.
RankingAkaike Information Criterion, with the Bayesian Information Criterion alongside. The gap to the runner-up here is 53.06 AIC points; a gap under about 2 means the two candidates are practically indistinguishable on this data.
Comparability across familiesContinuous candidates are scored on a probability density and discrete ones on a probability mass, so their likelihoods are on different scales. AIC is compared only WITHIN a family, never across the dashed line between them.
Candidates skippedpoisson (requires whole numbers; Response Time ms contains fractional values); negative binomial (requires whole numbers; Response Time ms contains fractional values)
Candidates that did not convergenone — every applicable candidate converged
Goodness of fitKolmogorov-Smirnov (base stats) and an Anderson-Darling statistic computed directly from its definition for continuous candidates; a Pearson chi-square on merged integer cells for discrete ones. Because the parameters were estimated from the same data the tests score, both p-values are optimistic — the true rejection rate is higher than the nominal one.
Effect of sample sizeAt 700 values the goodness-of-fit tests have moderate power: they will catch a badly wrong shape but not a subtly wrong one, so a large p-value means the candidate is not contradicted rather than confirmed.
Uniform fit caveatThe uniform fit places its boundaries exactly at the smallest and largest observed values, which flatters its likelihood. Treat a uniform win with suspicion unless the quantity really is bounded.
What a fit isA fitted distribution is a description that survived comparison with the alternatives offered here, not a fact about the process that generated Response Time ms. A shape outside this panel may fit better still.
Which tool answers which questionThis analysis asks which named shape best describes Response Time ms as a whole. The separate normality and outlier screening tool asks a different question — whether a column is close enough to normal, and free enough of outliers, for a t-test or a regression to be trustworthy. Use that one before a parametric test; use this one before a simulation, a queueing model or a pricing model.
Suggested Interpretation

Every candidate was fitted by maximum likelihood: normal, lognormal, exponential, and uniform via closed-form estimators; gamma and Weibull via numerical optimization (Nelder-Mead, two passes). Ranking uses AIC within the continuous family; BIC is shown for reference. Poisson and negative binomial were skipped because response times contain fractional values; all 6 continuous candidates converged. AIC is not compared across discrete and continuous families—they score on different probability scales (mass vs. density). The lognormal is the best fit among these 6 candidates on this sample, not a claim that response times are generated by that process. A separate normality and outlier screening tool answers whether the data are close enough to normal for a t-test; this tool supports simulations, queueing models, and pricing models that require the full distributional shape.

Methodology

Methodology

Statistical methodology and diagnostics for Distribution Fitting

Statistical Method

Distribution Fitting

Standard-library analysis: what distribution does my data follow? Map one numeric column and every plausible candidate shape is fitted to it by maximum likelihood — normal, lognormal, gamma, weibull, exponential and uniform, plus poisson and negative binomial when the values are counts. The candidates are ranked by AIC and BIC, each is put to a goodness-of-fit test, the winner is drawn over the observed histogram bin by bin, and a Q-Q plot shows where the fit holds and where it breaks. Candidates whose support does not contain your data are skipped with a stated reason rather than fitted to nonsense, and a search that fails to converge is reported as a failure rather than as a fit.

Data
N = 700 observations
Assumptions
  • The values are independent draws from one distribution — not a mixture of regimes and not a time series with trend
  • The column is numeric or cleanly convertible
  • Missing values are missing at random; they are set aside rather than imputed
  • The candidate panel contains a shape close to the truth — a distribution outside the panel may fit better still
Limitations
  • A fitted distribution is the best of the candidates offered, not a fact about the process that generated the data
  • Passing a goodness-of-fit test does not confirm a distribution; with small samples almost nothing is rejected and with very large samples almost everything is
  • Because the parameters are estimated from the same data the tests score, the reported p-values are optimistic
  • AIC is comparable only within the continuous family or within the discrete family, never across them, because densities and probabilities are on different scales
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

Distribution Fitting — What Shape Does This Column Follow?

Fits a panel of candidate probability distributions to one numeric column by maximum likelihood, ranks them by AIC and BIC, tests each fit for goodness of fit, and shows the winner drawn over a histogram of the actual data plus a quantile-quantile plot.

Why This Method?

Pricing models, queueing models, reliability targets, risk capital and simulation all start from an assumed distribution. Guessing that shape from a histogram is unreliable. Fitting every plausible candidate by maximum likelihood and ranking them on the same information criterion makes the choice explicit, comparable, and reproducible — and makes visible how close the runners-up were.

What This Analysis Covers

  • Maximum-likelihood fits of normal, lognormal, gamma, weibull, exponential

and uniform, plus poisson and negative binomial when the data is counts

  • A ranking by AIC and BIC inside each family, with the runner-up gap
  • Kolmogorov-Smirnov and Anderson-Darling goodness of fit for continuous

candidates, a Pearson chi-square goodness of fit for discrete ones

  • The winning fit drawn over the observed histogram, and a Q-Q plot
  • An explicit statement of what the sample size does to the tests' power

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {value}. All narrative is derived from the user's own column name 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

Step 1: Find the mapped column and humanize its name

initial_rows <- nrow(df)
  if (!("value" %in% names(df))) {
    stop("column_mapping must map one numeric column to &#x27;value'.")
  }
  var_name <- unname(humanize_semantic("value", col_map))
  if (length(var_name) == 0 || !nzchar(var_name)) var_name <- "the mapped column"

Step 2: Coerce to numeric under the 95 percent rule

v_raw <- df[["value"]]
  if (!is.numeric(v_raw)) {
    conv <- suppressWarnings(as.numeric(as.character(v_raw)))
    n_orig <- sum(!is.na(v_raw) & trimws(as.character(v_raw)) != "")
    if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
      v_raw <- conv
    } else {
      stop(sprintf(paste0("The column mapped for fitting, %s, is not numeric — ",
                          "fewer than 95 percent of its non-blank entries could ",
                          "be read as numbers. Map a numeric column instead."),
                   var_name))
    }
  }

Missing values are set aside, never imputed: an imputed value would change the very shape this analysis is trying to describe.

x_all <- suppressWarnings(as.numeric(v_raw))
  n_missing <- as.integer(sum(!is.finite(x_all)))
  x <- x_all[is.finite(x_all)]
  n <- length(x)
  final_rows <- n
  rows_removed <- initial_rows - final_rows

  if (n < 20) {
    stop(sprintf(paste0("Distribution fitting needs at least 20 usable values in %s; ",
                        "only %d were found. With fewer than 20 values no candidate ",
                        "shape can be told apart from any other."), var_name, n))
  }
  sd_x <- sd(x)
  if (!is.finite(sd_x) || sd_x == 0) {
    stop(sprintf(paste0("%s is constant — every usable row holds the value %s. ",
                        "A constant column has no distribution to fit."),
                 var_name, fmt_num(x[1])))
  }
  n_ties <- as.integer(n - length(unique(x)))

Step 3: Screen candidates against their domains

A candidate whose support does not contain the data is SKIPPED with a stated reason. It is never fitted to nonsense and never silently dropped.

x_min <- min(x); x_max <- max(x)
  strictly_positive <- x_min > 0
  count_shaped <- (x_min >= 0) && all(abs(x - round(x)) < 1e-8)

  pos_reason <- sprintf(
    "requires strictly positive values; the smallest value in %s is %s",
    var_name, fmt_num(x_min))
  int_reason <- if (x_min < 0) {
    sprintf("requires non-negative whole numbers; %s contains negative values(smallest is %s)",
            var_name, fmt_num(x_min))
  } else {
    sprintf("requires whole numbers; %s contains fractional values", var_name)
  }

  fits <- list()
  for (dist in .CANDIDATES) {
    applicable <- TRUE; reason <- ""
    if (dist %in% c("lognormal", "gamma", "weibull", "exponential") && !strictly_positive) {
      applicable <- FALSE; reason <- pos_reason
    }
    if (dist %in% .DISCRETE && !count_shaped) {
      applicable <- FALSE; reason <- int_reason
    }
    if (!applicable) {
      fits[[dist]] <- list(dist = dist,
                           family = if (dist %in% .DISCRETE) "discrete" else "continuous",
                           status = "skipped", reason = reason,
                           params = numeric(0), k = NA_integer_,
                           loglik = NA_real_, aic = NA_real_, bic = NA_real_)
    } else {
      fits[[dist]] <- .fit_candidate(dist, x)
    }
  }

  status_vec <- vapply(fits, function(f) f$status, character(1))
  fitted_names <- names(fits)[status_vec == "fitted"]
  skipped <- names(fits)[status_vec == "skipped"]
  failed <- names(fits)[status_vec == "did not converge"]
  if (length(fitted_names) == 0) {
    stop(sprintf(paste0("No candidate distribution could be fitted to %s: every ",
                        "candidate was either outside its valid domain or failed ",
                        "to converge."), var_name))
  }

Step 4: Rank within family

AIC compares models fitted to the SAME data on the SAME likelihood scale. Continuous candidates use a density, discrete candidates a probability, so their AIC values are NOT comparable to one another. Ranking therefore happens inside each family and the report says so.

fam_vec <- vapply(fits[fitted_names], function(f) f$family, character(1))
  aic_vec <- vapply(fits[fitted_names], function(f) f$aic, numeric(1))
  bic_vec <- vapply(fits[fitted_names], function(f) f$bic, numeric(1))
  ll_vec  <- vapply(fits[fitted_names], function(f) f$loglik, numeric(1))
  k_vec   <- vapply(fits[fitted_names], function(f) as.integer(f$k), integer(1))

  delta_vec <- rep(NA_real_, length(fitted_names))
  names(delta_vec) <- fitted_names
  for (fam in unique(fam_vec)) {
    idx <- which(fam_vec == fam)
    ok_idx <- idx[is.finite(aic_vec[idx])]
    if (length(ok_idx) > 0) {
      delta_vec[ok_idx] <- aic_vec[ok_idx] - min(aic_vec[ok_idx])
    }
  }

  primary_family <- if (count_shaped) "discrete" else "continuous"
  prim_idx <- which(fam_vec == primary_family & is.finite(aic_vec))
  if (length(prim_idx) == 0) {
    prim_idx <- which(is.finite(aic_vec))
    primary_family <- fam_vec[prim_idx[which.min(aic_vec[prim_idx])]]
    prim_idx <- which(fam_vec == primary_family & is.finite(aic_vec))
  }

NA-filtered before which.min (LAT-1445 crash class).

win_i <- prim_idx[which.min(aic_vec[prim_idx])]
  winner <- fits[[fitted_names[win_i]]]
  other_prim <- setdiff(prim_idx, win_i)
  runner_up_gap <- if (length(other_prim) > 0) {
    min(aic_vec[other_prim]) - aic_vec[win_i]
  } else NA_real_
  runner_up_name <- if (length(other_prim) > 0) {
    fitted_names[other_prim[which.min(aic_vec[other_prim])]]
  } else "none"

  ord <- order(fam_vec != primary_family, aic_vec)
  fit_ranking_df <- data.frame(
    distribution   = fitted_names[ord],
    family         = fam_vec[ord],
    parameters     = vapply(fitted_names[ord],
                            function(nm) .param_string(fits[[nm]]), character(1)),
    log_likelihood = round(ll_vec[ord], 2),
    aic            = round(aic_vec[ord], 2),
    delta_aic      = round(delta_vec[ord], 2),
    bic            = round(bic_vec[ord], 2),
    stringsAsFactors = FALSE
  )
  rownames(fit_ranking_df) <- NULL

Step 5: Goodness of fit

Continuous candidates: Kolmogorov-Smirnov from base stats plus an Anderson-Darling statistic computed directly from its definition. Discrete candidates: a Pearson chi-square on the binned counts, because the Kolmogorov-Smirnov test assumes a continuous distribution.

gof_rows <- list()
  ks_p_map <- setNames(rep(NA_real_, length(fitted_names)), fitted_names)
  for (nm in fitted_names) {
    f <- fits[[nm]]
    pf <- .dist_pfun(nm, f$params)
    if (f$family == "continuous") {
      kt <- tryCatch(suppressWarnings(ks.test(x, pf)), error = function(e) NULL)
      kstat <- if (!is.null(kt)) as.numeric(kt$statistic) else NA_real_
      kp <- if (!is.null(kt)) as.numeric(kt$p.value) else NA_real_
      a2 <- .anderson_darling(x, pf)
      gof_rows[[length(gof_rows) + 1]] <- data.frame(
        distribution = nm, test = "Kolmogorov-Smirnov",
        statistic = if (is.finite(kstat)) round(kstat, 4) else NA_real_,
        p_value = fmt_p(kp),
        anderson_darling = if (is.finite(a2)) round(a2, 3) else NA_real_,
        ad_reading = .ad_verdict(a2),
        stringsAsFactors = FALSE)
      ks_p_map[[nm]] <- kp
    } else {
      cg <- .chisq_gof(round(x), .dist_pmf(nm, f$params), f$k)
      gof_rows[[length(gof_rows) + 1]] <- data.frame(
        distribution = nm,
        test = if (is.na(cg$df)) "chi-square(too few cells)"
               else sprintf("chi-square on %d cells, %d df", cg$cells, cg$df),
        statistic = if (is.finite(cg$stat)) round(cg$stat, 4) else NA_real_,
        p_value = fmt_p(cg$p),
        anderson_darling = NA_real_,
        ad_reading = "not applicable to a discrete fit",
        stringsAsFactors = FALSE)
      ks_p_map[[nm]] <- cg$p
    }
  }
  gof_df <- do.call(rbind, gof_rows)
  gof_df <- gof_df[order(match(gof_df$distribution, fit_ranking_df$distribution)), , drop = FALSE]
  rownames(gof_df) <- NULL
  winner_gof_p <- ks_p_map[[winner$dist]]
  n_not_rejected <- sum(is.finite(ks_p_map) & ks_p_map >= 0.05)

Step 6: Observed versus fitted, bin by bin (the overlay)

win_pf <- .dist_pfun(winner$dist, winner$params)
  if (winner$family == "discrete") {
    xi <- round(x)
    top <- min(max(xi), 25)
    vals <- 0:top
    obs <- as.integer(table(factor(xi, levels = vals)))
    pmf <- .dist_pmf(winner$dist, winner$params)
    pr <- suppressWarnings(pmf(vals)); pr[!is.finite(pr)] <- 0
    labs <- as.character(vals)
    if (max(xi) > top) {
      obs <- c(obs, as.integer(sum(xi > top)))
      pr <- c(pr, max(0, 1 - sum(pr)))
      labs <- c(labs, paste0(top + 1, " or more"))
    }
    expected <- n * pr
  } else {
    nb <- max(8L, min(30L, as.integer(ceiling(sqrt(n)))))
    brks <- seq(x_min, x_max, length.out = nb + 1)
    obs <- as.integer(table(cut(x, breaks = brks, include.lowest = TRUE)))
    lo <- suppressWarnings(win_pf(brks[-(nb + 1)]))
    hi <- suppressWarnings(win_pf(brks[-1]))
    pr <- hi - lo
    pr[!is.finite(pr)] <- 0
    expected <- n * pr
    labs <- sprintf("%s to %s", fmt_num(brks[-(nb + 1)]), fmt_num(brks[-1]))
  }
  overlay_df <- data.frame(
    bin = rep(labs, each = 2),
    count = as.numeric(rbind(obs, round(expected, 2))),
    series = rep(c("Observed", paste0("Fitted ", winner$dist)), times = length(labs)),
    stringsAsFactors = FALSE
  )
  rownames(overlay_df) <- NULL
  bin_gap <- max(abs(obs - expected))
  worst_bin <- labs[which.max(abs(obs - expected))]

Step 7: Q-Q points (thinned deterministically to at most 1,000)

win_qf <- .dist_qfun(winner$dist, winner$params)
  xs <- sort(x)
  pp <- (seq_len(n) - 0.5) / n
  theo <- suppressWarnings(win_qf(pp))
  idx <- if (n > 1000) unique(round(seq(1, n, length.out = 1000))) else seq_len(n)
  qq_df <- data.frame(theoretical = theo[idx], sample = xs[idx],
                      stringsAsFactors = FALSE)
  qq_df <- qq_df[is.finite(qq_df$theoretical) & is.finite(qq_df$sample), , drop = FALSE]
  qq_df$theoretical <- round(qq_df$theoretical, 4)
  qq_df$sample <- round(qq_df$sample, 4)
  rownames(qq_df) <- NULL
  qq_cor <- if (nrow(qq_df) > 2) {
    suppressWarnings(cor(qq_df$theoretical, qq_df$sample))
  } else NA_real_

Step 8: The power statement — computed, and it names the actual n

power_text <- if (n < 50) {
    paste0("With only ", n, " value", .s(n), " the goodness-of-fit tests have ",
           "very little power: at this sample size they fail to reject almost ",
           "any candidate shape, so a large p-value here means \"too little ",
           "data to tell them apart\", not \"the data is this distribution\".")
  } else if (n <= 1000) {
    paste0("At ", format(n, big.mark = ","), " values the goodness-of-fit tests ",
           "have moderate power: they will catch a badly wrong shape but not a ",
           "subtly wrong one, so a large p-value means the candidate is not ",
           "contradicted rather than confirmed.")
  } else {
    paste0("At ", format(n, big.mark = ","), " values the goodness-of-fit tests ",
           "are highly sensitive: at this sample size they reject almost any ",
           "candidate, because no real dataset follows a textbook distribution ",
           "exactly. A small p-value here flags a detectable departure, not ",
           "necessarily one large enough to matter for your use.")
  }

Step 9: Disclosure table

skip_detail <- if (length(skipped) > 0) {
    paste(vapply(skipped, function(nm)
      paste0(nm, " (", fits[[nm]]$reason, ")"), character(1)), collapse = "; ")
  } else "none — every candidate was inside its valid domain"
  fail_detail <- if (length(failed) > 0) {
    paste(vapply(failed, function(nm)
      paste0(nm, " (", fits[[nm]]$reason, ")"), character(1)), collapse = "; ")
  } else "none — every applicable candidate converged"

  methods_df <- data.frame(
    item = c(
      "Estimation",
      "Ranking",
      "Comparability across families",
      "Candidates skipped",
      "Candidates that did not converge",
      "Goodness of fit",
      "Effect of sample size",
      "Uniform fit caveat",
      "What a fit is",
      "Which tool answers which question"),
    detail = c(
      paste0("Maximum likelihood. Closed-form estimators for normal, lognormal, ",
             "exponential, uniform and poisson; base optim(Nelder-Mead, two ",
             "passes) for gamma, weibull and the negative binomial. A search ",
             "that fails to converge is reported as such, never as a fit."),
      paste0("Akaike Information Criterion, with the Bayesian Information ",
             "Criterion alongside. The gap to the runner-up here is ",
             if (is.finite(runner_up_gap)) fmt_num(runner_up_gap, 2) else "not applicable",
             " AIC points; a gap under about 2 means the two candidates are ",
             "practically indistinguishable on this data."),
      paste0("Continuous candidates are scored on a probability density and ",
             "discrete ones on a probability mass, so their likelihoods are on ",
             "different scales. AIC is compared only WITHIN a family, never ",
             "across the dashed line between them."),
      skip_detail,
      fail_detail,
      paste0("Kolmogorov-Smirnov(base stats) and an Anderson-Darling statistic ",
             "computed directly from its definition for continuous candidates; a ",
             "Pearson chi-square on merged integer cells for discrete ones. ",
             "Because the parameters were estimated from the same data the tests ",
             "score, both p-values are optimistic — the true rejection rate is ",
             "higher than the nominal one."),
      power_text,
      paste0("The uniform fit places its boundaries exactly at the smallest and ",
             "largest observed values, which flatters its likelihood. Treat a ",
             "uniform win with suspicion unless the quantity really is bounded."),
      paste0("A fitted distribution is a description that survived comparison ",
             "with the alternatives offered here, not a fact about the process ",
             "that generated ", var_name, ". A shape outside this panel may fit ",
             "better still."),
      paste0("This analysis asks which named shape best describes ", var_name,
             " as a whole. The separate normality and outlier screening tool ",
             "asks a different question — whether a column is close enough to ",
             "normal, and free enough of outliers, for a t-test or a regression ",
             "to be trustworthy. Use that one before a parametric test; use this ",
             "one before a simulation, a queueing model or a pricing model.")),
    stringsAsFactors = FALSE
  )

  metrics <- list(
    `Observations`             = n,
    `Best Fitting Shape`       = winner$dist,
    `Best Fit AIC`             = round(winner$aic, 2),
    `Runner-Up AIC Gap`        = if (is.finite(runner_up_gap)) round(runner_up_gap, 2) else NA_real_,
    `Candidates Fitted`        = as.integer(length(fitted_names)),
    `Candidates Skipped`       = as.integer(length(skipped))
  )

  gap_phrase <- if (!is.finite(runner_up_gap)) {
    paste0("It was the only candidate in the ", primary_family, " family that could be fitted.")
  } else if (runner_up_gap < 2) {
    paste0("The gap to ", runner_up_name, " is only ", fmt_num(runner_up_gap, 2),
           " AIC points, so the two are practically indistinguishable on this data ",
           "and the ranking should not be read as a decision between them.")
  } else {
    paste0("It leads ", runner_up_name, " by ", fmt_num(runner_up_gap, 2),
           " AIC points.")
  }

  json_output <- list(
    answer = paste0(
      "Fitted ", length(fitted_names), " candidate distribution",
      .s(length(fitted_names)), " to ", var_name, " across ",
      format(n, big.mark = ","), " value", .s(n),
      " by maximum likelihood. The best fit inside the ", primary_family,
      " family is the ", winner$dist, " (", .param_string(winner),
      "), at AIC ", fmt_num(winner$aic, 2), ". ", gap_phrase, " ",
      if (length(skipped) > 0) paste0(
        length(skipped), " candidate", .s(length(skipped)), " ",
        if (length(skipped) == 1) "was" else "were",
        " skipped as inapplicable to this column: ",
        paste(skipped, collapse = ", "), ". ") else "",
      power_text,
      " A fitted distribution is a description that beat the alternatives, not a ",
      "fact about the process that generated ", var_name, "."
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "fit_comparison",
        "distribution_fit", "qq_plot", "goodness_of_fit", "methods"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows = initial_rows, final_rows = final_rows, rows_removed = rows_removed,
    var_name = var_name, x = x, n = n, n_missing = n_missing, n_ties = n_ties,
    x_min = x_min, x_max = x_max,
    strictly_positive = strictly_positive, count_shaped = count_shaped,
    fits = fits, fitted_names = fitted_names, skipped = skipped, failed = failed,
    primary_family = primary_family, winner = winner,
    runner_up_gap = runner_up_gap, runner_up_name = runner_up_name,
    fit_ranking_df = fit_ranking_df, gof_df = gof_df,
    overlay_df = overlay_df, qq_df = qq_df, methods_df = methods_df,
    bin_gap = bin_gap, worst_bin = worst_bin, qq_cor = qq_cor,
    winner_gof_p = winner_gof_p, n_not_rejected = n_not_rejected,
    power_text = power_text, gap_phrase = gap_phrase,
    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 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