6 minutes
统计学基础
数据分析的第三只眼睛是统计学。没有统计学,你看到的只是数字的堆砌;有了统计学,你才能判断这些差异是否显著、这些趋势是否可靠。
本文的目标不是让你成为统计学家,而是让你掌握数据分析中最常用的统计工具——能看懂统计指标,能做假设检验,能判断什么时候该用什么方法。
环境准备
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}]')
假设检验的完整框架
假设检验是数据分析中最常用的推断工具。它的逻辑是:
- 提出原假设 H0(通常是"没有差异")
- 计算在原假设下观察到当前数据的概率(p-value)
- 如果 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 标准:
- 时间顺序(原因在前)
- 效应强度(强关联更可能是因果)
- 剂量反应(越多越严重)
- 一致性(不同人群都观察到)
- 合理性(有机制解释)
- 特异性(特定原因导致特定结果)
选择统计方法的决策树
面对一个问题时,按这个流程选方法:
数据是什么类型?
├── 数值型
│ ├── 与已知值比较?→ 单样本 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 测试实战。