54  Pandas模块的统计计算

本章定位(复习与补充训练层):本章对应正课第6章《NumPy 数组运算》(章节 6)中的统计运算部分,并与第33章《数据分布情况分析》(章节 33)互为补充,用于复习、补缺与额外练习。建议先不看讲解,直接尝试下方平台任务,再对照解析补弱项。本章不属于必修主线。先做本章『动手与思考』第 1 题与平台任务自测,通过即可跳过本章。

54.1 引言统计计算是数据分析的核心

统计计算帮助我们量化风险(波动率、VaR)、评估收益、发现规律(相关性、趋势、周期性)并支持决策;Pandas 提供丰富的统计计算方法,结合 NumPy 的向量化运算,可以高效处理大规模金融数据。

54.2 本章学习目标

通过本章的复习与补充训练,你将能够:

  1. describe()mean()/std()/median()/quantile() 等方法完成描述性统计,并说明 Pandas 与 NumPy 在方差口径(ddof)上的默认差异
  2. 计算并解读偏度与峰度,据此判断收益分布的对称性与厚尾程度
  3. cumsum()/cumprod() 计算累积统计量,用 rolling()expanding() 窗口计算移动平均、滚动波动率等指标
  4. groupby() 完成分组聚合(含 agg 与自定义函数),用 corr()/cov() 度量多资产间的相关结构,独立完成平台任务

54.3 描述性统计

54.3.1 数学原理(描述性统计)

给定数据集 \(X = \{x_1, x_2, \ldots, x_n\}\):

集中趋势:

  • 均值(Mean): \(\bar{x} = \frac{1}{n}\sum_{i=1}^n x_i\)
  • 中位数(Median): 排序后位于中间位置的值
  • 众数(Mode): 出现频率最高的值

离散程度:

  • 样本方差(Sample Variance): \(s^2 = \frac{1}{n-1}\sum_{i=1}^n (x_i - \bar{x})^2\)(Pandas 的 var() 默认 ddof=1,即样本方差;NumPy 默认 ddof=0,为总体方差 \(\sigma^2 = \frac{1}{n}\sum_{i=1}^n (x_i - \bar{x})^2\))
  • 标准差(Standard Deviation): \(s = \sqrt{s^2}\)(Pandas 的 std() 同样默认样本口径 ddof=1)
  • 极差(Range): \(\max(X) - \min(X)\)

分布形状:

  • 偏度(Skewness): 衡量分布的对称性
  • 峰度(Kurtosis): 衡量分布的尖峰/厚尾程度

54.3.2 基础统计量计算

任务要求(归纳自块内任务注释与代码,同一代码块含四问):

  • 任务一:从给定地址读取 QDII 基金净值 Excel(117 行 × 5 列),把”日期”列转为 datetime 并设为索引,删除缺失行,以首个交易日净值归一化后绘制四条净值曲线(带网格),保存为 1.png
  • 任务二:输出各基金净值的最大值、最小值,以及两者所在的日期索引(idxmax/idxmin)
  • 任务三:用 diff() 计算每日净值变动金额,输出前 5 行与后 5 行
  • 任务四:用 pct_change() 计算每日净值百分比变动(该段 head()/tail() 未加 print,控制台不显示),再用任务三的差分除以 shift(1) 的前值重建百分比变动并输出前 5 行与后 5 行

请将代码原样输入教学平台(注释除外),判定以平台为准。

平台任务(平台原始代码)

以下代码与教学平台任务要求完全一致:

列表 54.1: 平台原始代码 (1)-(4)
# ⚠️ 平台原始代码 - 请原样输入至教学平台(注释除外),平台才会判定答案正确
#任务一
import pandas as pd
import matplotlib.pyplot as plt  # 导入Matplotlib绘图库
# 从Excel文件读取数据存入value_QDII
value_QDII = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/1726648479386.xlsx") 
value_QDII["日期"] = pd.to_datetime(value_QDII["日期"] , format='%Y%m%d')  # 转换为日期时间格式
value_QDII.set_index("日期",inplace=True)  # 将日期列设为value_QDII数据框的索引
value_QDII = value_QDII.dropna() #删除缺失值所在行
(value_QDII/value_QDII.iloc[0]).plot(figsize=(8,6),grid=True) #将基金净值按首个交易日进行归一处理并可视化
plt.savefig("1.png")  # 保存图形至文件

#任务二
import pandas as pd
# 从Excel文件读取数据存入value_QDII
value_QDII = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/1726648479386.xlsx") 
value_QDII["日期"] = pd.to_datetime(value_QDII["日期"] , format='%Y%m%d')  # 转换为日期时间格式
value_QDII.set_index("日期",inplace=True)  # 将日期列设为value_QDII数据框的索引
print(value_QDII.max())  #找出每只基金净值的最大值
print(value_QDII.min())  #找出每只基金净值的最小值
print(value_QDII.idxmax()) #最大值所在的索引值
print(value_QDII.idxmin()) #最小值所在的索引值


#任务三
import pandas as pd
# 从Excel文件读取数据存入value_QDII
value_QDII = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/1726648479386.xlsx") 
value_QDII["日期"] = pd.to_datetime(value_QDII["日期"] , format='%Y%m%d')  # 转换为日期时间格式
value_QDII.set_index("日期",inplace=True)  # 将日期列设为value_QDII数据框的索引
value_QDII_diff = value_QDII.diff()  # 计算基金每日净值的变动金额
print(value_QDII_diff.head())  #查看前五行数据
print(value_QDII_diff.tail())  #查看后五行数据

#任务四
import pandas as pd
# 从Excel文件读取数据存入value_QDII
value_QDII = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/1726648479386.xlsx") 
value_QDII_pctchangel = value_QDII.pct_change() #直接使用函数pct_change计算基金每日净值百分比变动

value_QDII_pctchangel.head()  # 查看value_QDII_pctchangel前5行数据
value_QDII_pctchangel.tail()  # 查看value_QDII_pctchangel后5行数据
value_QDII_diff = value_QDII.diff()  # 计算差分值
value_QDII_pctchange2 = value_QDII_diff/value_QDII.shift(1) #运用任务三的结果计算基金每日净值百分比变动
print(value_QDII_pctchange2.head())  # 输出前几行数据
print(value_QDII_pctchange2.tail())  # 输出最后几行数据

预期输出(数据来自教学平台的 OSS 直链,OSS 直链本机实测;具体数值以平台运行结果为准):

任务二先输出四组统计量(依次为各基金最大值、最小值、最大值所在日期、最小值所在日期),作者实测如下:

华夏全球股票       1.1114
华安香港精选股票     1.8300
工银瑞信全球股票     2.3920
易方达亚洲精选股票    1.0890
dtype: float64
华夏全球股票       0.9131
华安香港精选股票     1.4310
工银瑞信全球股票     2.1320
易方达亚洲精选股票    0.8460
dtype: float64
华夏全球股票      2024-06-19
华安香港精选股票    2024-06-20
工银瑞信全球股票    2024-05-20
易方达亚洲精选股票   2024-06-19
dtype: datetime64[ns]
华夏全球股票      2024-01-04
华安香港精选股票    2024-01-22
工银瑞信全球股票    2024-01-17
易方达亚洲精选股票   2024-01-17
dtype: datetime64[ns]

任务三随后输出差分表的前 5 行(首行全为 NaN)与后 5 行,行索引为日期,如 2024-01-05 行的四只基金变动为 0.0004、-0.011、-0.004、-0.006。任务四输出的表结构不同:因该段未 set_index,表中多出一个整数索引与一个”日期”列(取值形如 4.94e-08,是 yyyymmdd 整数日期做差分再除以前值的结果),首行同样全为 NaN——这是任务三、任务四代码差异的直接体现。任务一仅生成归一化净值曲线图 1.png,无控制台输出。

列表 54.2: 计算基础描述性统计量
import pandas as pd
import numpy as np

# 创建股票收益率数据
np.random.seed(42)
returns_data = {
    '贵州茅台': np.random.normal(0.001, 0.02, 100),  # 日收益率
    '五粮液': np.random.normal(0.0008, 0.025, 100),
    '招商银行': np.random.normal(0.0005, 0.015, 100),
    '中国平安': np.random.normal(0.0006, 0.018, 100)
}

df_returns = pd.DataFrame(returns_data)

print('收益率数据(前10行):')
print(df_returns.head(10))

# 计算各项统计量
print('\n基础统计量:')
print(f'均值:\n{df_returns.mean()}')
print(f'\n中位数:\n{df_returns.median()}')
print(f'\n标准差:\n{df_returns.std()}')
print(f'\n方差:\n{df_returns.var()}')
print(f'\n最小值:\n{df_returns.min()}')
print(f'\n最大值:\n{df_returns.max()}')

# 一次性获取所有描述性统计
print('\n完整描述性统计:')
desc_stats = df_returns.describe()
print(desc_stats)

54.3.3 describe()方法详解

describe() 方法返回的统计量:

统计量 含义 公式
count 非缺失值数量 \(n_{\text{valid}}\)
mean 均值 \(\bar{x} = \frac{1}{n}\sum x_i\)
std 标准差 \(\sqrt{\frac{1}{n-1}\sum(x_i-\bar{x})^2}\)
min 最小值 \(\min(X)\)
25% 第一四分位数 \(Q_1 = P_{25}\)
50% 第二四分位数(中位数) \(Q_2 = P_{50}\)
75% 第三四分位数 \(Q_3 = P_{75}\)
max 最大值 \(\max(X)\)

54.3.4 分位数计算

列表 54.3: 计算自定义分位数
# 计算常用分位数
quantiles = [0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]
print('自定义分位数:')
print(df_returns.quantile(quantiles))

# 四分位距(IQR)
Q1 = df_returns.quantile(0.25)
Q3 = df_returns.quantile(0.75)
IQR = Q3 - Q1
print('\n四分位距(IQR):')
print(IQR)

# 识别异常值(超出1.5*IQR)
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
print('\n异常值边界:')
print(f'下界:\n{lower_bound}')
print(f'\n上界:\n{upper_bound}')

# 检测异常值
outliers = (df_returns < lower_bound) | (df_returns > upper_bound)
print(f'\n异常值数量:')
print(outliers.sum())

异常值检测的数学原理:

箱线图规则(Boxplot Rule):

  • 正常值: \([Q_1 - 1.5 \times IQR, Q_3 + 1.5 \times IQR]\)
  • 温和异常值(Mild Outlier): 距离箱体 \(1.5-3 \times IQR\)
  • 极端异常值(Extreme Outlier): 距离箱体 \(> 3 \times IQR\)

Z-Score方法:

\[ Z_i = \frac{x_i - \bar{x}}{\sigma} \] 通常认为 \(|Z| > 3\) 为异常值。

54.4 偏度与峰度

54.4.1 数学原理(随机性与波动)

偏度(Skewness):

\[ \gamma_1 = \frac{E[(X-\mu)^3]}{\sigma^3} = \frac{\frac{1}{n}\sum_{i=1}^n (x_i-\bar{x})^3}{\left[\sqrt{\frac{1}{n}\sum_{i=1}^n (x_i-\bar{x})^2}\right]^3} \]

峰度(Kurtosis):

\[ \gamma_2 = \frac{E[(X-\mu)^4]}{\sigma^4} - 3 = \frac{\frac{1}{n}\sum_{i=1}^n (x_i-\bar{x})^4}{\left[\frac{1}{n}\sum_{i=1}^n (x_i-\bar{x})^2\right]^2} - 3 \]

54.4.2 金融意义

列表 54.4: 计算偏度和峰度
# 计算偏度和峰度
skewness = df_returns.skew()
kurtosis = df_returns.kurtosis()

print('偏度(Skewness):')
print(skewness)
print('\n峰度(Kurtosis):')
print(kurtosis)

# 解释
print('\n解释:')
for stock in df_returns.columns:
    skew_val = skewness[stock]
    kurt_val = kurtosis[stock]

    # 偏度解释
    if skew_val > 0.5:
        skew_interp = '右偏(正偏),有较长右尾,极端正收益更多'
    elif skew_val < -0.5:
        skew_interp = '左偏(负偏),有较长左尾,极端负收益更多'
    else:
        skew_interp = '近似对称'

    # 峰度解释
    if kurt_val > 1:
        kurt_interp = '尖峰分布,有较多极端值(厚尾)'
    elif kurt_val < -1:
        kurt_interp = '低峰分布,较为平坦'
    else:
        kurt_interp = '接近正态分布'

    print(f'\n{stock}:')
    print(f'  偏度={skew_val:.3f}{skew_interp}')
    print(f'  峰度={kurt_val:.3f}{kurt_interp}')

金融应用:

  1. 正偏度:
    • 大多数时间小幅下跌
    • 偶尔出现大幅上涨(如牛市中的股票)
    • 符合投资者偏好(有限亏损,无限收益)
  2. 负偏度:
    • 大多数时间小幅上涨
    • 偶尔出现暴跌(如高杠杆资产)
    • 风险较高(黑天鹅事件)
  3. 高峰度:
    • 厚尾(Fat Tails): 极端事件发生的概率高于正态分布
    • 金融市场的典型特征
    • 风险管理需要考虑极端情况

正态分布检验:

如果数据完全服从正态分布:

  • 偏度 ≈ 0
  • 峰度 ≈ 0(超额峰度)
  • Jarque-Bera检验: \(JB = \frac{n}{6}\left(\gamma_1^2 + \frac{\gamma_2^2}{4}\right)\)

54.5 累积统计量

列表 54.5: 计算累积统计量
# 创建价格序列
prices = pd.DataFrame({
    '贵州茅台': [1850, 1860, 1855, 1870, 1865, 1880],
    '五粮液': [220, 218, 222, 225, 223, 226]
})

print('原始价格:')
print(prices)

# 累积和
print('\n累积和:')
print(prices.cumsum())

# 累积积
print('\n累积积:')
print(prices.cumprod())

# 累积最大值
print('\n累积最大值( expanding maximum):')
print(prices.cummax())

# 累积最小值
print('\n累积最小值( expanding minimum):')
print(prices.cummin())

# 金融应用:累计收益率
initial_prices = prices.iloc[0]
cum_returns = (prices / initial_prices - 1) * 100
print('\n累计收益率(%):')
print(cum_returns)

累积统计量的金融应用:

  1. cumsum: 累计收益、累计成交量
  2. cumprod: 复利增长(价格相对变化)
  3. cummax: 回撤分析(Drawdown)
  4. cummin: 历史最低价监控

54.5.1 回撤计算

列表 54.6: 计算最大回撤
# 模拟净值曲线
np.random.seed(42)
nav = pd.DataFrame({
    '日期': pd.date_range('2024-01-01', periods=100),
    '净值': 1.0 + np.cumsum(np.random.normal(0.001, 0.02, 100))
})

# 计算历史最高点
nav['历史最高'] = nav['净值'].cummax()

# 计算回撤
nav['回撤'] = (nav['净值'] - nav['历史最高']) / nav['历史最高']

print('净值与回撤:')
print(nav.head(20))

# 最大回撤
max_drawdown = nav['回撤'].min()
print(f'\n最大回撤: {max_drawdown:.2%}')

# 可视化
import matplotlib.pyplot as plt

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC', 'SimHei']  # 规范字体在前,SimHei 仅作后备
plt.rcParams['axes.unicode_minus'] = False

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True)

# 净值曲线
ax1.plot(nav['日期'], nav['净值'], label='净值', linewidth=2)
ax1.plot(nav['日期'], nav['历史最高'], label='历史最高', linewidth=2, linestyle='--')
ax1.set_ylabel('净值')
ax1.set_title('净值曲线与回撤分析')
ax1.legend()
ax1.grid(True, alpha=0.3)

# 回撤曲线
ax2.fill_between(nav['日期'], nav['回撤'], 0, alpha=0.3, color='red')
ax2.plot(nav['日期'], nav['回撤'], color='red', linewidth=2)
ax2.set_ylabel('回撤率')
ax2.set_xlabel('日期')
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

回撤的金融意义:

回撤(Drawdown):从历史最高点到当前点的下降幅度

\[ \text{Drawdown}_t = \frac{P_t - \max_{i \leq t} P_i}{\max_{i \leq t} P_i} \]

最大回撤(Maximum Drawdown, MDD):

\[ \text{MDD} = \min_t \text{Drawdown}_t \]

最大回撤是衡量投资策略风险的关键指标:

  • MDD = -20%: 意味着历史上曾经从高点下跌20%
  • 心理影响: 投资者需要承受20%的亏损
  • 恢复要求: 下跌20%需要上涨25%才能回本

54.6 窗口统计量滚动计算

54.6.1 数学原理(滚动窗口)

滚动窗口(Rolling Window):对于时间序列 \(\{x_t\}_{t=1}^T\),窗口大小为 \(w\):

\[ \text{RollingMean}_t = \frac{1}{w}\sum_{i=t-w+1}^t x_i \]

54.6.2 滚动统计量计算

列表 54.7: 滚动窗口统计量计算
# 创建价格数据
dates = pd.date_range('2024-01-01', periods=100)
np.random.seed(42)  # 固定随机种子,保证每次运行结果可复现
prices_ts = pd.DataFrame({
    '日期': dates,
    '收盘价': 100 + np.cumsum(np.random.normal(0.5, 2, 100))
})
prices_ts = prices_ts.set_index('日期')

print('原始价格:')
print(prices_ts.tail(10))

# 5日滚动均值
prices_ts['MA5'] = prices_ts['收盘价'].rolling(window=5).mean()

# 20日滚动均值
prices_ts['MA20'] = prices_ts['收盘价'].rolling(window=20).mean()

# 5日滚动标准差
prices_ts['STD5'] = prices_ts['收盘价'].rolling(window=5).std()

# 5日滚动最大值
prices_ts['MAX5'] = prices_ts['收盘价'].rolling(window=5).max()

print('\n滚动统计量:')
print(prices_ts.tail(10))

# 计算布林带
prices_ts['布林带_上'] = prices_ts['MA20'] + 2 * prices_ts['收盘价'].rolling(window=20).std()
prices_ts['布林带_下'] = prices_ts['MA20'] - 2 * prices_ts['收盘价'].rolling(window=20).std()

print('\n布林带:')
print(prices_ts[['收盘价', 'MA20', '布林带_上', '布林带_下']].tail(10))

金融技术指标:

  1. 移动平均(Moving Average, MA):平滑价格波动,识别趋势
  2. 布林带(Bollinger Bands):
    • 中轨: \(MA_{20}\)
    • 上轨: \(MA_{20} + 2\sigma\)
    • 下轨: \(MA_{20} - 2\sigma\)
    • 交易信号:价格触及上轨可能超买,触及下轨可能超卖

54.7 expanding窗口

列表 54.8: 扩展窗口统计量
# expanding:从起点到当前点的累积计算
prices_ts['累积均值'] = prices_ts['收盘价'].expanding().mean()
prices_ts['累积标准差'] = prices_ts['收盘价'].expanding().std()
prices_ts['累积最大值'] = prices_ts['收盘价'].expanding().max()

print('扩展窗口统计:')
print(prices_ts[['收盘价', '累积均值', '累积标准差', '累积最大值']].tail(10))

# 金融应用:累计波动率
prices_ts['累计波动率'] = prices_ts['收盘价'].pct_change().expanding().std() * np.sqrt(252)
print('\n年化累计波动率:')
print(prices_ts['累计波动率'].tail(10))

rolling vs expanding:

特性 rolling expanding
窗口大小 固定 不断增长
计算范围 \([t-w+1, t]\) \([1, t]\)
权重 等权重 等权重
应用 移动平均、短期波动 累计收益、长期风险

54.8 分组统计 groupby

54.8.1 数学原理(分组聚合)

分组聚合(GroupBy Aggregation):

\[ \text{GroupBy}(K, f, X) = \{(k, f(\{x | key(x) = k\})) | k \in K\} \]

其中:

  • \(K\): 键集合
  • \(f\): 聚合函数
  • \(X\): 数据集

54.8.2 基础分组操作

列表 54.9: groupby基础操作
# 创建行业数据
industry_data = pd.DataFrame({
    '股票代码': ['600519.SH', '000858.SZ', '600036.SH', '601318.SH', '000001.SZ', '601398.SH'],
    '股票名称': ['贵州茅台', '五粮液', '招商银行', '中国平安', '平安银行', '工商银行'],
    '行业': ['白酒', '白酒', '银行', '保险', '银行', '银行'],
    '市盈率': [45.2, 35.8, 8.5, 12.3, 9.2, 6.8],
    '市净率': [12.3, 8.9, 0.9, 1.5, 1.1, 0.7],
    'ROE': [0.28, 0.22, 0.15, 0.18, 0.13, 0.14],
    '股息率': [0.012, 0.018, 0.035, 0.028, 0.040, 0.045]
})

print('行业数据:')
print(industry_data)

# 按行业分组计算均值
industry_mean = industry_data.groupby('行业').mean(numeric_only=True)
print('\n行业均值:')
print(industry_mean)

# 按行业分组计算多个统计量
industry_stats = industry_data.groupby('行业').agg({
    '市盈率': ['mean', 'median', 'std'],
    '市净率': ['mean', 'min', 'max'],
    'ROE': 'mean',
    '股息率': 'mean'
})
print('\n行业详细统计:')
print(industry_stats.round(4))

# 分组计数
industry_count = industry_data.groupby('行业').size()
print('\n行业股票数量:')
print(industry_count)

54.8.3 多级分组

列表 54.10: 多级分组统计
# 创建多级数据
multi_level_data = pd.DataFrame({
    '行业': ['白酒', '白酒', '白酒', '银行', '银行', '银行', '保险', '保险'],
    '市值等级': ['大盘', '中盘', '大盘', '大盘', '小盘', '大盘', '大盘', '中盘'],
    '市盈率': [45.2, 35.8, 38.5, 8.5, 15.2, 6.8, 12.3, 18.5],
    '收益率': [0.05, 0.03, 0.06, 0.02, 0.04, 0.01, 0.03, 0.02]
})

df_multi = pd.DataFrame(multi_level_data)

print('多级分组统计:')
# 多级分组
multi_stats = df_multi.groupby(['行业', '市值等级']).agg({
    '市盈率': 'mean',
    '收益率': 'mean'
})
print(multi_stats)

# 按行业统计,并排名
df_multi['行业内PE排名'] = df_multi.groupby('行业')['市盈率'].rank()
print('\n行业内PE排名:')
print(df_multi)

54.8.4 自定义聚合函数

列表 54.11: 自定义聚合函数
# 定义自定义函数:计算市净率与市盈率的比率均值
def price_to_book_ratio(group):
    '''计算市净率/市盈率比率'''
    return (group['市净率'] / group['市盈率']).mean()  # 按组计算PB/PE均值

# 定义自定义函数:基于ROE计算类夏普比率
def roe_adjusted_ratio(group, benchmark_roe=0.10):
    '''计算ROE超额收益与波动的比率'''
    excess_roe = group['ROE'].mean() - benchmark_roe  # 超额ROE
    return excess_roe / group['ROE'].std() if group['ROE'].std() > 0 else 0  # 类夏普比率

# 应用自定义函数
print('自定义聚合函数:')  # 输出标题
print('\n市净率/市盈率比率:')  # 输出PB/PE比率标题
print(industry_data.groupby('行业').apply(price_to_book_ratio))  # 按行业计算PB/PE比率

# 使用agg配合lambda计算市盈率范围
print('\n市盈率范围(最大-最小):')  # 输出PE范围标题
print(industry_data.groupby('行业')['市盈率'].agg(lambda x: x.max() - x.min()))  # 计算各行业PE极差

54.9 相关性分析

54.9.1 协方差与相关系数

协方差(Covariance):

\[ \text{Cov}(X, Y) = E[(X - \mu_X)(Y - \mu_Y)] = \frac{1}{n-1}\sum_{i=1}^n (x_i - \bar{x})(y_i - \bar{y}) \]

相关系数(Correlation Coefficient):

\[ \rho_{X,Y} = \frac{\text{Cov}(X, Y)}{\sigma_X \sigma_Y} = \frac{\sum(x_i - \bar{x})(y_i - \bar{y})}{\sqrt{\sum(x_i - \bar{x})^2}\sqrt{\sum(y_i - \bar{y})^2}} \]

54.9.2 相关性计算

列表 54.12: 相关性矩阵计算
# 使用之前的收益率数据
df_returns_sample = df_returns

# 计算协方差矩阵
cov_matrix = df_returns_sample.cov()
print('协方差矩阵:')
print(cov_matrix)

# 计算相关系数矩阵
corr_matrix = df_returns_sample.corr()
print('\n相关系数矩阵:')
print(corr_matrix)

# 找出相关性最高的股票对
corr_unstack = corr_matrix.unstack()
corr_unstack = corr_unstack[corr_unstack != 1]  # 排除自相关
top_corr = corr_unstack.abs().sort_values(ascending=False).head(5)
print('\n相关性最高的股票对:')
print(top_corr)

相关性的金融意义:

相关系数 关系 金融含义 投资策略
\(\rho \approx 1\) 强正相关 同涨同跌 分散化效果差
\(\rho \approx 0\) 无相关 独立变动 有效分散化
\(\rho \approx -1\) 强负相关 此消彼长 对冲工具

现代投资组合理论(MPT):

\[ \sigma_p^2 = \sum_{i=1}^n \sum_{j=1}^n w_i w_j \sigma_i \sigma_j \rho_{ij} \]

\(\rho_{ij} < 1\) 时,组合方差小于各资产方差的加权平均,实现风险分散

54.10 本章小结

要点:

  • 描述性统计三层次:集中趋势(均值、中位数、众数)、离散程度(方差、标准差、极差)、分布形状(偏度、峰度);describe() 一次给出多数指标
  • 口径差异:Pandas 的 var()/std() 默认样本口径(ddof=1),NumPy 默认总体口径(ddof=0),跨库对比时必须统一
  • 累积统计量:cumsum() 累加、cumprod() 累乘;pct_change() 转为收益率,(1+r).cumprod() 得到净值曲线
  • 窗口统计:rolling(w) 固定窗口(如 20 日均线、滚动波动率),expanding() 从起点到当前的扩展窗口;两者输出前 w-1 个位置为 NaN 属正常
  • groupby() 按”拆分-应用-合并”完成分组统计,agg 支持多函数与自定义函数;corr()/cov() 给出多变量的相关与协方差矩阵,是组合构建的输入

易错点:

  • 偏度看尾、峰度看峰:正偏不代表”涨得多”,而是右尾更长;高峰度意味着极端值比正态假设下更常见
  • rolling(w).mean() 的前 w-1 个值是 NaN,直接对其求均值或画图会看到起始段缺口,不是数据错误
  • 日频波动率年化要乘 \(\sqrt{252}\),收益率年化通常乘 252,两者口径混用会得到荒谬结果
  • groupby().apply() 里的自定义函数若返回标量,结果是按组索引的 Series;返回结构不一致时结果形态难以预料
  • 协方差受量纲影响,比较变量间线性关系强弱应看相关系数

54.11 动手与思考

以下练习每题附参考答案(默认折叠)。请先独立完成并写下你的判断,再点开对照,最后上机验证。

  1. 输出预测:不运行代码,先写出下面代码的输出结果(注意第一个值),再上机检验你的判断。

    import pandas as pd
    
    s = pd.Series([1, 2, 3, 4])
    print(s.rolling(2).mean().tolist())
    print(s.cumsum().tolist())
    print(s.rolling(2).mean().isna().sum())

    参考答案(先写下你的预测再点开)

    解题思路rolling(2).mean() 是窗口为 2 的滚动均值——第 1 个位置前面只有 1 个观测、窗口不满,输出 NaN;之后依次是 (1+2)/2、(2+3)/2、(3+4)/2,即 [nan, 1.5, 2.5, 3.5]。cumsum() 从第 1 个值起逐项累加,[1, 3, 6, 10]。滚动序列只有开头 1 个 NaN,计数为 1(一般地,窗口 w 的前 w−1 个位置为 NaN)。

    # 验证脚本:滚动均值、累加与起始段缺失计数
    import pandas as pd  # 导入pandas库
    s = pd.Series([1, 2, 3, 4])  # 简单序列
    print(s.rolling(2).mean().tolist())  # 窗口2滚动均值,首值窗口不满为NaN
    print(s.cumsum().tolist())  # 累计和
    print(s.rolling(2).mean().isna().sum())  # 起始段NaN个数

    预期输出(本机 peter 环境实际运行结果,具体以平台运行结果为准):

    [nan, 1.5, 2.5, 3.5]
    [1, 3, 6, 10]
    1

    回扣主线:窗口统计前 w−1 个位置为 NaN 属正常,见第 章节 6 章统计运算部分与本章”要点”第 4 条。

  2. 自测回忆:不看正文,写出 describe() 输出包含的统计量名称;再说明 rolling(20).std()expanding().std() 在窗口范围上的差异,以及前 19 个值为什么是 NaN。

    参考答案(点开前请先独立完成)

    解题思路describe() 默认输出 8 个统计量:count(非缺失计数)、mean、std、min、25%、50%(中位数)、75%、max——即计数、集中趋势、离散程度与五个分位数。窗口差异:rolling(20).std() 每次只用”最近 20 个”观测,窗口固定长度、随时间滑动,反映局部(近期)波动;expanding().std() 的窗口从序列起点一直扩展到当前,用”截至当前的全部历史”,反映累积的长期波动。前 19 个值为 NaN 的原因:第 t 个位置(t 小于 19)之前不足 20 个观测,窗口不满无法计算 20 期标准差——这不是数据错误,而是滚动窗口的定义使然。

    回扣主线:滚动窗口与扩展窗口的对照见本章”窗口统计量”“expanding窗口”两节,运算基础见第 章节 6 章。

  3. 变式任务(平台任务同型改造):平台任务一把基金净值除以首日净值做归一化处理;请改用 pct_change() 先算出日收益率,再用 (1 + 收益率).cumprod() 重建净值曲线,并与直接归一化的结果对照是否一致。

    参考答案(点开前请先独立完成)

    解题思路:结论——一致(到浮点舍入精度)。理由:归一化是逐日净值除以首日净值 \(p_t/p_0\);而 \(\prod_{i=1}^{t}(1+r_i) = \prod_{i=1}^{t}(p_i/p_{i-1})\),连乘分子分母逐项相消(telescoping)后恰好等于 \(p_t/p_0\)。唯一差别是重建曲线的首行为 NaN——pct_change() 的第一个值没有前值。数据为 4 只 QDII 基金 117 个交易日的净值(OSS 直链实拉,拉取日期 2026-08-27)。

    # 变式脚本:两条净值重建路径的对照
    import pandas as pd  # 导入pandas库
    value_QDII = pd.read_excel('https://huoran.oss-cn-shenzhen.aliyuncs.com/1726648479386.xlsx')  # 读取QDII基金净值
    value_QDII['日期'] = pd.to_datetime(value_QDII['日期'], format='%Y%m%d')  # 日期列转日期类型
    value_QDII.set_index('日期', inplace=True)  # 日期设为行索引
    value_QDII = value_QDII.dropna()  # 删除缺失行
    normalized = value_QDII / value_QDII.iloc[0]  # 路径一:除以首日净值归一化
    rebuilt = (1 + value_QDII.pct_change()).cumprod()  # 路径二:日收益率累乘重建
    print(normalized.iloc[:3, 0].round(6).tolist())  # 归一化曲线前3个值
    print(rebuilt.iloc[:3, 0].round(6).tolist())  # 重建曲线前3个值(首值为NaN)
    print(float((normalized - rebuilt).abs().max().max()))  # 两曲线最大绝对差

    预期输出(本机 peter 环境实际运行结果,具体以平台运行结果为准):

    [1.0, 1.000438, 1.009528]
    [nan, 1.000438, 1.009528]
    1.7763568394002505e-15

    最大绝对差约 1.8e-15,属浮点舍入量级,两条路径逐点一致。若 OSS 直链失效,可到教学平台运行平台任务同款代码,或用任一净值/价格序列演练,代码不变。

    注意:以上为变式代码;列表 54.1 的平台任务仍须按原始代码原样输入教学平台。

    回扣主线pct_change()(1+r).cumprod() 的净值曲线关系见本章”累积统计量”一节,与第 章节 6 章”切片错位相除”呼应。

  4. 变式任务:构造一份含”行业”与”市盈率”两列的小数据框(至少 3 个行业、每个行业至少 3 条记录),用 groupby('行业')['市盈率'].agg(['mean', 'std', 'count']) 输出分组统计表,并说明 count 一列帮助你确认了什么。

    参考答案(点开前请先独立完成)

    解题思路groupby 按”拆分—应用—合并”逐组计算,agg 一次输出多个统计量。count 一列确认两件事:一是每组的有效样本量(各组都是 3 条、无缺失),二是组间可比性——均值与标准差的比较必须建立在样本量与缺失情况已知的前提下,只有 3 个观测的组其 std 本身抽样误差就不小,跨组结论要谨慎。

    # 变式脚本:三行业的市盈率分组统计
    import pandas as pd  # 导入pandas库
    pe_data = pd.DataFrame({  # 构造行业与市盈率小样本
        '行业': ['银行', '银行', '银行', '白酒', '白酒', '白酒', '医药', '医药', '医药'],  # 3个行业各3条
        '市盈率': [5.2, 5.8, 6.1, 28.0, 32.5, 30.2, 35.1, 42.0, 38.7]})  # 市盈率取值
    print(pe_data.groupby('行业')['市盈率'].agg(['mean', 'std', 'count']).round(4))  # 分组多统计量

    预期输出(本机 peter 环境实际运行结果,具体以平台运行结果为准):

             mean     std  count
    行业
    医药  38.6000  3.4511      3
    白酒  30.2333  2.2502      3
    银行   5.7000  0.4583      3

    回扣主线groupby().agg() 的拆分—应用—合并机制见本章”分组统计”一节,与第 章节 6 章按 axis 聚合的基础相衔接。

  5. 思考题:金融收益率常呈”尖峰厚尾”。若风险管理系统假设收益服从正态分布,会系统性低估还是高估极端损失的概率?请结合峰度的含义说明。

    参考答案(点开前请先独立完成)

    解题思路:本题与第 章节 33 章第 5 题相同,可先做后对照。结论:会系统性低估极端损失的概率。峰度的含义是分布尾部相对于正态分布的厚度:高峰度意味着极端值(大幅亏损与大幅盈利)出现的频率高于正态假设下的预期。若风险系统按正态分布刻画收益,尾部概率被压得过小——同样置信水平(如 99%)下算出的分位数偏小,超过阈值的实际频率远高于模型给出的 1%,极端损失被系统性低估。判断是否厚尾可依据:偏度与峰度(超额峰度显著大于 0)、Q-Q 图两端是否翘起、样本极端分位数与正态理论分位数的对比。对风险度量的影响:应放弃正态假设下的参数化口径,改用历史分位数法、历史模拟 VaR,或对尾部建模的极值理论(EVT)等更稳健的做法,并辅以压力测试。

    回扣主线:厚尾形态的诊断与”正态假设低估极端风险”见第 章节 33 章;偏度峰度的计算基础见本章”偏度与峰度”一节。