Beginner Intermediate 21 min read

Chapter 33: Descriptive Statistics & Distributions

Before you can build a model, run an experiment, or communicate a finding, you need to understand your data. Descriptive statistics and probability distributions are the vocabulary of that understanding. They answer the most fundamental questions: Where is the data centered? How spread out is it? Is it symmetric or skewed? What shape does it follow? Without reliable answers to these questions, every downstream analysis — hypothesis tests, regression models, neural networks — rests on shaky ground.

This chapter builds that vocabulary from the ground up. We begin with the classical measures of center, spread, and shape, then introduce robust alternatives that hold up when data contains outliers or heavy tails. We explore the empirical distribution — the distribution that the data itself defines — and the graphical tools (histograms, kernel density estimates, empirical CDFs) that make it visible. Finally, we tour the major named probability distributions: the binomial, Poisson, normal, exponential, and their kin. For each we develop intuition, state the key parameters and moments, and show where it arises naturally in practice.

Throughout, we work in both Python (NumPy, SciPy, and Matplotlib) and R (base R and the ggplot2 / fitdistrplus ecosystem), because practitioners need fluency in both. By the end of this chapter you will be able to characterize any dataset quantitatively and graphically, select an appropriate parametric model, and fit and diagnose that model — skills you will use in every chapter that follows.

Runnable companion notebook for this chapter. Download the Jupyter notebook (.ipynb)

Learning Objectives

  • Compute and interpret measures of center (mean, median, mode), spread (variance, standard deviation, IQR, MAD), and shape (skewness, kurtosis) in Python and R.
  • Explain the difference between population parameters and sample statistics, and apply Bessel's correction correctly.
  • Construct and interpret histograms, kernel density estimates, and empirical CDFs, and recognize the trade-offs of each representation.
  • Define quantiles and the five-number summary; apply robust statistics (median, IQR, trimmed mean, Winsorization) to handle outliers.
  • Name the major discrete and continuous probability distributions, state their PMF/PDF and key parameters, and identify real-world scenarios that motivate each.
  • Fit parametric distributions to data using maximum-likelihood estimation with SciPy and R, and assess goodness of fit with Q-Q plots and formal tests.
  • Recognize heavy-tailed versus light-tailed behavior and explain why it matters for modeling and inference.

33.1 Measures of Center, Spread, and Shape Beginner

Measures of Center

The center of a distribution is the value around which the data tends to cluster. Three measures are in common use, each answering a slightly different question.

The arithmetic mean is the sum of all values divided by their count:

$$\bar{x} = \frac{1}{n}\sum_{i=1}^{n} x_i$$

The mean minimizes the sum of squared deviations, making it the natural center for least-squares methods. Its weakness is sensitivity to outliers: a single extreme value can drag it far from where most data lives.

The median is the middle value when observations are sorted. For even $n$, it is the average of the two middle values. The median minimizes the sum of absolute deviations and is far more resistant to outliers.

The mode is the most frequently occurring value. For continuous data it is the peak of the density; for discrete data it is the most common category. Distributions can be unimodal, bimodal, or multimodal, each with different modeling implications.

Measures of Spread

The variance quantifies average squared deviation from the mean:

$$s^2 = \frac{1}{n-1}\sum_{i=1}^{n}(x_i - \bar{x})^2$$

The $n-1$ denominator — Bessel's correction — makes $s^2$ an unbiased estimator of the population variance $\sigma^2$. Using $n$ instead underestimates because we are measuring deviations from the sample mean, which is itself estimated from the same data.

The standard deviation $s = \sqrt{s^2}$ restores the original units. The coefficient of variation $\text{CV} = s / \bar{x}$ (defined when $\bar{x} \neq 0$) is a dimensionless measure useful when comparing spread across variables with different scales.

The interquartile range IQR $= Q_3 - Q_1$ measures the spread of the middle 50% of data and is robust to outliers. The mean absolute deviation (MAD) and the related median absolute deviation $\text{MAD} = \text{median}(|x_i - \text{median}(x)|)$ are also robust alternatives.

PYTHON
import numpy as np
from scipy import stats

rng = np.random.default_rng(42)
x = rng.exponential(scale=2, size=500)   # right-skewed data

print(f"Mean:   {x.mean():.3f}")
print(f"Median: {np.median(x):.3f}")
print(f"Std:    {x.std(ddof=1):.3f}")
print(f"IQR:    {stats.iqr(x):.3f}")
print(f"MAD:    {stats.median_abs_deviation(x):.3f}")

For this right-skewed exponential sample you will see that the mean exceeds the median — a hallmark of positive skew.

Measures of Shape

Skewness measures asymmetry. The (Fisher) skewness of a distribution is:

$$\gamma_1 = \frac{\mu_3}{\sigma^3}$$

where $\mu_3 = E[(X-\mu)^3]$ is the third central moment. Positive skew (long right tail) is common in income, wait times, and file sizes. Negative skew appears in test scores with a ceiling effect.

Kurtosis measures tail heaviness relative to a normal distribution. The excess kurtosis is $\gamma_2 = \mu_4/\sigma^4 - 3$. A positive excess kurtosis (leptokurtic) means heavier tails than normal — relevant for financial returns, where extreme events occur more often than a Gaussian model predicts. A negative excess kurtosis (platykurtic) means lighter tails.

PYTHON
print(f"Skewness:        {stats.skew(x):.3f}")
print(f"Excess Kurtosis: {stats.kurtosis(x):.3f}")  # Fisher definition, excess

Pitfalls

  • Units matter for variance: if <!--MATHBLOCK15--> is in meters, <!--MATHBLOCK16--> is in meters-squared. Always prefer <!--MATHBLOCK17--> for communication.
  • Mean vs. median storytelling: median household income is more informative than mean income because a billionaire shifts the mean dramatically.
  • Sample size for shape statistics: skewness and kurtosis estimates are noisy for small <!--MATHBLOCK18--> — meaningful only for <!--MATHBLOCK19-->.

Code Examples

Computing all key descriptive statistics with NumPy and SciPy

Compute mean, median, variance, standard deviation, IQR, MAD, skewness, and kurtosis for a sample drawn from an exponential distribution.

PYTHON
import numpy as np
from scipy import stats

rng = np.random.default_rng(0)
x = rng.exponential(scale=3, size=1000)

desc = {
    "n":               len(x),
    "mean":            x.mean(),
    "median":          np.median(x),
    "std (Bessel)": x.std(ddof=1),
    "variance":        x.var(ddof=1),
    "IQR":             stats.iqr(x),
    "MAD":             stats.median_abs_deviation(x),
    "skewness":        stats.skew(x),
    "excess kurtosis": stats.kurtosis(x),
}

for k, v in desc.items():
    print(f"{k:>20}: {v:.4f}")
Output
                   n: 1000.0000
                mean: 3.0234
              median: 2.0736
        std (Bessel): 3.0237
            variance: 9.1428
                 IQR: 3.3019
                 MAD: 1.4517
            skewness: 1.9781
    excess kurtosis: 5.7202

Descriptive statistics in base R

Equivalent summary statistics in R, demonstrating the built-in summary() function and manual computation.

R
set.seed(42)
x <- rexp(1000, rate = 1/3)   # scale = 3

cat("summary():\n")
print(summary(x))

cat(sprintf("\nStd dev:         %.4f\n", sd(x)))
cat(sprintf("IQR:             %.4f\n", IQR(x)))
cat(sprintf("MAD:             %.4f\n", mad(x, constant = 1)))  # constant=1 for pure MAD

# skewness & kurtosis via moments package (if available)
if (requireNamespace("moments", quietly = TRUE)) {
  library(moments)
  cat(sprintf("Skewness:        %.4f\n", skewness(x)))
  cat(sprintf("Excess kurtosis: %.4f\n", kurtosis(x) - 3))
}
Output
summary():
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
 0.0012  0.8559  2.0655  3.0161  4.1749 21.9804

Std dev:         2.9868
IQR:             3.3190
MAD:             1.4876
Skewness:        2.0134
Excess kurtosis: 5.9243

33.2 Quantiles, Robust Statistics, and Outlier Detection Beginner

Quantiles and the Five-Number Summary

The $p$-th quantile (or $100p$-th percentile) $Q(p)$ is the value below which a fraction $p$ of the data falls. The quartiles $Q_1 = Q(0.25)$, $Q_2 = Q(0.5)$ (the median), and $Q_3 = Q(0.75)$ divide the dataset into four equal parts.

The five-number summary — minimum, $Q_1$, median, $Q_3$, maximum — gives a compact, robust snapshot of any distribution. It is the basis of the box plot (box-and-whisker plot), where the box spans the IQR, the whiskers extend to the farthest points within $1.5 \times \text{IQR}$ of the quartiles, and any points beyond the whiskers are plotted individually as suspected outliers.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

rng = np.random.default_rng(7)
x = rng.lognormal(mean=1.5, sigma=0.8, size=300)

q1, med, q3 = np.percentile(x, [25, 50, 75])
iqr = q3 - q1
print(f"Min={x.min():.2f}  Q1={q1:.2f}  Med={med:.2f}  Q3={q3:.2f}  Max={x.max():.2f}")
print(f"IQR = {iqr:.2f}")

fig, ax = plt.subplots(figsize=(6, 3))
ax.boxplot(x, vert=False, patch_artist=True)
ax.set_xlabel("Value")
ax.set_title("Box plot of log-normal sample")
plt.tight_layout()
plt.savefig("boxplot_example.png", dpi=120)

Interpolation Methods for Quantiles

NumPy's np.percentile and R's quantile() both expose multiple interpolation methods (linear, nearest, midpoint, lower, upper, hazen, etc.). The default linear method is standard for most purposes. For small samples it can matter which you choose — be explicit.

Robust Measures of Center

The trimmed mean with trim fraction $\alpha$ discards the lowest and highest $\alpha$ fraction before averaging:

$$\bar{x}_{\alpha} = \frac{1}{n - 2\lfloor\alpha n\rfloor} \sum_{i=\lfloor\alpha n\rfloor+1}^{n - \lfloor\alpha n\rfloor} x_{(i)}$$

where $x_{(1)} \leq x_{(2)} \leq \cdots \leq x_{(n)}$ are the order statistics. Common choices are $\alpha = 0.05$ or $0.10$. The trimmed mean is more resistant to outliers than the mean while remaining more efficient than the median for near-normal data.

Winsorization replaces extreme values with the boundary quantile values rather than discarding them:

PYTHON
from scipy.stats import trim_mean, mstats

print(f"Mean:          {x.mean():.3f}")
print(f"Trimmed mean:  {trim_mean(x, 0.10):.3f}")

# Winsorize at 5th and 95th percentiles
xw = mstats.winsorize(x, limits=[0.05, 0.05])
print(f"Winsorized mean: {xw.mean():.3f}")

Outlier Detection

Outliers are observations that deviate markedly from the bulk of the data. Several detection rules exist:

  • Tukey fences: outlier if <!--MATHBLOCK16--> or <!--MATHBLOCK17--> ("inner fences"). Extreme outliers use <!--MATHBLOCK18-->.
  • Z-score rule: outlier if <!--MATHBLOCK19-->. Works best for near-normal data; unreliable for heavy-tailed distributions because outliers inflate <!--MATHBLOCK20--> and <!--MATHBLOCK21-->.
  • Modified Z-score (Iglewicz-Hoaglin): uses median and MAD instead: <!--MATHBLOCK22-->; flag <!--MATHBLOCK23-->.

PYTHON
med = np.median(x)
mad = stats.median_abs_deviation(x)
mod_z = 0.6745 * (x - med) / mad
outliers = x[np.abs(mod_z) > 3.5]
print(f"{len(outliers)} outliers detected by modified Z-score")

Why Robustness Matters

In many real-world domains — sensor data with occasional spikes, financial returns with fat tails, genomic data with batch effects — the mean and variance are poor summaries. Robust statistics give you stable estimates that do not chase noise. The key insight: the median has a breakdown point of 50%, meaning up to half the sample can be replaced by arbitrary values without the median becoming unbounded. The mean has a breakdown point of 0%.

Pitfall: Removing Outliers Without Investigation

Outlier detection rules identify candidates, not errors. Before discarding any point, ask: Is it a data-entry error? A measurement artifact? Or a genuinely extreme but real observation? Removing real extreme values biases your analysis. Document every decision.

Code Examples

Five-number summary and box plot with outlier annotation

Compute the five-number summary and render an annotated box plot for a lognormal sample.

PYTHON
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(99)
data = rng.lognormal(mean=2, sigma=1, size=500)

q0, q1, q2, q3, q4 = np.percentile(data, [0, 25, 50, 75, 100])
iqr = q3 - q1
lo_fence = q1 - 1.5 * iqr
hi_fence = q3 + 1.5 * iqr
outliers = data[(data < lo_fence) | (data > hi_fence)]

print(f"Min={q0:.2f}, Q1={q1:.2f}, Median={q2:.2f}, Q3={q3:.2f}, Max={q4:.2f}")
print(f"IQR={iqr:.2f}, Tukey fences: [{lo_fence:.2f}, {hi_fence:.2f}]")
print(f"Outliers: {len(outliers)} points")

fig, ax = plt.subplots(figsize=(7, 3))
bp = ax.boxplot(data, vert=False, patch_artist=True,
                boxprops=dict(facecolor='steelblue', alpha=0.6),
                flierprops=dict(marker='o', color='crimson', alpha=0.5))
ax.axvline(lo_fence, color='orange', linestyle='--', label='1.5×IQR fences')
ax.axvline(hi_fence, color='orange', linestyle='--')
ax.set_xlabel('Value')
ax.set_title('Lognormal sample — box plot with Tukey fences')
ax.legend()
plt.tight_layout()
plt.savefig('boxplot_annotated.png', dpi=120)
Output
Min=0.12, Q1=4.23, Median=8.91, Q3=20.17, Max=421.37
IQR=15.94, Tukey fences: [-19.68, 44.08]
Outliers: 43 points

Quantiles and robust statistics in R

Demonstrates quantile computation, trimmed mean, and modified Z-score outlier detection in R.

R
set.seed(7)
x <- rlnorm(300, meanlog = 1.5, sdlog = 0.8)

# Five-number summary + IQR
five <- fivenum(x)
cat("Five-number summary (Tukey):\n")
cat(sprintf("  Min=%.2f  Q1=%.2f  Median=%.2f  Q3=%.2f  Max=%.2f\n",
            five[1], five[2], five[3], five[4], five[5]))
cat(sprintf("  IQR = %.2f\n", IQR(x)))

# Trimmed mean
cat(sprintf("\nMean:         %.3f\n", mean(x)))
cat(sprintf("Trimmed mean: %.3f\n", mean(x, trim = 0.10)))
cat(sprintf("Median:       %.3f\n", median(x)))

# Modified Z-score
med  <- median(x)
mad_ <- mad(x, constant = 1)  # raw MAD
mod_z <- 0.6745 * (x - med) / mad_
cat(sprintf("\nOutliers (|mod Z| > 3.5): %d\n", sum(abs(mod_z) > 3.5)))

33.3 The Empirical Distribution: Histograms, KDE, and ECDF Intermediate

The Empirical Distribution Function

Given a sample $x_1, \ldots, x_n$, the empirical cumulative distribution function (ECDF) is:

$$\hat{F}_n(t) = \frac{1}{n}\sum_{i=1}^{n} \mathbf{1}[x_i \leq t]$$

It is a step function that jumps by $1/n$ at each observed value. The Glivenko-Cantelli theorem guarantees that $\hat{F}_n \to F$ uniformly as $n \to \infty$, making the ECDF a nonparametric, consistent estimator of the true CDF with no smoothing assumptions.

The ECDF is especially valuable because it is exact — every observation is represented without binning or smoothing. It is the natural tool for computing empirical quantiles and for comparing two samples visually.

PYTHON
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
x = rng.normal(loc=5, scale=2, size=200)

xs = np.sort(x)
ys = np.arange(1, len(x) + 1) / len(x)

fig, ax = plt.subplots(figsize=(7, 4))
ax.step(xs, ys, where='post', color='steelblue', lw=2, label='ECDF')
ax.set_xlabel('Value')
ax.set_ylabel('$\\hat{F}_n(x)$')
ax.set_title('Empirical CDF of Normal(5, 2) sample')
ax.legend()
plt.tight_layout()

Histograms

A histogram bins the data into intervals and counts (or normalizes to density) how many observations fall in each bin. The bin width $h$ is the critical tuning parameter:

  • Too narrow: high variance, noisy appearance.
  • Too wide: high bias, important structure hidden.

Common automatic rules for selecting $h$:

  • Sturges: <!--MATHBLOCK8--> bins. Too few bins for large <!--MATHBLOCK9-->, poor for skewed data.
  • Scott: <!--MATHBLOCK10-->. Optimal for normal data in mean squared error sense.
  • Freedman-Diaconis: <!--MATHBLOCK11-->. Robust version of Scott's rule, better for skewed data.

Matplotlib's default is 'auto' which picks the better of Sturges and Freedman-Diaconis. Always try multiple bin widths.

PYTHON
fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharey=False)
for ax, rule in zip(axes, ['auto', 5, 100]):
    ax.hist(x, bins=rule, density=True, color='steelblue', alpha=0.7, edgecolor='white')
    ax.set_title(f'bins={rule}')
    ax.set_xlabel('Value')
plt.tight_layout()

Kernel Density Estimation

KDE smooths the discrete mass at each observation using a kernel function $K$:

$$\hat{f}_h(x) = \frac{1}{nh}\sum_{i=1}^{n} K\!\left(\frac{x - x_i}{h}\right)$$

The most common choice is the Gaussian kernel $K(u) = \frac{1}{\sqrt{2\pi}}e^{-u^2/2}$. The bandwidth $h$ plays the same role as bin width: larger $h$ means more smoothing. Silverman's rule of thumb is $h = 0.9 \min(\hat{\sigma}, \text{IQR}/1.34) n^{-1/5}$, which is optimal for normal reference distributions.

PYTHON
from scipy.stats import gaussian_kde

kde = gaussian_kde(x, bw_method='silverman')
xgrid = np.linspace(x.min() - 1, x.max() + 1, 400)

fig, ax = plt.subplots(figsize=(7, 4))
ax.hist(x, bins='fd', density=True, color='steelblue', alpha=0.4, label='Histogram')
ax.plot(xgrid, kde(xgrid), 'k-', lw=2, label='KDE (Silverman)')
ax.set_xlabel('Value')
ax.set_ylabel('Density')
ax.legend()

KDE Pitfalls

  • KDE leaks probability mass beyond the natural support of a variable (e.g., negative values for a positive-only variable). Use a log-transform or a boundary-corrected kernel for such data.
  • For multimodal distributions, a global bandwidth may over-smooth one peak while under-smoothing another. Adaptive KDE methods address this.
  • ECDF vs. Histogram vs. KDE: When to Use Each

  • Use the ECDF for precise quantile reading, two-sample comparison, and formal tests (Kolmogorov-Smirnov). It makes no smoothing assumptions.
  • Use histograms for a quick look at shape, especially in presentations, when exact bin counts matter, or when the audience is non-technical.
  • Use KDE for smooth density visualization, overlaying multiple groups, and publication-quality figures. Remember it requires a bandwidth choice.

For diagnostic purposes — checking normality, comparing samples — the ECDF is often the most honest tool.

Code Examples

ECDF, histogram, and KDE side by side

Generate a bimodal sample and visualize it with all three tools, showing how each reveals (or hides) structure.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import gaussian_kde

rng = np.random.default_rng(5)
x = np.concatenate([rng.normal(2, 0.8, 300), rng.normal(7, 1.2, 200)])

fig, axes = plt.subplots(1, 3, figsize=(14, 4))

# ECDF
xs = np.sort(x)
ys = np.arange(1, len(x)+1) / len(x)
axes[0].step(xs, ys, where='post', color='teal', lw=2)
axes[0].set_title('ECDF')
axes[0].set_xlabel('Value')
axes[0].set_ylabel('Cumulative probability')

# Histogram
axes[1].hist(x, bins='fd', density=True, color='steelblue', alpha=0.7, edgecolor='white')
axes[1].set_title('Histogram (Freedman-Diaconis)')
axes[1].set_xlabel('Value')
axes[1].set_ylabel('Density')

# KDE
kde = gaussian_kde(x, bw_method='silverman')
xg = np.linspace(x.min()-1, x.max()+1, 500)
axes[2].fill_between(xg, kde(xg), alpha=0.4, color='coral')
axes[2].plot(xg, kde(xg), color='darkred', lw=2)
axes[2].set_title('KDE (Silverman bandwidth)')
axes[2].set_xlabel('Value')
axes[2].set_ylabel('Density')

plt.tight_layout()
plt.savefig('ecdf_hist_kde.png', dpi=120)
Output
Three-panel figure saved to ecdf_hist_kde.png. The ECDF shows two distinct slope changes around x=2 and x=7 corresponding to the two modes. The histogram and KDE both reveal bimodality clearly.

ECDF and density in R with ggplot2

Plot the ECDF and a density estimate for a bimodal sample using ggplot2.

R
library(ggplot2)

set.seed(5)
x <- c(rnorm(300, mean=2, sd=0.8), rnorm(200, mean=7, sd=1.2))
df <- data.frame(x = x)

# ECDF
p1 <- ggplot(df, aes(x)) +
  stat_ecdf(geom = "step", color = "steelblue", size = 1) +
  labs(title = "ECDF", y = expression(hat(F)[n](x))) +
  theme_minimal()

# Density
p2 <- ggplot(df, aes(x)) +
  geom_histogram(aes(y = after_stat(density)), bins = 40,
                 fill = "steelblue", alpha = 0.5, color = "white") +
  geom_density(color = "darkred", size = 1, bw = "SJ") +
  labs(title = "Histogram + KDE (Sheather-Jones)", y = "Density") +
  theme_minimal()

# cowplot or patchwork to combine
if (requireNamespace("patchwork", quietly = TRUE)) {
  library(patchwork)
  print(p1 + p2)
} else {
  print(p1)
  print(p2)
}

33.4 Discrete Distributions: Bernoulli, Binomial, and Poisson Intermediate

The Bernoulli Distribution

The simplest discrete distribution models a single binary trial with success probability $p$:

$$P(X = k) = p^k (1-p)^{1-k}, \quad k \in \{0, 1\}$$

Mean: $\mu = p$. Variance: $\sigma^2 = p(1-p)$. It is the building block for everything that follows.

The Binomial Distribution

If we run $n$ independent Bernoulli($p$) trials and count successes, we get $X \sim \text{Binomial}(n, p)$:

$$P(X = k) = \binom{n}{k} p^k (1-p)^{n-k}, \quad k = 0, 1, \ldots, n$$

Mean: $np$. Variance: $np(1-p)$. The binomial is appropriate when:

  • The number of trials <!--MATHBLOCK12--> is fixed in advance.
  • Each trial is independent.
  • The success probability <!--MATHBLOCK13--> is constant across trials.

Typical use cases: number of defective items in a batch of $n$; number of users who click a button given a sample of $n$ visitors; number of heads in $n$ coin flips.

PYTHON
from scipy.stats import binom
import numpy as np
import matplotlib.pyplot as plt

n, p = 20, 0.3
k = np.arange(0, n+1)
pmf = binom.pmf(k, n, p)

fig, ax = plt.subplots(figsize=(7, 4))
ax.vlines(k, 0, pmf, colors='steelblue', lw=3)
ax.set_title(f'Binomial({n}, {p}) PMF')
ax.set_xlabel('k')
ax.set_ylabel('P(X = k)')

print(f"Mean:     {binom.mean(n, p):.2f}")
print(f"Variance: {binom.var(n, p):.2f}")
print(f"P(X >= 10): {1 - binom.cdf(9, n, p):.4f}")

Normal Approximation

When $np \geq 5$ and $n(1-p) \geq 5$, the binomial is well approximated by $\text{Normal}(np, np(1-p))$. Apply the continuity correction: $P(X \leq k) \approx \Phi\left(\frac{k + 0.5 - np}{\sqrt{np(1-p)}}\right)$.

The Poisson Distribution

The Poisson distribution models the number of events in a fixed interval of time or space when events occur at a constant average rate $\lambda$ and independently:

$$P(X = k) = \frac{\lambda^k e^{-\lambda}}{k!}, \quad k = 0, 1, 2, \ldots$$

Mean: $\lambda$. Variance: $\lambda$. The equality of mean and variance is a defining characteristic — if you observe $\text{Var}(X) \gg E[X]$ (overdispersion), a negative binomial model is often more appropriate.

Typical use cases: call center arrivals per hour; mutations per genome per generation; photons hitting a detector per second; insurance claims per month.

PYTHON
from scipy.stats import poisson

lambda_ = 4
k = np.arange(0, 15)
pmf = poisson.pmf(k, lambda_)

fig, ax = plt.subplots(figsize=(7, 4))
ax.vlines(k, 0, pmf, colors='coral', lw=3)
ax.set_title(f'Poisson($\\lambda={lambda_}$) PMF')
ax.set_xlabel('k')
ax.set_ylabel('P(X = k)')

print(f"Mean=Var: {poisson.mean(lambda_):.2f}")
print(f"P(X = 0): {poisson.pmf(0, lambda_):.4f}")
print(f"P(X > 8): {1 - poisson.cdf(8, lambda_):.4f}")

Poisson as a Limit of the Binomial

When $n$ is large and $p$ is small with $\lambda = np$ fixed, $\text{Binomial}(n, p) \to \text{Poisson}(\lambda)$. This is why Poisson models rare events well: think of $n$ people in a city each independently having a rare car accident with probability $p$; the total count is approximately Poisson($np$).

The Negative Binomial

For overdispersed count data, the Negative Binomial $\text{NB}(r, p)$ models the number of failures before the $r$-th success. Parameterized by mean $\mu$ and dispersion $r$:

$$\text{Var}(X) = \mu + \frac{\mu^2}{r}$$

As $r \to \infty$ the variance approaches $\mu$ and we recover the Poisson. Negative binomial regression is the standard replacement for Poisson regression in the presence of overdispersion.

PYTHON
from scipy.stats import nbinom

# mean=4, dispersion r=2 -> Var = 4 + 16/2 = 12
r, mu = 2, 4
p_nb = r / (r + mu)
k = np.arange(0, 20)
plt.figure(figsize=(7,4))
plt.vlines(k, 0, nbinom.pmf(k, r, p_nb), colors='purple', lw=3, label='NegBin')
plt.vlines(k, 0, poisson.pmf(k, mu), colors='coral', lw=3, alpha=0.5, label='Poisson')
plt.legend(); plt.xlabel('k'); plt.title('Poisson vs Negative Binomial (same mean, larger variance)')

Code Examples

Binomial CDF and the normal approximation

Compare the exact Binomial CDF to its normal approximation with continuity correction, illustrating when the approximation is accurate.

PYTHON
import numpy as np
from scipy.stats import binom, norm
import matplotlib.pyplot as plt

n, p = 50, 0.4
k_vals = np.arange(0, n+1)

# Exact binomial CDF
exact_cdf = binom.cdf(k_vals, n, p)

# Normal approximation with continuity correction
mu, sigma = n*p, np.sqrt(n*p*(1-p))
approx_cdf = norm.cdf(k_vals + 0.5, loc=mu, scale=sigma)

fig, ax = plt.subplots(figsize=(8, 4))
ax.step(k_vals, exact_cdf, where='post', label='Exact Binomial', lw=2)
ax.plot(k_vals, approx_cdf, 'r--', label='Normal approx (cont. correction)', lw=2)
ax.set_xlabel('k'); ax.set_ylabel('CDF'); ax.legend()
ax.set_title(f'Binomial({n},{p}) vs Normal approximation')
plt.tight_layout()

# Max absolute error
max_err = np.max(np.abs(exact_cdf - approx_cdf))
print(f"Max absolute error: {max_err:.5f}")
Output
Max absolute error: 0.00821

Simulating Poisson arrivals and checking variance

Simulate a Poisson process (call center arrivals) and verify the mean-variance equality.

PYTHON
import numpy as np

rng = np.random.default_rng(42)
lambda_per_hour = 12   # average 12 calls per hour
hours = 10_000         # simulate 10,000 hours

arrival_counts = rng.poisson(lam=lambda_per_hour, size=hours)

print(f"True lambda:   {lambda_per_hour}")
print(f"Sample mean:   {arrival_counts.mean():.4f}")
print(f"Sample var:    {arrival_counts.var(ddof=1):.4f}")
print(f"Ratio var/mean: {arrival_counts.var(ddof=1)/arrival_counts.mean():.4f}  (expect ~1.0)")
Output
True lambda:   12
Sample mean:   11.9942
Sample var:    12.0184
Ratio var/mean: 1.0020  (expect ~1.0)

Discrete distribution PMFs in R

Compute and plot PMFs for Binomial and Poisson distributions in base R.

R
par(mfrow = c(1, 2))

# Binomial
n <- 20; p <- 0.35
k <- 0:n
plot(k, dbinom(k, size=n, prob=p), type='h', lwd=3, col='steelblue',
     main=paste0('Binomial(', n, ', ', p, ')'),
     xlab='k', ylab='P(X=k)')

# Poisson
lam <- 5
k2 <- 0:15
plot(k2, dpois(k2, lambda=lam), type='h', lwd=3, col='coral',
     main=paste0('Poisson(lambda=', lam, ')'),
     xlab='k', ylab='P(X=k)')

par(mfrow = c(1, 1))

cat(sprintf("Binom mean=%.2f, var=%.2f\n", n*p, n*p*(1-p)))
cat(sprintf("Poisson mean=%.2f, var=%.2f\n", lam, lam))

33.5 Continuous Distributions: Normal, Exponential, and Beyond Intermediate

The Normal Distribution

The normal (Gaussian) distribution $X \sim \mathcal{N}(\mu, \sigma^2)$ has PDF:

$$f(x) = \frac{1}{\sigma\sqrt{2\pi}} \exp\!\left(-\frac{(x-\mu)^2}{2\sigma^2}\right), \quad x \in \mathbb{R}$$

The standard normal $Z = (X-\mu)/\sigma \sim \mathcal{N}(0, 1)$. The 68-95-99.7 rule: approximately 68%, 95%, and 99.7% of a normal distribution falls within 1, 2, and 3 standard deviations of the mean.

The normal distribution's dominance in statistics comes from the Central Limit Theorem: sums (and averages) of large numbers of independent, finite-variance random variables converge in distribution to normal, regardless of the original distribution. This is why measurement error, sample means, and many aggregate quantities are approximately normal.

PYTHON
from scipy.stats import norm
import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(-4, 4, 400)
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# PDF
for mu, sig, label in [(0,1,'N(0,1)'), (0,2,'N(0,4)'), (2,1,'N(2,1)')]:
    axes[0].plot(x*sig + mu*0 + mu, norm.pdf(x*sig + mu*0 + mu, mu, sig), label=label)
axes[0].set_title('Normal PDFs'); axes[0].legend()

# Q-Q plot check
rng = np.random.default_rng(0)
data = rng.normal(3, 1.5, 300)
from scipy.stats import probplot
probplot(data, dist='norm', plot=axes[1])
axes[1].set_title('Normal Q-Q plot')
plt.tight_layout()

The Q-Q Plot

A quantile-quantile (Q-Q) plot compares the quantiles of your sample to the theoretical quantiles of a reference distribution. If the sample follows the reference distribution, the points fall on a 45-degree line. Systematic S-curves indicate heavier tails; a bow shape indicates skewness.

The Exponential Distribution

The exponential distribution $X \sim \text{Exp}(\lambda)$ models the waiting time between events in a Poisson process:

$$f(x) = \lambda e^{-\lambda x}, \quad x \geq 0$$

Mean: $1/\lambda$. Variance: $1/\lambda^2$. Its defining property is memorylessness: $P(X > s + t \mid X > s) = P(X > t)$. The time remaining until the next event does not depend on how long you have already waited — which is realistic for certain failure modes but unrealistic for aging systems.

Typical use cases: service times in queueing models; time to first failure of electronic components; inter-arrival times in a Poisson process.

The Gamma and Weibull Distributions

The Gamma distribution generalizes the exponential. $X \sim \text{Gamma}(\alpha, \beta)$ is the sum of $\alpha$ independent $\text{Exp}(1/\beta)$ variables:

$$f(x) = \frac{x^{\alpha-1} e^{-x/\beta}}{\beta^\alpha \Gamma(\alpha)}, \quad x > 0$$

Mean: $\alpha\beta$. Variance: $\alpha\beta^2$. When $\alpha = 1$ it reduces to exponential; when $\alpha = n/2, \beta = 2$ it gives the chi-squared distribution with $n$ degrees of freedom, central to statistical testing.

The Weibull distribution models failure times with non-constant hazard rates:

$$f(x) = \frac{k}{\lambda}\left(\frac{x}{\lambda}\right)^{k-1} e^{-(x/\lambda)^k}, \quad x > 0$$

The shape parameter $k$ controls the hazard: $k < 1$ means decreasing hazard (infant mortality); $k = 1$ is exponential (constant hazard); $k > 1$ means increasing hazard (wear-out). It is the workhorse of reliability engineering.

Log-Normal and Heavy-Tailed Distributions

If $\ln X \sim \mathcal{N}(\mu, \sigma^2)$, then $X$ is log-normal. It arises naturally whenever a positive quantity is the product of many independent factors (by CLT on the log scale). Common examples: incomes, stock prices, particle sizes, file sizes, reaction times.

The Pareto and power-law distributions model truly heavy-tailed phenomena — where extreme values are far more common than a normal or exponential model predicts:

$$P(X > x) = \left(\frac{x_m}{x}\right)^\alpha, \quad x \geq x_m$$

If $\alpha \leq 2$ the variance is infinite; if $\alpha \leq 1$ even the mean is infinite. Web page requests, earthquake magnitudes, and city sizes follow power laws.

PYTHON
from scipy.stats import lognorm, expon, gamma, weibull_min
import numpy as np
import matplotlib.pyplot as plt

xg = np.linspace(0.01, 10, 500)
fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(xg, expon.pdf(xg, scale=2),       label='Exp(rate=0.5)')
ax.plot(xg, gamma.pdf(xg, a=2, scale=1),  label='Gamma(2,1)')
ax.plot(xg, weibull_min.pdf(xg, c=2, scale=2), label='Weibull(k=2)')
ax.plot(xg, lognorm.pdf(xg, s=0.8, scale=np.exp(1)), label='LogNormal')
ax.set_xlabel('x'); ax.set_ylabel('f(x)')
ax.set_title('Positive continuous distributions')
ax.legend(); plt.tight_layout()

Choosing a Distribution: A Practical Guide

  • Bounded integers: Binomial, Hypergeometric
  • Unbounded counts: Poisson, Negative Binomial
  • Symmetric, approximately normal: Normal
  • Positive, right-skewed, moderate: Gamma, Log-normal
  • Failure times, reliability: Weibull, Exponential
  • Extreme values / fat tails: Pareto, Generalized Extreme Value
  • Proportions / probabilities: Beta

Always start with domain knowledge (what process generated the data?) before fitting. Then use Q-Q plots and goodness-of-fit tests to validate.

Code Examples

Fitting distributions to data with scipy and comparing with Q-Q plots

Fit a gamma distribution to right-skewed data using MLE, compare to a normal fit, and evaluate both with Q-Q plots.

PYTHON
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt

rng = np.random.default_rng(123)
data = rng.gamma(shape=3, scale=2, size=500)  # true: Gamma(3, 2)

# Fit Gamma and Normal via MLE
alpha_hat, loc_hat, scale_hat = stats.gamma.fit(data, floc=0)  # fix loc=0
mu_hat, sigma_hat = data.mean(), data.std(ddof=1)

print(f"Gamma MLE:  shape={alpha_hat:.3f}, scale={scale_hat:.3f} (true: 3, 2)")
print(f"Normal MLE: mean={mu_hat:.3f}, std={sigma_hat:.3f}")

# Kolmogorov-Smirnov tests
ks_gamma = stats.kstest(data, 'gamma', args=(alpha_hat, loc_hat, scale_hat))
ks_norm  = stats.kstest(data, 'norm',  args=(mu_hat, sigma_hat))
print(f"\nKS test vs Gamma:  stat={ks_gamma.statistic:.4f}, p={ks_gamma.pvalue:.4f}")
print(f"KS test vs Normal: stat={ks_norm.statistic:.4f},  p={ks_norm.pvalue:.4f}")

# Q-Q plots
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
stats.probplot(data, dist=stats.gamma(alpha_hat, loc_hat, scale_hat), plot=axes[0])
axes[0].set_title('Q-Q: data vs fitted Gamma')
stats.probplot(data, dist='norm', plot=axes[1])
axes[1].set_title('Q-Q: data vs Normal')
plt.tight_layout()
plt.savefig('qq_comparison.png', dpi=120)
Output
Gamma MLE:  shape=3.052, scale=1.981 (true: 3, 2)
Normal MLE: mean=6.046, std=3.477

KS test vs Gamma:  stat=0.0198, p=0.9823
KS test vs Normal: stat=0.0721, p=0.0044

Distribution fitting in R with fitdistrplus

Fit gamma and lognormal distributions to positive data using fitdistrplus, compare AIC, and produce a descdist plot.

R
library(fitdistrplus)

set.seed(42)
x <- rgamma(500, shape=3, rate=0.5)  # mean=6, var=12

# Descriptive plot (skewness-kurtosis plane)
descdist(x, discrete=FALSE, boot=100)

# Fit two candidate distributions
fit_gamma  <- fitdist(x, "gamma",    method="mle")
fit_lnorm  <- fitdist(x, "lnorm",    method="mle")

cat("=== Gamma fit ===\n")
print(summary(fit_gamma))
cat("\n=== Log-normal fit ===\n")
print(summary(fit_lnorm))

cat(sprintf("\nAIC Gamma:    %.2f\n", fit_gamma$aic))
cat(sprintf("AIC Log-norm: %.2f\n", fit_lnorm$aic))

# Goodness-of-fit plots
par(mfrow=c(2,2))
plot.legend <- c("gamma", "lnorm")
denscomp(list(fit_gamma, fit_lnorm), legendtext=plot.legend)
qqcomp(   list(fit_gamma, fit_lnorm), legendtext=plot.legend)
cdfcomp(  list(fit_gamma, fit_lnorm), legendtext=plot.legend)
ppcomp(   list(fit_gamma, fit_lnorm), legendtext=plot.legend)
par(mfrow=c(1,1))

Visualizing the Central Limit Theorem

Demonstrate that the sample mean of an exponential distribution converges to normal as sample size grows.

PYTHON
import numpy as np
from scipy.stats import norm, expon
import matplotlib.pyplot as plt

rng = np.random.default_rng(0)
fig, axes = plt.subplots(1, 4, figsize=(14, 4), sharey=False)

for ax, n in zip(axes, [1, 5, 30, 100]):
    # Sample means of n exponential(1) variables, repeated 5000 times
    means = rng.exponential(scale=1, size=(5000, n)).mean(axis=1)
    ax.hist(means, bins=40, density=True, color='steelblue', alpha=0.7, edgecolor='white')
    # Overlay theoretical normal
    mu_clt = 1.0        # E[Exp(1)] = 1
    sig_clt = 1.0 / np.sqrt(n)   # std of sample mean
    xg = np.linspace(means.min(), means.max(), 300)
    ax.plot(xg, norm.pdf(xg, mu_clt, sig_clt), 'r-', lw=2)
    ax.set_title(f'n = {n}')
    ax.set_xlabel('Sample mean')

fig.suptitle('CLT: Sample mean of Exp(1) converges to Normal', y=1.02)
plt.tight_layout()
plt.savefig('clt_demo.png', dpi=120)
print("Saved clt_demo.png")
Output
Saved clt_demo.png
# Figure shows: n=1 is exponential shape, n=5 slightly skewed, n=30 closely normal, n=100 nearly perfect bell curve overlaid by red normal PDF.

33.6 Distribution Fitting, Diagnostics, and Goodness of Fit Intermediate

Maximum Likelihood Estimation

Given a parametric family $f(x; \theta)$ and data $x_1, \ldots, x_n$, maximum likelihood estimation (MLE) finds the parameter vector $\hat{\theta}$ that maximizes the likelihood:

$$L(\theta) = \prod_{i=1}^{n} f(x_i; \theta) \implies \hat{\theta} = \arg\max_\theta \sum_{i=1}^{n} \log f(x_i; \theta)$$

Working in log-space converts the product to a sum and is numerically stable. SciPy implements MLE for all major distributions via .fit(data), which returns the shape parameters, location (loc), and scale.

PYTHON
from scipy.stats import gamma, norm, expon
import numpy as np

rng = np.random.default_rng(7)
data = rng.gamma(shape=2.5, scale=3.0, size=1000)

# MLE — fix loc=0 for a proper positive distribution
a, loc, scale = gamma.fit(data, floc=0)
print(f"Estimated shape={a:.3f} (true 2.5), scale={scale:.3f} (true 3.0)")

# Log-likelihood at MLE
log_lik = gamma.logpdf(data, a, loc, scale).sum()
print(f"Log-likelihood: {log_lik:.2f}")

Pitfall: loc Parameter

SciPy's dist.fit() estimates loc (shift) by default. For naturally positive data like durations, fix floc=0. Failing to do so can give nonsensical results — e.g., expon.fit(data) will set loc = min(data) rather than 0.

Method of Moments

An alternative to MLE: set theoretical moments equal to sample moments and solve for parameters. For the Gamma($\alpha$, $\beta$):

$$\hat{\alpha} = \frac{\bar{x}^2}{s^2}, \quad \hat{\beta} = \frac{s^2}{\bar{x}}$$

Method of moments is faster but generally less efficient (higher variance) than MLE. Use it as a starting point or when you need closed-form estimates.

Goodness-of-Fit Diagnostics

Q-Q Plots

The Q-Q plot is the primary visual diagnostic. Plot the empirical quantiles against the theoretical quantiles of the fitted distribution. Deviations indicate where the model fails:

  • Heavy right tail: points curve upward above the line at the right end.
  • Light tail: points curve downward below the line.
  • Systematic S-curve: both tails depart — the distribution is heavier-tailed than the fit.
  • Outliers: isolated extreme points far from the line.

PYTHON
import matplotlib.pyplot as plt
from scipy.stats import probplot

fig, axes = plt.subplots(1, 2, figsize=(11, 4))

# Good fit: Gamma vs Gamma-distributed data
probplot(data, dist=gamma(a, loc, scale), plot=axes[0])
axes[0].set_title('Q-Q: data vs fitted Gamma (good fit)')

# Bad fit: Normal vs Gamma-distributed data
probplot(data, dist='norm', plot=axes[1])
axes[1].set_title('Q-Q: data vs Normal (poor fit)')

plt.tight_layout()

Formal Tests

  • Kolmogorov-Smirnov (KS) test: <!--MATHBLOCK9-->. Tests whether the sample came from a specific, fully specified <!--MATHBLOCK10-->. Critical limitation: when parameters are estimated from the same data, the standard KS critical values are anti-conservative (too liberal). Use Lilliefors correction for normality testing.
  • Anderson-Darling (AD) test: weighted version of KS that gives more weight to the tails:

$$A^2 = -n - \frac{1}{n}\sum_{i=1}^{n}\left[(2i-1)(\ln F(x_{(i)}) + \ln(1 - F(x_{(n+1-i)})))\right]$$
More powerful than KS for detecting tail deviations.

  • Shapiro-Wilk test: specifically designed for normality. Highly recommended over KS/AD for testing normality; powerful even for small samples.

PYTHON
from scipy.stats import kstest, anderson, shapiro

# KS test against the fitted gamma
ks_stat, ks_p = kstest(data, lambda x: gamma.cdf(x, a, loc, scale))
print(f"KS test:  stat={ks_stat:.4f}, p={ks_p:.4f}")

# Anderson-Darling for normal
ad_result = anderson(data, dist='norm')
print(f"AD stat:  {ad_result.statistic:.4f}")
print(f"Critical values: {dict(zip(ad_result.significance_level, ad_result.critical_values))}")

# Shapiro-Wilk on a small normal sample
normal_sample = np.random.default_rng(0).normal(0,1,50)
stat, p_val = shapiro(normal_sample)
print(f"\nShapiro-Wilk on N(0,1) sample: stat={stat:.4f}, p={p_val:.4f}")

AIC and BIC for Model Selection

When comparing multiple candidate distributions, use information criteria:

$$\text{AIC} = 2k - 2\ell(\hat{\theta}) \qquad \text{BIC} = k\ln n - 2\ell(\hat{\theta})$$

where $k$ is the number of parameters and $\ell(\hat{\theta})$ is the maximized log-likelihood. Lower is better. BIC penalizes complexity more heavily for large $n$, favoring parsimonious models.

PYTHON
def aic(log_lik, k): return 2*k - 2*log_lik
def bic(log_lik, k, n): return k*np.log(n) - 2*log_lik

for dist_name, dist_obj, params, k in [
    ('Gamma',    gamma,  (a, loc, scale),                        2),
    ('Normal',   norm,   (data.mean(), data.std(ddof=1)),        2),
    ('Exponential', expon, (0, data.mean()),                     1),
]:
    ll = dist_obj.logpdf(data, *params).sum()
    print(f"{dist_name:12s}  AIC={aic(ll,k):.1f}  BIC={bic(ll,k,len(data)):.1f}")

A difference of more than 2 AIC units is generally considered meaningful; differences greater than 10 are strong evidence.

Code Examples

Full distribution fitting pipeline: MLE, Q-Q plot, KS test, and AIC comparison

Fit several candidate distributions to a real-world-like dataset (network latency simulation), rank them by AIC, and visualize the best fit.

PYTHON
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
# Simulate "network latency" data: mixture of fast and slow paths
data = np.concatenate([rng.exponential(10, 800), rng.exponential(50, 200)])

candidates = {
    'Exponential': (stats.expon,  {'floc': 0}),
    'Gamma':       (stats.gamma,  {'floc': 0}),
    'Lognormal':   (stats.lognorm,{'floc': 0}),
    'Weibull':     (stats.weibull_min, {'floc': 0}),
}

results = []
n = len(data)
for name, (dist, kwargs) in candidates.items():
    params = dist.fit(data, **kwargs)
    ll = dist.logpdf(data, *params).sum()
    k = len(params) - sum(v is not None for v in kwargs.values())
    aic = 2*k - 2*ll
    bic = k*np.log(n) - 2*ll
    results.append({'name': name, 'params': params, 'dist': dist,
                    'll': ll, 'aic': aic, 'bic': bic})

results.sort(key=lambda r: r['aic'])
print(f"{'Distribution':15s} {'LogLik':>12s} {'AIC':>10s} {'BIC':>10s}")
print('-' * 50)
for r in results:
    print(f"{r['name']:15s} {r['ll']:12.1f} {r['aic']:10.1f} {r['bic']:10.1f}")

# Q-Q plot for the best-fitting distribution
best = results[0]
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
xg = np.linspace(0.1, data.max()*1.1, 500)

axes[0].hist(data, bins='fd', density=True, alpha=0.5, color='steelblue', label='Data')
axes[0].plot(xg, best['dist'].pdf(xg, *best['params']), 'r-', lw=2,
             label=f"{best['name']} (AIC={best['aic']:.0f})")
axes[0].set_xlabel('Latency (ms)'); axes[0].set_ylabel('Density'); axes[0].legend()
axes[0].set_title('Best-fit distribution')

theoretical_dist = best['dist'](*best['params'])
stats.probplot(data, dist=theoretical_dist, plot=axes[1])
axes[1].set_title(f"Q-Q plot: {best['name']}")
plt.tight_layout()
plt.savefig('distribution_fit.png', dpi=120)
Output
Distribution      LogLik        AIC        BIC
--------------------------------------------------
Lognormal       -5241.8    10487.7    10497.4
Weibull         -5271.3    10546.6    10556.3
Gamma           -5307.4    10618.7    10628.5
Exponential     -5484.1    10970.1    10975.0

Shapiro-Wilk normality test and Q-Q plot in R

Test normality assumptions for four different distributions and show how Q-Q plots and Shapiro-Wilk complement each other.

R
set.seed(1)
samples <- list(
  Normal    = rnorm(100),
  Lognormal = rlnorm(100, meanlog=0, sdlog=1),
  Exponential = rexp(100, rate=1),
  Uniform   = runif(100, -2, 2)
)

par(mfrow=c(2,4))
for (nm in names(samples)) {
  x <- samples[[nm]]
  # Q-Q plot
  qqnorm(x, main=paste("Q-Q:", nm))
  qqline(x, col='red', lwd=2)
}
# Shapiro-Wilk
for (nm in names(samples)) {
  x   <- samples[[nm]]
  sw  <- shapiro.test(x)
  cat(sprintf("%-12s  W=%.4f  p=%.4f  %s\n", nm, sw$statistic, sw$p.value,
              ifelse(sw$p.value > 0.05, 'NORMAL', 'NOT NORMAL')))
}
par(mfrow=c(1,1))
Output
Normal        W=0.9956  p=0.9827  NORMAL
Lognormal     W=0.8973  p=0.0000  NOT NORMAL
Exponential   W=0.8654  p=0.0000  NOT NORMAL
Uniform       W=0.9587  p=0.0030  NOT NORMAL