Statistical analysis of data on natural selection

I tested whether humerus length in sparrows is related to surviving a severe winter storm. The dataset has humerus measurements for birds that survived and birds that died. The analysis covers point estimates, distribution estimates, parametric model fitting, simulation, confidence intervals, and t-tests.


import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import scipy.stats as st

pd.set_option('display.float_format', '{:g}'.format)
sns.set()

dataset

K = 16
L = 3
M = ((K + L) * 47) % 11 + 1
pd.DataFrame([K, L, M], index=["K", "L", "M"], columns=[""])
DatasetDescription
case0201Humerus length according to sparrow survival

introduction

First I load the data and split the humerus lengths into the two groups. Then I describe the data and estimate the mean, variance, and median for each group separately.

data = pd.read_csv("case0201.csv")

S = data.loc[data["Status"] == "Survived", "Humerus"]
P = data.loc[data["Status"] == "Perished", "Humerus"]

objective

After a severe winter storm, some of the collected sparrows survived and some died. The question is whether the ones that died had a shorter humerus.

data description

The data covers 59 adult male sparrows, of which 24 died and 35 survived. Humerus length is recorded in thousandths of an inch.

RegressorDescriptionValues
HumerusLength of arm bone of adult male sparrows (in hundredths of an inch)733, 736, …
StatusVariable indicating whether the sparrow died or survivedsurvived / perished

point estimates

I estimate the mean with the sample mean

\[ \bar{X}_n = \frac{1}{n} \sum_{i=1}^{n} X_i \]

The variance with the sample variance

\[ s_n^2 = \frac{1}{n-1} \sum_{i=1}^{n} \left( X_i - \bar{X}_n \right)^2 \]

And the standard deviation with the sample standard deviation

\[ s_n = \sqrt{s_n^2} \]
S_n = S.shape[0]
S_mean = np.mean(S)
S_var = np.var(S, ddof=1)
S_std = np.sqrt(S_var)
S_med = np.median(S)

P_n = P.shape[0]
P_mean = np.mean(P)
P_var = np.var(P, ddof=1)
P_std = np.sqrt(P_var)
P_med = np.median(P)


pd.DataFrame({
    "Frequency": [S_n, P_n],
    "Sample mean": [S_mean, P_mean],
    "Sample variance": [S_var, P_var],
    "Sample standard deviation": [S_std, P_std],
    "Median": [S_med, P_med],
}, index=["Survived", "Perished"]).round(4).T
SurvivedPerished
Frequency3524
Sample mean738727.917
Sample variance393.588554.254
Sample standard deviation19.839123.5426
Median736733.5

distribution function estimate

For each group, I estimate the density with a histogram and the distribution function with the empirical distribution function.

histogram

I estimate the density with a histogram using \(k\) bins over the range of the data.

\[ \hat{f}(x) = \frac{\text{number of observations in the same bin as } x}{\text{number of all observations}} \]

empirical distribution function

The empirical distribution function gives the estimate for the distribution function.

\[ \hat{F}(x) = \frac{1}{n} \sum_{i=1}^{n} \mathbb{1}_{\{X \le x\}} \]
def process_bins(vals, bins, normalize=True):
    density_est = []
    my_bins = []
    
    for i in range(len(vals)):
        if i == len(vals) - 1:
            bin = f"[{bins[i]:.0f}, {bins[i + 1]:.0f}]"
        else:
            bin = f"[{bins[i]:.0f}, {bins[i + 1]:.0f})"
        my_bins.append(bin)

    for val in vals:
        if normalize:
            val = val / np.sum(vals)
        density_est.append(val)

    return density_est, my_bins

def histogram(data, bin_count):
    fig, axes = plt.subplots(1, 2, figsize=(10, 5), layout="constrained")
    
    counts, bins = np.histogram(data, bins=bin_count)
    bins = bins.round()
    
    ax = axes[0]
    sns.kdeplot(data, ax=ax, linewidth=1, color="black", alpha=0.3)
    sns.histplot(data, bins=bins, stat="density", ax=ax)
    sns.rugplot(data, ax=ax, color="black")
    ax.set_title("Histogram")
    
    ax = axes[1]
    sns.ecdfplot(data, stat="proportion", ax=ax, linewidth=1.5)
    sns.kdeplot(data, cumulative=True, ax=ax, linewidth=1, color="black", alpha=0.3)
    sns.rugplot(data, ax=ax, color="black", height=0.015)
    ax.set_title("Empirical distribution function")

    plt.show()

    density_est, my_bins =  process_bins(counts, bins)
    density_table = pd.DataFrame([my_bins, np.round(density_est, 4)], index=["Bin", "Estimation \(F(x)\)"], columns=[""]*len(my_bins))

    ecdf = st.ecdf(data)
    distrib_est, my_bins =  process_bins(ecdf.cdf.probabilities[:-1], ecdf.cdf.quantiles, normalize=False)
    distrib_table = pd.DataFrame([my_bins, np.round(distrib_est, 4)], index=["Bin", "Estimation \(F(x)\)"], columns=[""]*len(my_bins))
    
    return density_table, distrib_table

survived

density_table, distrib_table = histogram(S, 9)

png

density estimation

From the histogram, the density estimate is

\[ \hat{f}_{\text{survived}}(x) = \begin{cases} \text{the corresponding value in the table,} &\quad x \in \left[ 687, 780 \right] \\ 0 \text{,} &\quad x \notin \left[ 687, 780 \right] \end{cases} \]
density_table
Bin[687, 697)[697, 708)[708, 718)[718, 728)[728, 739)[739, 749)[749, 759)[759, 770)[770, 780]
Estimation \(F(x)\)0.02860.02860.05710.20.20.17140.17140.08570.0571

estimation of the distribution function

From the empirical distribution function, the distribution function estimate is

\[ \hat{F}_{\text{survived}}(x) = \begin{cases} \text{0,} &\quad x \lt 687\\ \text{the corresponding value in the table,} &\quad x \in \left[ 687, 780 \right] \\ \text{1,} &\quad x \gt 780\\ \end{cases} \]
display(distrib_table.iloc[:, :9])
display(distrib_table.iloc[:, 9:18])
display(distrib_table.iloc[:, 18:])
Bin[687, 703)[703, 709)[709, 715)[715, 721)[721, 723)[723, 726)[726, 728)[728, 729)[729, 730)
Estimation \(F(x)\)0.02860.05710.08570.11430.14290.20.22860.31430.3429
Bin[730, 733)[733, 735)[735, 736)[736, 739)[739, 741)[741, 743)[743, 749)[749, 751)[751, 752)
Estimation \(F(x)\)0.40.45710.48570.51430.54290.65710.68570.71430.7429
Bin[752, 755)[755, 756)[756, 766)[766, 767)[767, 769)[769, 770)[770, 780]
Estimation \(F(x)\)0.80.82860.85710.88570.91430.94290.9714

perished

density_table, distrib_table = histogram(P, 6)

png

density estimation

From the histogram, the density estimate is

\[ \hat{f}_{\text{perished}}(x) = \begin{cases} \text{the corresponding value in the table,} &\quad x \in \left[ 659, 765 \right] \\ 0 \text{,} &\quad x \notin \left[ 659, 765 \right] \end{cases} \]
density_table
Bin[659, 677)[677, 694)[694, 712)[712, 730)[730, 747)[747, 765]
Estimation \(F(x)\)0.04170.04170.1250.250.3750.1667

estimation of the distribution function

From the empirical distribution function, the distribution function estimate is

\[ \hat{F}_{\text{perished}}(x) = \begin{cases} \text{0,} &\quad x \lt 659\\ \text{the corresponding value in the table,} &\quad x \in \left[ 659, 765 \right] \\ \text{1,} &\quad x \gt 765\\ \end{cases} \]
display(distrib_table.iloc[:, :7])
display(distrib_table.iloc[:, 7:14])
display(distrib_table.iloc[:, 14:21])
Bin[659, 689)[689, 702)[702, 703)[703, 709)[709, 713)[713, 720)[720, 726)
Estimation \(F(x)\)0.04170.08330.1250.16670.20830.250.3333
Bin[726, 729)[729, 731)[731, 736)[736, 737)[737, 738)[738, 739)[739, 743)
Estimation \(F(x)\)0.41670.45830.50.54170.58330.66670.7083
Bin[743, 744)[744, 745)[745, 752)[752, 754)[754, 765]
Estimation \(F(x)\)0.750.79170.83330.91670.9583

statistical model

For each group, I fit normal, exponential, and uniform distributions by estimating their parameters. Then I overlay the fitted densities on the histograms and see which one looks closest.

parameter estimation

I estimate the exponential and uniform parameters using the method of moments.

The moments are estimated by sample moments.

\[ \widehat{EX^k} = m_k = \frac{1}{n} \sum_{i=1}^{n} X_i^k \]
S_m1 = 1 / S_n * np.sum(S)
S_m2 = 1 / S_n * np.dot(S, S)

P_m1 = 1 / P_n * np.sum(P)
P_m2 = 1 / P_n * np.dot(P, P)

pd.DataFrame({
    "Survived": [S_m1, S_m2],
    "Perished": [P_m1, P_m2],
}, index=["Sample moment \(m_1\)", "Sample moment \(m_2\)"]).round(4)
SurvivedPerished
Sample moment \(m_1\)738727.917
Sample moment \(m_2\)545026530394

Now I calculate the normal distribution parameters from the earlier estimates.

Normal distribution \(\mathcal{N}(\mu, \sigma^2)\)

\[ f(x) = \frac{1}{\sqrt{2\pi \sigma^2}} e^{-\frac{\left(x - \mu\right)^2}{2 \sigma^2}} \quad \text{for } x \in \mathbb{R} \]

\[\hat{\mu} = \bar{X}_n\]

\[\hat{\sigma}^2 = s_n^2\]

I express the parameters of the remaining two models as functions of moments.

Exponential distribution \(\text{Exp}(\lambda, L)\)

\[ f(x) = \begin{cases} \lambda e^{-\lambda \left(x - L\right)} & \text{for } x \geq 0 \\ 0 &\text{otherwise} \end{cases} \]\[\hat{\lambda} = \frac{1}{\sqrt{m_2 - m_1^2}}\]

\[\hat{L} = m_1 - \sqrt{m_2 - m_1^2}\]

Uniform distribution \(\mathcal{U}(a, b)\)

\[ f(x) = \begin{cases} \frac{1}{b-a} &\quad \text{for } a \leq x \leq b \\ 0 &\quad \text{otherwise} \end{cases} \]\[\hat{a} = m_1 - \sqrt{3 \cdot \left( m_2 - m_1^2 \right)}\]

\[\hat{b} = m_1 + \sqrt{3 \cdot \left( m_2 - m_1^2 \right)}\]
def add_est(data, m1, m2, bin_count, mean, std, title, ax):
    X = np.linspace(data.min()-10, data.max()+10, 1000)
    counts, bins = np.histogram(data, bins=bin_count)
    bins = bins.round()
    
    lamb_hat = 1 / np.sqrt(m2 - m1*m1)
    l_hat = m1 - np.sqrt(m2 - m1*m1)
    
    a_hat = m1 - np.sqrt(3 * (m2 - m1*m1))
    b_hat = m1 + np.sqrt(3 * (m2 - m1*m1))

    Y_norm = st.norm.pdf(X, loc=mean, scale=std)
    
    X_exp = np.linspace(l_hat, data.max()+10, 1000)
    Y_exp = st.expon.pdf(X_exp, loc=l_hat, scale=1/lamb_hat)
    
    sns.histplot(data, bins=bins, stat="density", ax=ax, color="grey", alpha=0.3)
    
    sns.lineplot(x=X, y=Y_norm, ax=ax, label=f"N ( {mean:.0f}, {std*std:.0f} )", linewidth=2.5, color="royalblue")
    
    sns.lineplot(x=X_exp, y=Y_exp, ax=ax, label=f"Exp ( {lamb_hat:.4f}, {l_hat:.0f} )", linewidth=2.5, color="darkorange")
    sns.lineplot(x=[X.min(), l_hat], y=0.0003, ax=ax, linewidth=2.5, color="darkorange")
    
    sns.lineplot(x=[a_hat, b_hat], y=1 / (b_hat - a_hat), ax=ax, label=f"U ( {a_hat:.0f}, {b_hat:.0f} )", linewidth=2.5, color="green")
    sns.lineplot(x=[X.min(), a_hat], y=0.0003, ax=ax, linewidth=2.5, color="green")
    sns.lineplot(x=[b_hat, X.max()], y=0.0003, ax=ax, linewidth=2.5, color="green")
    
    ax.legend(loc="upper right")
    ax.set_title(title)

fig, axes = plt.subplots(1, 2, figsize=(10, 5), layout="constrained", sharey=True)
add_est(S, S_m1, S_m2, 9, S_mean, S_std, "Survived", axes[0])
add_est(P, P_m1, P_m2, 6, P_mean, P_std, "Perished", axes[1])

png

Conclusion

Both groups look approximately normal, and the normal distribution fits best of the three.


simulation

For each group, I simulate 100 values from the fitted normal distribution and compare the histogram to the original data.

def plot_norm(data, mean, std, bin_count):
    fig, axes = plt.subplots(1, 2, figsize=(10, 5), layout="constrained", sharey=True)

    counts, bins = np.histogram(data, bins=bin_count)
    bins = bins.round()
    w = bins[1] - bins[0]
    
    ax = axes[0]
    sns.histplot(data, bins=bins, stat="density", ax=ax)
    ax.set_title("Original data")

    X = np.linspace(data.min()-10, data.max()+10, 1000)
    while X.max() > bins.max():
        bins = np.append(bins, [bins.max() + w])
    while X.min() < bins.min():
        bins = np.insert(bins, 0, bins.min() - w)
    
    ax = axes[1]
    new_data = st.norm.rvs(mean, std, 100, random_state=42)
    sns.histplot(new_data, bins=bins, stat="density", ax=ax, color="tomato")
    ax.set_title("Simulated data")
    ax.set_xlabel("Humerus")

survived

plot_norm(S, S_mean, S_std, 9)

png

perished

plot_norm(P, P_mean, P_std, 6)

png

Conclusion

The histograms of the simulated samples look similar to the observed ones.


confidence interval

Now I calculate a two-sided \(95\%\) confidence interval for the mean in each group.

The two-sided \(100 \cdot (1 - \alpha)\%\) confidence interval for \(\mu\) with unknown variance is calculated as

\[ \left( \bar{X}_n - t_{\alpha / 2, n-1} \frac{s_n}{\sqrt{n}}, \bar{X}_n + t_{\alpha / 2, n-1} \frac{s_n}{\sqrt{n}} \right) \]
def interval(data, n, mean, std, alpha):
    t_a_half = st.t.isf(alpha / 2, n - 1)
    delta = t_a_half * std / np.sqrt(n)
    return np.array([mean - delta, mean + delta])

S_ci = interval(S, S_n, S_mean, S_std, 0.05).round(4)
P_ci = interval(P, P_n, P_mean, P_std, 0.05).round(4)

pd.DataFrame({
    "Survived": [f"( {S_ci[0]} , {S_ci[1]} )"],
    "Perished": [f"( {P_ci[0]} , {P_ci[1]} )"],
}, index=["Two-sided 95% confidence interval for \(\mu\)"]).T
Two-sided 95% confidence interval for \(\mu\)
Survived( 731.185 , 744.815 )
Perished( 717.9755 , 737.8578 )

mean value test

For each group, I test at the \(5\%\) level whether the mean equals \(\mu_0 = K\), against the two-sided alternative. \(K\) is a parameter of the course assignment and is far below the observed lengths (around 740), so the outcome of this test is clear in advance.

pd.DataFrame([K], index=["K"], columns=[""])
K16

survived

Test

At significance level \(\alpha = 5\%\), the test is

\[ H_0 : \quad \mu = 16 \]

\[ H_A : \quad \mu \neq 16 \]

The corresponding confidence interval is

\[ (L, U) = (731.19 , 744.81)\]

The tested value \(\mu_0 = 16\) is not inside the interval.

Test conclusion

At the \(5\%\) level, we reject the null hypothesis. The true mean humerus length of surviving sparrows is not 16.

perished

Test

At significance level \(\alpha = 5\%\), the test is

\[ H_0 : \quad \mu = 16 \]

\[ H_A : \quad \mu \neq 16 \]

The corresponding confidence interval is

\[ (L, U) = (717.98 , 737.86)\]

The tested value \(\mu_0 = 16\) is not inside the interval.

Test conclusion

At the \(5\%\) level, we reject the null hypothesis. The true mean humerus length of sparrows that died is not 16.


test for equality of means

At the \(5\%\) level, I test whether the two groups have the same mean.

two-sample t-test

pd.DataFrame([np.round(S_std / P_std, 4)], index=["\(s_{\text{survived}} / s_{\text{perished}}\)"], columns=[""])
\(s_{\text{survived}} / s_{\text{perished}}\)0.8427

The variances look similar enough to assume equal variances.

\[ \frac{s_1}{s_2} \approx 0.8427 \]

\[ \frac{1}{2} \lt \frac{s_1}{s_2} \lt 2 \]

I use a two-sample t-test for normal distributions.

The test statistic \(T\) is compared to the critical value from Student’s \(T\)-distribution.

\[ T = \frac{\bar{X}_1 - \bar{X}_2}{s_{12}} \cdot \sqrt{ \frac{n_1 n_2}{n_1 + n_2}} \]

Where

\[ s_{12} = \sqrt{ \frac{ \left( n_1 - 1 \right) s_1^2 + \left( n_2 - 1 \right) s_2^2 }{ n_1 + n_2 - 2 } } \]
SP_std = np.sqrt(( (S_n - 1) * S_var + (P_n - 1) * P_var )/( S_n + P_n - 2 ))
T_stat = (S_mean - P_mean) / SP_std * np.sqrt((S_n * P_n)/(S_n + P_n))
T_crit_05_57 = st.t.isf(0.05, 57)
T_crit_025_57 = st.t.isf(0.025, 57)

pd.DataFrame(np.array([T_stat, T_crit_05_57]).round(4),
             index=["\(T\)", "\(t_{0.05, 57}\)"], columns=["Value"])
Value
\(T\)1.777
\(t_{0.05, 57}\)1.672

Test

At significance level \(\alpha = 5\%\), the test is

\[ H_0 \colon \quad \mu_{\text{survived}} = \mu_{\text{perished}} \]

\[ H_A \colon \quad \mu_{\text{survived}} > \mu_{\text{perished}} \]

The test statistic is \(T = 1.777\).

Since

\[ 1.777 = T \gt t_{\alpha, n_1 + n_2 - 2} = t_{0.05, 57} = 1.672 \]

We reject the null hypothesis at the \(5\%\) level.


conclusion

Surviving sparrows had a longer mean humerus than those that died. The one-sided test rejects equal means at 5% in favor of the survivors.


alternative conclusion

pd.DataFrame(np.array([T_stat, T_crit_025_57]).round(4),
             index=["\(T\)", "\(t_{0.025, 57}\)"], columns=["Value"])
Value
\(T\)1.777
\(t_{0.025, 57}\)2.0025

With a two-sided alternative instead:

\[ H_0 \colon \quad \mu_{\text{survived}} = \mu_{\text{perished}} \]

\[ H_A \colon \quad \mu_{\text{survived}} \neq \mu_{\text{perished}} \]

\[ 1.777 = |T| \lt t_{\alpha / 2, n_1 + n_2 − 2} = t_{0.025, 57} = 2.0025 \]

The two-sided \(p\)-value is above \(5\%\), so the two-sided test does not reject \(H_0\). The result therefore depends on the one-sided alternative, which is only justified if the direction was hypothesized before looking at the data.

p_greater = st.mstats.ttest_ind(S, P, equal_var=True, alternative="greater")
p_two_sided = st.mstats.ttest_ind(S, P, equal_var=True, alternative="two-sided")
pd.DataFrame(np.array([p_greater, p_two_sided]).round(4),
             index=["One-sided test", "Two-sided test"], columns=["T statistic", "p-value"])
T statisticp-value
One-sided test1.7770.0405
Two-sided test1.7770.0809