Chapter 08

数据合并与重塑

merge/join/concat 的语义差异、pivot_table 透视、melt/stack/unstack 宽长转换

数据合并的三种方式概览

pd.concat:沿轴堆叠

import pandas as pd
import numpy as np

df1 = pd.DataFrame({'A': [1,2], 'B': [3,4]})
df2 = pd.DataFrame({'A': [5,6], 'B': [7,8]})
df3 = pd.DataFrame({'A': [9], 'C': [10]})  # 有不同的列

# ── 按行堆叠(axis=0,默认)──
pd.concat([df1, df2])                    # 行索引重复
pd.concat([df1, df2], ignore_index=True) # 重置行索引

# 堆叠列不匹配的 DataFrame
pd.concat([df1, df3])                    # join='outer'(默认):缺列填 NaN
pd.concat([df1, df3], join='inner')     # 只保留共同列('A')

# 添加层级标识(方便区分来源)
combined = pd.concat(
    [df1, df2],
    keys=['2023', '2024'],  # 最外层索引标识来源
    ignore_index=False,
)
# 行索引变为 MultiIndex: ('2023', 0), ('2023', 1), ('2024', 0), ...

# ── 按列堆叠(axis=1)──
df_left  = pd.DataFrame({'name': ['Alice', 'Bob'], 'age': [25, 30]})
df_right = pd.DataFrame({'salary': [60000, 80000], 'dept': ['eng', 'mkt']})
pd.concat([df_left, df_right], axis=1)  # 按列拼接(按行索引对齐)

# 常见用法:将多个月的数据文件合并
import glob
files = glob.glob('data/2024-*.csv')
df_all = pd.concat(
    [pd.read_csv(f) for f in files],
    ignore_index=True
)
⚠️
append() 已废弃 Pandas 2.0 移除了 df.append() 方法。替换方式:用 pd.concat([df, new_row_df])。在循环中频繁追加行时,先收集到 Python 列表,最后一次性 concat,避免每次都创建新 DataFrame。

pd.merge:SQL 风格的键连接

merge 对应 SQL 的 JOIN 操作,是最灵活的表合并方式。理解四种连接类型是关键:

连接类型SQL 等价保留行为
how='inner'INNER JOIN只保留两表键值都存在的行(默认)
how='left'LEFT JOIN保留左表所有行,右表无匹配填 NaN
how='right'RIGHT JOIN保留右表所有行,左表无匹配填 NaN
how='outer'FULL OUTER JOIN保留两表所有行,无匹配填 NaN
import pandas as pd

employees = pd.DataFrame({
    'emp_id': [1, 2, 3, 4],
    'name':   ['Alice', 'Bob', 'Carol', 'Dave'],
    'dept_id':[10, 20, 10, 30],
})

departments = pd.DataFrame({
    'dept_id': [10, 20, 40],  # 注意:dept_id=30 不存在,40 是新的
    'dept_name': ['工程部', '市场部', '行政部'],
})

# inner join:只有 dept_id 在两表都存在的员工
pd.merge(employees, departments, on='dept_id')
# Dave (dept_id=30) 被丢弃,行政部 (dept_id=40) 也被丢弃

# left join:保留所有员工,找不到部门的填 NaN
pd.merge(employees, departments, on='dept_id', how='left')
# Dave 的 dept_name 为 NaN

# 左右键名不同时
pd.merge(employees, departments, left_on='dept_id', right_on='dept_id')
# 本例两表键名相同,这只是演示参数用法

# 按索引连接
pd.merge(employees, departments, left_index=True, right_index=True)

# 多键连接
pd.merge(df1, df2, on=['year', 'month'])  # 按年+月连接

# 处理重复列名(两表有相同列名但不作为连接键)
pd.merge(df1, df2, on='id', suffixes=('_left', '_right'))
# 重复的非键列会被自动加上后缀

# validate 参数:验证连接类型的正确性
pd.merge(employees, departments, on='dept_id', validate='m:1')
# 'm:1':左表多行对应右表一行(最常见的员工-部门关系)
# '1:1':一对一
# '1:m':一对多
# 如果实际数据违反了指定的关系类型,会抛出异常

merge 的 indicator 参数

# indicator 参数:添加 _merge 列,标识每行的来源
result = pd.merge(employees, departments, on='dept_id', how='outer', indicator=True)
print(result['_merge'].value_counts())
# 'both'       :两表都有
# 'left_only'  :只在左表
# 'right_only' :只在右表

# 用于"反连接":找出只在左表出现的行
left_only = result[result['_merge'] == 'left_only'].drop(columns='_merge')

df.join:基于索引的连接

# join 默认基于索引连接
df_main = df1.set_index('emp_id')
df_extra = df2.set_index('emp_id')
df_main.join(df_extra)                    # 默认 left join
df_main.join(df_extra, how='inner')      # inner join

# 同时 join 多个 DataFrame
df_main.join([df_extra1, df_extra2])

pivot_table:数据透视表

透视表是将"长格式"数据重塑为"宽格式"摘要的工具,类似 Excel 的数据透视表功能。它是 groupby + unstack 的组合简化写法。

import pandas as pd

sales = pd.DataFrame({
    'year':    [2023, 2023, 2024, 2024, 2023, 2024],
    'region': ['华北', '华南', '华北', '华南', '华东', '华东'],
    'product':['A', 'A', 'A', 'A', 'B', 'B'],
    'amount': [100, 200, 150, 250, 80, 120],
    'qty':    [10, 20, 15, 25, 8, 12],
})

# 基础透视表:每个地区每年的总销售额
pivot = pd.pivot_table(
    sales,
    values='amount',       # 汇总的值列
    index='region',        # 行维度
    columns='year',        # 列维度
    aggfunc='sum',         # 聚合方式
    fill_value=0,           # 缺失组合填0
    margins=True,           # 添加行列总计
    margins_name='合计'    # 总计行/列的标签
)

# 多值聚合
pivot2 = pd.pivot_table(
    sales,
    values=['amount', 'qty'],  # 多个值列
    index=['region', 'product'],  # 多行维度
    columns='year',
    aggfunc={'amount': 'sum', 'qty': 'mean'}  # 不同列不同聚合
)

# pivot(非聚合版):要求行列组合唯一
df_pivot = sales.pivot(index='region', columns='year', values='amount')
# 如果 (region, year) 有重复,会报错(需用 pivot_table)

melt:宽格式转长格式

数据整洁原则(Tidy Data)要求每个变量占一列,每次观测占一行。当数据以"宽格式"存在时(多个时间点的值分散在不同列),需要用 melt() 转换为"长格式"。

import pandas as pd

# 宽格式数据:每个月的销售额是一列
wide_df = pd.DataFrame({
    'region':  ['华北', '华南', '华东'],
    '2024-01': [100, 200, 150],
    '2024-02': [120, 210, 160],
    '2024-03': [130, 190, 170],
})

# melt:将多列转为一列(长格式)
long_df = pd.melt(
    wide_df,
    id_vars=['region'],          # 保持不变的列(标识符)
    value_vars=['2024-01', '2024-02', '2024-03'],  # 要熔化的列
    var_name='month',            # 熔化后"列名"存储在哪个新列
    value_name='sales',          # 熔化后"值"存储在哪个新列
)
# 结果:region, month, sales 三列,原来3列×3行变成了9行×3列

# 自动熔化所有非 id_vars 列
long_df2 = wide_df.melt(id_vars=['region'], var_name='month', value_name='sales')

stack 和 unstack:MultiIndex 与宽长转换

import pandas as pd

# 基础 stack/unstack 示例
df = pd.DataFrame({
    'math':    [85, 90, 78],
    'english': [72, 88, 95],
}, index=['Alice', 'Bob', 'Carol'])

stacked = df.stack()       # 列变为行,返回 MultiIndex Series
# Alice  math      85
#        english   72
# Bob    math      90
#        english   88

df_back = stacked.unstack()  # 还原为原始 DataFrame

# 指定 unstack 的层级
stacked.unstack(level=0)    # 将第0层(姓名)展开为列
stacked.unstack(level=1)    # 将第1层(科目)展开为列(默认)
stacked.unstack(level='name')  # 按层级名称指定

# groupby + unstack 的组合(等价于 pivot_table)
result = (
    sales
    .groupby(['region', 'year'])['amount']
    .sum()
    .unstack(level='year')     # 将 year 展开为列
    .fillna(0)
)

explode:展开列表值

当某列的值是列表或其他可迭代对象时,explode() 可以将每个列表元素变成独立的行。

import pandas as pd

df = pd.DataFrame({
    'product': ['A', 'B', 'C'],
    'tags':    [['电子', '数码'], ['家居'], ['电子', '家居', '数码']],
    'price':   [999, 199, 1299],
})

exploded = df.explode('tags')
# 每个 tag 占一行,product 和 price 列重复
# product   tags   price
# A         电子    999
# A         数码    999
# B         家居    199
# C         电子    1299
# C         家居    1299
# C         数码    1299

# 应用:统计每个 tag 出现的次数
tag_counts = (
    df.explode('tags')['tags']
    .value_counts()
)

综合案例:销售数据的多维分析

import pandas as pd
import numpy as np

# 生成模拟销售数据
np.random.seed(42)
n = 1000
orders = pd.DataFrame({
    'order_id':  range(1, n+1),
    'customer_id': np.random.randint(1, 201, n),
    'product':   np.random.choice(['laptop', 'phone', 'tablet'], n),
    'region':    np.random.choice(['华北', '华南', '华东', '华西'], n),
    'amount':    np.random.randint(500, 15000, n),
    'date':      pd.date_range('2024-01-01', periods=n, freq='D'),
})

customers = pd.DataFrame({
    'customer_id': range(1, 201),
    'tier': np.random.choice(['gold', 'silver', 'bronze'], 200),
    'city': np.random.choice(['北京', '上海', '广州', '成都'], 200),
})

# Step 1: 连接订单与客户数据
df = pd.merge(orders, customers, on='customer_id', how='left')

# Step 2: 添加时间维度
df['month'] = df['date'].dt.strftime('%Y-%m')
df['quarter'] = df['date'].dt.quarter

# Step 3: 透视表——产品 × 季度的销售额
product_quarter = pd.pivot_table(
    df,
    values='amount',
    index='product',
    columns='quarter',
    aggfunc='sum',
    fill_value=0,
    margins=True,
    margins_name='总计',
)
print(product_quarter)

# Step 4: 地区 × 客户等级的客单价
tier_region = pd.pivot_table(
    df,
    values='amount',
    index='region',
    columns='tier',
    aggfunc='mean',
)
print(tier_region.round(0))

小结

数据合并与重塑是将多个数据源整合为分析就绪格式的关键技能: