数据分析的第三只眼睛是统计学。没有统计学,你看到的只是数字的堆砌;有了统计学,你才能判断这些差异是否显著、这些趋势是否可靠。

本文的目标不是让你成为统计学家,而是让你掌握数据分析中最常用的统计工具——能看懂统计指标,能做假设检验,能判断什么时候该用什么方法。

环境准备

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
from scipy.stats import (norm, t, chi2, f, 
                          ttest_ind, ttest_rel, 
                          chisquare, f_oneway,
                          pearsonr, spearmanr)
import statsmodels.api as sm
import warnings
warnings.filterwarnings('ignore')

plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
sns.set_theme(style='whitegrid')

描述性统计回顾

在动手之前,先快速温习基础篇学过的描述性统计。

data = np.random.normal(loc=50, scale=15, size=1000)

# 集中趋势
mean = np.mean(data)
median = np.median(data)
mode = stats.mode(data, keepdims=True).mode[0]

# 离散程度
variance = np.var(data, ddof=1)  # 样本方差
std = np.std(data, ddof=1)
range_val = np.ptp(data)
iqr = np.percentile(data, 75) - np.percentile(data, 25)
cv = std / mean  # 变异系数

# 分布形态
skewness = stats.skew(data)
kurtosis = stats.kurtosis(data)  # 超额峰度(正态=0)

print(f'均值: {mean:.2f}')
print(f'中位数: {median:.2f}')
print(f'众数: {mode:.2f}')
print(f'方差: {variance:.2f}')
print(f'标准差: {std:.2f}')
print(f'极差: {range_val:.2f}')
print(f'四分位距: {iqr:.2f}')
print(f'变异系数: {cv:.3f}')
print(f'偏度: {skewness:.3f}')
print(f'峰度: {kurtosis:.3f}')
  • 偏度 > 0:右偏,均值 > 中位数(如收入分布)
  • 偏度 < 0:左偏,均值 < 中位数(如考试分数)
  • 峰度 > 0:尖峰厚尾,极端值较多
  • 峰度 < 0:平峰薄尾,数据分布更均匀

概率基础

概率分布

数据分布决定了你用哪种统计方法。下面是数据分析中最常见的四种分布:

fig, axes = plt.subplots(2, 2, figsize=(12, 8))

# 正态分布
x = np.linspace(-4, 4, 1000)
axes[0, 0].plot(x, norm.pdf(x, 0, 1), 'b-', linewidth=2)
axes[0, 0].fill_between(x, norm.pdf(x, 0, 1), alpha=0.3)
axes[0, 0].set_title('正态分布 N(0,1)')
axes[0, 0].set_xlabel('x')
axes[0, 0].set_ylabel('概率密度')

# 二项分布
from scipy.stats import binom
n, p = 20, 0.3
x = np.arange(0, n+1)
axes[0, 1].bar(x, binom.pmf(x, n, p))
axes[0, 1].set_title(f'二项分布 B({n},{p})')
axes[0, 1].set_xlabel('成功次数')
axes[0, 1].set_ylabel('概率')

# 泊松分布
from scipy.stats import poisson
lam = 3
x = np.arange(0, 15)
axes[1, 0].bar(x, poisson.pmf(x, lam))
axes[1, 0].set_title(f'泊松分布 P(λ={lam})')
axes[1, 0].set_xlabel('事件次数')
axes[1, 0].set_ylabel('概率')

# 均匀分布
from scipy.stats import uniform
x = np.linspace(0, 1, 1000)
axes[1, 1].plot(x, uniform.pdf(x, 0, 1), 'r-', linewidth=2)
axes[1, 1].fill_between(x, uniform.pdf(x, 0, 1), alpha=0.3)
axes[1, 1].set_title('均匀分布 U(0,1)')
axes[1, 1].set_xlabel('x')
axes[1, 1].set_ylabel('概率密度')

plt.tight_layout()
plt.show()

中心极限定理

这是整个推断统计的基石。简单来说:

无论原始数据是什么分布,只要样本量足够大(通常 n >= 30),样本均值的分布近似正态分布。

# 演示中心极限定理
# 从一个极度偏态的分布中反复抽样
population = np.random.exponential(scale=2, size=100000)

sample_means = []
for _ in range(1000):
    sample = np.random.choice(population, size=30)
    sample_means.append(np.mean(sample))

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

axes[0].hist(population, bins=50, alpha=0.7)
axes[0].set_title('总体分布(指数分布,偏态)')

axes[1].hist(sample_means, bins=50, alpha=0.7, density=True)
x = np.linspace(min(sample_means), max(sample_means), 100)
axes[1].plot(x, norm.pdf(x, np.mean(sample_means), np.std(sample_means)), 
             'r-', linewidth=2)
axes[1].set_title('样本均值的分布(近似正态)')

plt.tight_layout()
plt.show()

推断统计

描述性统计只描述你手头的数据,而推断统计是根据样本数据推断总体特征。

置信区间

置信区间给出了总体参数的可能范围。95% 置信区间的含义是:重复抽样 100 次,大约有 95 个区间会包含真实的总体均值。

# 计算均值的置信区间
np.random.seed(42)
sample = np.random.normal(loc=100, scale=15, size=50)

mean = np.mean(sample)
se = stats.sem(sample)  # 标准误
confidence = 0.95

ci = stats.t.interval(confidence, df=len(sample)-1, loc=mean, scale=se)
print(f'样本均值: {mean:.2f}')
print(f'95% 置信区间: [{ci[0]:.2f}, {ci[1]:.2f}]')

用 bootstrap 方法计算置信区间(不依赖正态假设):

def bootstrap_ci(data, stat_func=np.mean, n_bootstrap=10000, ci=95):
    bootstrap_stats = []
    for _ in range(n_bootstrap):
        bootstrap_sample = np.random.choice(data, size=len(data), replace=True)
        bootstrap_stats.append(stat_func(bootstrap_sample))
    
    lower = np.percentile(bootstrap_stats, (100 - ci) / 2)
    upper = np.percentile(bootstrap_stats, 100 - (100 - ci) / 2)
    return lower, upper

ci_boot = bootstrap_ci(sample)
print(f'Bootstrap 95% CI: [{ci_boot[0]:.2f}, {ci_boot[1]:.2f}]')

假设检验的完整框架

假设检验是数据分析中最常用的推断工具。它的逻辑是:

  1. 提出原假设 H0(通常是"没有差异")
  2. 计算在原假设下观察到当前数据的概率(p-value)
  3. 如果 p-value 小于显著性水平(通常 0.05),拒绝 H0
# 单样本 t 检验:样本均值是否等于某个值
sample = np.random.normal(loc=102, scale=15, size=50)
t_stat, p_value = stats.ttest_1samp(sample, popmean=100)
print(f'单样本 t 检验: t = {t_stat:.3f}, p = {p_value:.4f}')
print(f'结论: {"显著不同" if p_value < 0.05 else "无显著差异"}')

t 检验——比较两组均值

独立样本 t 检验(两组独立数据):

# 模拟 A/B 测试数据
group_a = np.random.normal(loc=50, scale=10, size=100)
group_b = np.random.normal(loc=53, scale=10, size=100)

# 等方差 t 检验
t_stat, p_value = ttest_ind(group_a, group_b)
print(f'独立 t 检验: t = {t_stat:.3f}, p = {p_value:.4f}')

# 不等方差 t 检验(Welch's t-test)
t_stat_w, p_value_w = ttest_ind(group_a, group_b, equal_var=False)
print(f'Welch t 检验: t = {t_stat_w:.3f}, p = {p_value_w:.4f}')

配对 t 检验(同一组的前后对比):

# 模拟前后测量数据
before = np.random.normal(loc=70, scale=12, size=30)
after = before - np.random.normal(loc=5, scale=3, size=30)  # 平均降低 5

t_stat, p_value = ttest_rel(before, after)
print(f'配对 t 检验: t = {t_stat:.3f}, p = {p_value:.4f}')

# 效应量 Cohen's d
cohens_d = np.mean(before - after) / np.std(before - after, ddof=1)
print(f'Cohen\'s d = {cohens_d:.3f}{"大" if abs(cohens_d) > 0.8 else "中" if abs(cohens_d) > 0.5 else "小"}效应)')

卡方检验——分类变量独立性

# 构建列联表
observed = np.array([[50, 30], [20, 40]])  # 行: 性别, 列: 购买与否
chi2_stat, p_value, dof, expected = stats.chi2_contingency(observed)

print(f'卡方检验: χ² = {chi2_stat:.3f}, p = {p_value:.4f}')
print(f'自由度: {dof}')
print('期望频数:')
print(expected)

# 用实际数据
df = sns.load_dataset('titanic')
contingency = pd.crosstab(df['sex'], df['survived'])
chi2_stat, p_value, dof, expected = stats.chi2_contingency(contingency)
print(f'\n泰坦尼克数据——性别与生存: χ² = {chi2_stat:.3f}, p = {p_value:.4f}')

ANOVA——比较三组及以上均值

# 加载 iris 数据
iris = sns.load_dataset('iris')

# 三组花瓣长度比较
setosa = iris[iris['species'] == 'setosa']['petal_length']
versicolor = iris[iris['species'] == 'versicolor']['petal_length']
virginica = iris[iris['species'] == 'virginica']['petal_length']

# 单因素 ANOVA
f_stat, p_value = f_oneway(setosa, versicolor, virginica)
print(f'ANOVA: F = {f_stat:.3f}, p = {p_value:.4f}')

# 事后检验(Tukey HSD)
from scipy.stats import tukey_hsd
res = tukey_hsd(setosa, versicolor, virginica)
print('事后检验 p 值:')
print(res.pvalue)

相关性检验

# Pearson 相关系数
x = np.random.normal(0, 1, 100)
y = 0.6 * x + np.random.normal(0, 0.8, 100)

r, p_value = pearsonr(x, y)
print(f'Pearson r = {r:.3f}, p = {p_value:.4f}')

# Spearman 秩相关(适合非线性单调关系)
x_nonlin = np.linspace(-3, 3, 100)
y_nonlin = x_nonlin**3 + np.random.normal(0, 2, 100)

r_s, p_s = spearmanr(x_nonlin, y_nonlin)
r_p, p_p = pearsonr(x_nonlin, y_nonlin)
print(f'Spearman ρ = {r_s:.3f}(捕捉非线性关系)')
print(f'Pearson r = {r_p:.3f}(可能低估非线性关系)')

实际案例:A/B 测试全流程

把以上知识串起来,做一个完整的 A/B 测试分析:

np.random.seed(42)

# 模拟实验数据
n_visitors = 2000
n_days = 14

# 生成每日转化数据
dates = pd.date_range('2025-01-01', periods=n_days)

control_data = []
treatment_data = []

for date in dates:
    # 每天 2000 访客
    control_conversions = np.random.binomial(n=n_visitors, p=0.08)
    treatment_conversions = np.random.binomial(n=n_visitors, p=0.09)
    
    control_data.append({
        'date': date,
        'visitors': n_visitors,
        'conversions': control_conversions,
        'rate': control_conversions / n_visitors
    })
    treatment_data.append({
        'date': date,
        'visitors': n_visitors,
        'conversions': treatment_conversions,
        'rate': treatment_conversions / n_visitors
    })

df_control = pd.DataFrame(control_data)
df_treatment = pd.DataFrame(treatment_data)

# 1. 总体转化率
overall_control = df_control['conversions'].sum() / df_control['visitors'].sum()
overall_treatment = df_treatment['conversions'].sum() / df_treatment['visitors'].sum()

print(f'对照组转化率: {overall_control:.4f} ({overall_control*100:.2f}%)')
print(f'实验组转化率: {overall_treatment:.4f} ({overall_treatment*100:.2f}%)')
print(f'提升幅度: {(overall_treatment/overall_control - 1)*100:.2f}%')

# 2. 假设检验
# 准备两组的所有个体数据(0/1 数组)
control_individuals = np.concatenate([
    np.ones(df_control['conversions'].sum()),
    np.zeros(df_control['visitors'].sum() - df_control['conversions'].sum())
])
treatment_individuals = np.concatenate([
    np.ones(df_treatment['conversions'].sum()),
    np.zeros(df_treatment['visitors'].sum() - df_treatment['conversions'].sum())
])

# 比例的 z 检验
from statsmodels.stats.proportion import proportions_ztest
counts = np.array([treatment_individuals.sum(), control_individuals.sum()])
nobs = np.array([len(treatment_individuals), len(control_individuals)])

z_stat, p_value = proportions_ztest(counts, nobs)
print(f'\nZ 检验: z = {z_stat:.3f}, p = {p_value:.4f}')

# 3. 置信区间
from statsmodels.stats.proportion import proportion_confint
ci_control = proportion_confint(counts[1], nobs[1], alpha=0.05)
ci_treatment = proportion_confint(counts[0], nobs[0], alpha=0.05)
print(f'对照组 95% CI: [{ci_control[0]:.4f}, {ci_control[1]:.4f}]')
print(f'实验组 95% CI: [{ci_treatment[0]:.4f}, {ci_treatment[1]:.4f}]')

# 4. 每日转化率趋势
plt.figure(figsize=(10, 5))
plt.plot(df_control['date'], df_control['rate'], 
         'o-', label=f'对照组 ({overall_control:.3f})', alpha=0.7)
plt.plot(df_treatment['date'], df_treatment['rate'], 
         's-', label=f'实验组 ({overall_treatment:.3f})', alpha=0.7)
plt.axhline(y=overall_control, color='gray', linestyle='--', alpha=0.5)
plt.axhline(y=overall_treatment, color='gray', linestyle='--', alpha=0.5)
plt.xlabel('日期')
plt.ylabel('转化率')
plt.title('A/B 测试每日转化率趋势')
plt.legend()
plt.show()

# 5. 样本量估算(用于实验设计)
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize

effect_size = proportion_effectsize(overall_treatment, overall_control)
power_analysis = NormalIndPower()
required_n = power_analysis.solve_power(
    effect_size=effect_size,
    power=0.8,
    alpha=0.05,
    ratio=1.0,
    alternative='two-sided'
)
print(f'\n检测当前效应量需要的每组样本量: {int(required_n):,}')

相关性与因果性

这是统计学里最重要的一课:

相关 ≠ 因果

# 经典的"冰淇淋销量与溺水人数正相关"演示
months = np.arange(1, 13)
ice_cream = 100 + 50 * np.sin(np.pi * (months - 3) / 6) + np.random.normal(0, 10, 12)
drowning = 30 + 15 * np.sin(np.pi * (months - 3) / 6) + np.random.normal(0, 5, 12)

r, _ = pearsonr(ice_cream, drowning)
print(f'冰淇淋 vs 溺水 相关系数: r = {r:.3f}')

plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.scatter(ice_cream, drowning)
plt.xlabel('冰淇淋销量')
plt.ylabel('溺水人数')
plt.title(f'相关: r = {r:.3f}')

plt.subplot(1, 2, 2)
plt.plot(months, ice_cream, 'o-', label='冰淇淋')
plt.plot(months, drowning, 's-', label='溺水')
plt.xlabel('月份')
plt.ylabel('数量')
plt.title('共同原因:夏天')
plt.legend()
plt.show()

判断因果的 Bradford Hill 标准

  1. 时间顺序(原因在前)
  2. 效应强度(强关联更可能是因果)
  3. 剂量反应(越多越严重)
  4. 一致性(不同人群都观察到)
  5. 合理性(有机制解释)
  6. 特异性(特定原因导致特定结果)

选择统计方法的决策树

面对一个问题时,按这个流程选方法:

数据是什么类型?
├── 数值型
│   ├── 与已知值比较?→ 单样本 t 检验
│   ├── 两组比较
│   │   ├── 独立样本?→ 独立 t 检验
│   │   ├── 配对样本?→ 配对 t 检验
│   │   └── 非正态?→ Mann-Whitney U 检验
│   ├── 多组比较
│   │   ├── 一个因素?→ ANOVA + Tukey HSD
│   │   ├── 两个因素?→ Two-way ANOVA
│   │   └── 非正态?→ Kruskal-Wallis 检验
│   └── 关系分析
│       ├── 线性关系?→ Pearson 相关
│       └── 单调关系?→ Spearman 相关
├── 分类型
│   ├── 拟合优度?→ 卡方拟合优度检验
│   └── 独立性?→ 卡方独立性检验
└── 生存数据 → Kaplan-Meier + Log-rank 检验

小结

回到开头的比喻——统计学是数据分析师的第三只眼。它让你能从样本中推断总体,从差异中判断显著性,从相关中追问因果。

本文没有面面俱到地讲所有统计方法(那需要整本书),而是覆盖了数据分析中最常用的核心工具:

  • 点估计与区间估计:均值和置信区间
  • 假设检验:t 检验、卡方检验、ANOVA
  • 效应量:Cohen’s d
  • 相关性:Pearson 和 Spearman
  • 因果思维:相关不等于因果

掌握这些工具,你就能为绝大多数分析问题找到可信的答案。下一篇文章我们换个角度,聊聊数据库里的分析——SQL 数据分析。

Summary: 推断统计、假设检验与 A/B 测试实战。