目录211

Pandas


概述与安装

安装与验证

方式 命令 适用场景
pip(默认) pip install -U pandas 大多数环境;-U 兼作升级
显式解释器 python -m pip install pandas pip 不在 PATH / 机器上有多个 Python
指定版本 pip install "pandas==3.0.0"、pip install "pandas>=3.0" 复现别人的环境
用户目录 pip install --user pandas 无管理员权限
纯二进制 pip install pandas --only-binary :all: 报「需要 C 编译器」时
conda conda install -c conda-forge pandas Anaconda / Miniconda(多数发行版已自带)
临时镜像 pip install -i <镜像URL> pandas 官方源下载慢、超时

常用镜像:清华 https://pypi.tuna.tsinghua.edu.cn/simple、阿里 https://mirrors.aliyun.com/pypi/simple/、中科大 https://pypi.mirrors.ustc.edu.cn/simple/、豆瓣 https://pypi.douban.com/simple/。永久生效:pip config set global.index-url <镜像URL>。

Bash1 行
python -c "import pandas as pd, numpy as np; print(pd.__version__, np.__version__)"   # 验证

⚠️ Jupyter 里 import pandas 失败 ≠ 没装,通常是 kernel 与命令行不是同一个 Python。在 cell 内执行 !pip install pandas,或先确认 sys.executable 再用 python -m pip。

⚠️ 本笔记面向 Pandas 3.x:缺失值填充写 ffill() / bfill()(不再 fillna(method=...));赋值一律走 df.loc[...] = ...;字符串列默认 str dtype 而非 object;copy-on-write 默认开启,不推荐 inplace=True。

定位与生态位

Pandas 是构建在 NumPy 之上的带标签表格处理层:NumPy 负责同质数值块的高效计算,Pandas 负责列名与索引、异构列、缺失值、分组聚合、时间索引,并统一了 CSV / Excel / SQL / JSON 的读写入口。

场景 NumPy Pandas
纯数值计算、矩阵运算、科学计算 ✓
异构列的表格数据 ✓
缺失值、重复值、脏数据清洗 ✓
分组聚合、透视表 ✓
时间序列(重采样、滚动窗口) ✓
文件 / 数据库读写 ✓

周边生态:Matplotlib(df.plot() 直出图)、scikit-learn(吃 df.to_numpy())、SciPy / statsmodels(统计检验)、PyArrow(3.x 字符串 dtype 的 Arrow 后端与更快的 IO)、Jupyter(交互式探索)。

Series 与 DataFrame 心智模型

Series DataFrame
维度 一维 二维
构成 一组值 + 一个 index 多列共享同一 index,外加一个 columns
类比 表格里的一列 / 带标签的数组 / 增强版 dict 整张表 / dict of Series
dtype 整个 Series 一个 dtype 每列各自 dtype(可异构)
创建 pd.Series([...], index=..., name=...) pd.DataFrame({...})、pd.DataFrame(arr, columns=...)
常用属性 .index .dtype .name .shape .index .columns .shape .dtypes .empty
Python20 行
import pandas as pd
import numpy as np

s = pd.Series([10, 20, 30, 40, 50], index=list('abcde'), name='销量')
s.index                            # 输出: Index(['a', 'b', 'c', 'd', 'e'], dtype='str')
s.dtype, s.name, s.shape           # 输出: (dtype('int64'), '销量', (5,))
s['a'], s.iloc[0]                  # 输出: 10  10(按标签取值 / 按位置取值)
s * 2                              # 输出: a 20  b 40  c 60  d 80  e 100(运算按 index 对齐)
s.mean(), s.sum(), s.max()         # 输出: 30.0  150  50

df = pd.DataFrame({'姓名': ['张三', '李四', '王五'],
                   '年龄': [25, 30, 28],
                   '城市': ['北京', '上海', '深圳']})
df.shape, list(df.columns), list(df.index)   # 输出: ((3, 3), ['姓名', '年龄', '城市'], [0, 1, 2])
df.dtypes                          # 输出: 姓名 str / 年龄 int64 / 城市 str(3.x 字符串是 str,不是 object)
df['年龄']                         # 输出: 单列 → Series(dtype int64)
df[['姓名', '年龄']]                # 输出: 多列 → DataFrame(注意双层方括号)
df.loc[0]                          # 输出: 第一行 → Series,各列被向上提升为 object
df.loc[0, '年龄']                   # 输出: 25(标量)
df.loc[df['年龄'] > 26]             # 输出: 李四 30 / 王五 28(布尔掩码选行)
需求 写法 说明
取一列 df['年龄'] 得到 Series
取多列 df[['姓名', '年龄']] 双层方括号,得到 DataFrame
按标签取行 df.loc[0] / df.loc[0:2] 含右端点
按位置取行 df.iloc[0] / df.iloc[0:2] 不含右端点(同 Python 切片)
取标量 df.loc[0, '年龄'] / df.at[0, '年龄'] at 更快
条件选行 df[df['年龄'] > 26] 布尔掩码;多条件用 & 并各自加括号
同时选行列 df.loc[df['年龄'] > 26, ['姓名', '年龄']] 行条件 + 列名
按位置取单元格 df.iloc[0, 1] / df.iat[0, 1] iat 更快

⚠️ df[0] 是按列名取列,不是取第 0 行;取行用 .loc[标签] 或 .iloc[位置]。多条件筛选必须写成 (df['a'] > 1) & (df['b'] < 2)——用 and / or 会抛 ValueError(Series 的真值不明确)。混合类型的行切片(如 df.loc[0])会把各列 dtype 向上提升成 object,转 ndarray 前先 select_dtypes。

最小可运行骨架

读进来 → 加列 → 统计 → 排序 → 落盘,这一条链路覆盖了日常八成的需求。

Python11 行
df = pd.DataFrame({'产品': ['手机', '电脑', '平板'],
                   '销量': [120, 80, 45],
                   '单价': [2999, 5999, 1999]})
df['销售额'] = df['销量'] * df['单价']   # 新增列:逐元素运算,不需要写循环
df['销售额'].sum()                       # 输出: 929755
df.head(2)                               # 输出: 前 2 行(tail(2) 看末尾)
df.info()                                # 输出: 行数、列数、每列 dtype 与非空计数
df.describe()                            # 输出: 数值列的 count/mean/std/min/四分位/max
df.sort_values('销售额', ascending=False) # 输出: 降序新对象,原 df 不变
df = df.reset_index(drop=True)           # 输出: 索引重排为 0..n-1,旧索引丢弃
df.to_csv('out.csv', index=False, encoding='utf-8-sig')   # 落盘,Excel 打开不乱码

⚠️ 3.x 里 sort_values / drop / rename / fillna 都返回新对象:写 df.sort_values(...) 不回赋值等于白做。别用 inplace=True——copy-on-write 下它常常既不提速也不生效。

读写与保存速查 (完整用法见后续)

格式 读取 保存
CSV pd.read_csv('a.csv', encoding='utf-8-sig') df.to_csv('a.csv', index=False, encoding='utf-8-sig')
Excel pd.read_excel('a.xlsx', sheet_name=0)(需 openpyxl) df.to_excel('a.xlsx', index=False)
JSON pd.read_json('a.json') df.to_json('a.json', orient='records', force_ascii=False)
SQL pd.read_sql('SELECT * FROM t', conn)(需 SQLAlchemy) df.to_sql('t', conn, if_exists='replace', index=False)
剪贴板 pd.read_clipboard() df.to_clipboard()
Parquet pd.read_parquet('a.parquet')(需 pyarrow) df.to_parquet('a.parquet')
Python6 行
df = pd.read_csv('data.csv', encoding='utf-8-sig')              # 国内 CSV 常见 GBK,读不了换 encoding='gbk'
pd.read_csv('data.csv', usecols=['姓名', '年龄'], nrows=100)     # 只读部分列 / 部分行,省内存
pd.read_csv('data.csv', parse_dates=['日期'])                    # 直接把列解析成 datetime64
pd.read_excel('data.xlsx', sheet_name='Sheet1')                  # sheet_name=None → {表名: df} 字典
df.to_csv('out.csv', index=False, encoding='utf-8-sig')          # 不写行号 + Excel 不乱码
df.to_json('out.json', orient='records', force_ascii=False)      # records = 每行一个对象,中文不转义

⚠️ to_csv 默认会额外写出索引列,几乎总要加 index=False;中文写 CSV 用 utf-8-sig,否则 Excel 打开是乱码;read_excel 报缺 openpyxl 就 pip install openpyxl。

数据清洗速查 (完整用法见)

需求 写法
逐列缺失计数 df.isna().sum()
每列是否有缺失 df.isna().any()
删含缺失的行 / 列 df.dropna() / df.dropna(axis=1)
只看某几列判缺失 df.dropna(subset=['年龄'])
定值填充 df.fillna(0)
中位数 / 均值填充 df.fillna(df[['年龄']].median())
前向 / 后向填充 df.ffill() / df.bfill()
分组内填充 df.groupby('城市')['工资'].ffill()
删重复行 df.drop_duplicates()
按列判重、保留末次 df.drop_duplicates(subset=['姓名'], keep='last')
占位值转 NaN df.replace('', np.nan)、df.replace(-999, np.nan)
Python11 行
df = pd.DataFrame({'姓名': ['张三', '李四', '张三', None],
                   '年龄': [25, np.nan, 25, 40],
                   '城市': ['北京', '', '北京', '深圳']})

df.isna().sum()                                        # 输出: 姓名 1 / 年龄 1 / 城市 0('' 不算缺失)
df.isna().any()                                        # 输出: 每列是否含缺失(布尔 Series)
df.dropna(subset=['年龄'])                              # 输出: 只丢「年龄」为空的行
df['年龄'] = df['年龄'].fillna(df['年龄'].median())      # 用中位数补单列
df['城市'] = df['城市'].replace('', np.nan).ffill()      # 空串先变 NaN,再前向填充
df = df.drop_duplicates(subset=['姓名'], keep='last')    # 同名保留最后一次出现的行
df = df.reset_index(drop=True)                         # 删行后索引会断,重排成 0..n-1

⚠️ isna() 不认空字符串 ''、也不认 -999 这类占位数字,读进来先 replace 成 NaN;df.fillna(df.mean()) 会对整表求均值、把字符串列一起卷进去报错——显式指定要填的列。

版本与依赖检查

Python3 行
pd.__version__                                       # 输出: '3.x.x'
tuple(map(int, pd.__version__.split('.')[:2])) >= (3, 0)   # 输出: True(代码里做版本门禁)
pd.show_versions()                                   # 输出: INSTALLED VERSIONS 段——pandas/numpy/python 及各可选依赖版本
用途 需要装的包
Excel 读 / 写 openpyxl(xlsx)、xlrd(旧 xls)
字符串 dtype / 更快 IO pyarrow
画图 matplotlib
统计与检验 scipy、statsmodels
数据库读写 SQLAlchemy + 对应驱动

⚠️ ImportError: Missing optional dependency 不是安装坏了,只是缺可选包,按上表补装即可;pd.show_versions() 的 INSTALLED VERSIONS 段能一眼看出哪些是 None。

显示设置 pd.set_option / pd.options.display.*

目的 option 默认 说明
最多显示行数 display.max_rows 60 None = 不截断
最多显示列数 display.max_columns 20 超出用 ... 折叠
单元格最大宽度 display.max_colwidth 50 长文本截断
总字符宽度 display.width 80 None = 不自动换行
浮点小数位 display.precision 6 只影响显示
浮点格式 display.float_format None 如 '{:.2f}'.format
info() 摘要阈值 display.max_info_rows 1690785 超过只给列摘要
Python10 行
pd.set_option('display.max_columns', 50)   # 宽表不再被折叠
pd.set_option('display.width', 120)        # 单行可容纳更多字符
pd.set_option('display.precision', 2)      # 浮点显示 2 位
pd.options.display.max_colwidth = 60       # 等价的属性式写法
pd.get_option('display.precision')         # 输出: 2(读回当前值)
pd.reset_option('display.precision')       # 恢复默认
pd.describe_option('display.max_rows')     # 输出: 该项的说明与默认值

with pd.option_context('display.max_rows', None, 'display.precision', 4):
    df                                     # 只在 with 内临时生效,退出自动还原

⚠️ 这些全是显示层设置,不改数据本身,to_csv() / to_numpy() 拿不到“四舍五入后”的值;pd.set_option 带全局副作用,写进函数或库代码时改用 pd.option_context。

与 NumPy 的互转

写法 结果 说明
df['col'].to_numpy() 1D ndarray 单列,dtype 保留
df.to_numpy() 2D ndarray 异构列统一成 object
df.to_numpy(dtype=..., na_value=...) 2D ndarray 可显式统一 dtype、替换 NaN
np.array(df) 2D ndarray 与 to_numpy() 走同一条 __array__ 路径,但没有 na_value
df.values 2D ndarray 旧写法,3.x 不推荐
pd.DataFrame(arr, columns=[...]) DataFrame ndarray → 表,索引自动生成
pd.Series(arr, index=...) Series 1D → 带标签列
Python11 行
arr2 = np.arange(6).reshape(2, 3)
df2 = pd.DataFrame(arr2, columns=list('ABC'))  # ndarray → DataFrame,索引 0..1
df2.to_numpy()                                 # 输出: array([[0, 1, 2], [3, 4, 5]])
np.array(df2)                                  # 输出: 同上(等价路径)
df2['A'].to_numpy()                            # 输出: array([0, 3])(1D,索引被丢弃)

mix = pd.DataFrame({'年龄': [25, 30, 28], '城市': ['北京', '上海', '深圳']})
mix['年龄'].to_numpy()                         # 输出: array([25, 30, 28])(dtype 保留)
mix.to_numpy().dtype                           # 输出: dtype('O')(异构列被统一成 object)
mix.to_numpy(na_value=0)                       # np.array(mix) 做不到:还能把 NaN 换成 0
mix.select_dtypes('number').to_numpy(dtype='float64')   # 输出: 只取数值列的纯浮点数组

⚠️ to_numpy() 会丢掉 index / columns;异构表(数值 + 字符串)转出来是 object 数组,直接算会报错或极慢。喂给 scikit-learn / SciPy 前先 select_dtypes('number') 并显式 dtype='float64'。

环境自检与显示设置一次性配好

装完 Pandas 先跑一次自检:确认版本号、看清字符串列的 dtype 变化,并把常用显示选项调好,免得每次 df 都被 ... 折叠。之后每次开新会话,把这段当成模板粘贴即可。

Python19 行
import pandas as pd
import numpy as np

pd.set_option('display.max_columns', 50)
pd.set_option('display.width', 120)
pd.set_option('display.precision', 2)

pd.__version__                          # 输出: '3.x.x'
pd.get_option('display.max_columns')    # 输出: 50
pd.get_option('display.precision')      # 输出: 2

wide = pd.DataFrame(np.arange(120).reshape(3, 40))   # 40 列的宽表
wide.shape                              # 输出: (3, 40)
wide.iloc[:, :6]                        # 输出: 前 6 列(40 列全开时仍会折叠,按需再调 max_columns)

df = pd.DataFrame({'姓名': ['张三', '李四', '王五'], '年龄': [25, 30, 28]})
df.dtypes                               # 输出: 姓名 str;年龄 int64(3.x 字符串是 str,不是 object)
df['姓名'].dtype == 'str'                # 输出: True
pd.reset_option('display.precision')    # 还原单项设置

要点:set_option 只改显示、不改数据;dtype 检查是迁移到 3.x 时的第一道关卡——字符串列从 object 变成 str,依赖 == 'object' 的旧判断会失效。

订单明细清洗 → 聚合 → 导出

一份带缺失值的订单明细:数值列用中位数补、字符串列用前向填充补、补不上的标“未知”,再算销售额、分等级、按产品汇总,最后取数值列导出 ndarray 与 CSV。这是最典型的“脏数据 → 可建模数组”流程。

Python22 行
import pandas as pd
import numpy as np

df = pd.DataFrame({
    '日期': pd.to_datetime(['2024-03-01', '2024-03-01', '2024-03-02', '2024-03-02', '2024-03-03']),
    '产品': ['手机', '电脑', '手机', '平板', '电脑'],
    '销量': [5, 3, np.nan, 4, 6],
    '单价': [3000, 5000, 3000, 2000, 5000],
    '城市': [None, '上海', '北京', '深圳', '上海'],
})

df.isna().sum()                              # 输出: 销量 1;城市 1
df['销量'] = df['销量'].fillna(df['销量'].median())   # 输出: 中位数 4.5 补进第 3 行
df['城市'] = df['城市'].ffill()               # 输出: 首行是 NaN,前向填不到 → 仍是 NaN
df.loc[df['城市'].isna(), '城市'] = '未知'     # 安全赋值:先算掩码,再 .loc 定位写入
df['销售额'] = df['销量'] * df['单价']         # 输出: 15000 15000 13500 8000 30000
df.loc[df['销售额'] > 15000, '等级'] = 'A'     # 条件赋值,未命中的行留 NaN
df['等级'] = df['等级'].fillna('B')           # 输出: 补成 B B B B A
df.groupby('产品')['销售额'].sum()            # 输出: 手机 28500 / 电脑 45000 / 平板 8000
df.loc[df['销售额'].idxmax(), ['日期', '产品', '销售额']]   # 输出: 2024-03-03 电脑 30000
df.select_dtypes('number').to_numpy()        # 输出: 数值列的纯 ndarray,无日期/无字符串
df.to_csv('orders.csv', index=False, encoding='utf-8-sig')   # 落盘,Excel 打开不乱码

要点:3.x 的填充一律写 ffill() / bfill();新增或修改列一律走 df.loc[mask, col] = ...,避免链式赋值触发 SettingWithCopyWarning;to_numpy() 前用 select_dtypes 明确列集,防止整表被 object 化。

Series

创建 Series

Series = 一维 + 带标签 + 同质 dtype 的数组,由 values(数据)、index(标签)、dtype 三部分组成,另有可选 name。

Python8 行
import pandas as pd
import numpy as np

s = pd.Series([85, 92, 78, 95, 88],
              index=['张三', '李四', '王五', '赵六', '孙七'],
              name='成绩')
s.index.name = '学生'
s                 # 输出: 张三 85 / 李四 92 / 王五 78 / 赵六 95 / 孙七 88;Name: 成绩, dtype: int64
来源 写法 结果 / 说明
list pd.Series([1, 2, 3]) 索引自动为 0..n-1,dtype 自动推断
list + index pd.Series([1, 2, 3], index=['a','b','c']) 自定义标签,长度必须与数据一致
dict pd.Series({'a': 1, 'b': 2}) 键 → 索引,值 → 数据
dict + index pd.Series(d, index=['b','a','z']) 重排 / 筛选;字典里没有的键 → NaN,dtype 升为 float64
ndarray pd.Series(np.arange(3), index=['a','b','c']) 从 ndarray 转;copy=True 可断开与原数组的关联
标量 pd.Series(0, index=['a','b','c']) 广播填充,必须给 index
指定 dtype pd.Series([1, 2], dtype='float64') 'Int64' 可空整型、'category' 类别、'str' 字符串
命名 pd.Series([1, 2], name='值') name 进 DataFrame 后成为列名
Python9 行
pd.Series([10, 20, 30])                                   # 输出: 0→10 1→20 2→30,dtype: int64
pd.Series({'a': 10, 'b': 20})                             # 输出: a→10 b→20
pd.Series({'a': 10, 'b': 20}, index=['b', 'a', 'z'])      # 输出: b→20.0 a→10.0 z→NaN(z 不存在 → 缺失,dtype 变 float64)
pd.Series(np.arange(3), index=['a', 'b', 'c'])            # 输出: a→0 b→1 c→2
pd.Series(9.9, index=['产品A', '产品B'])                   # 输出: 两行都是 9.9(标量广播)
pd.Series([1, 2, 3], dtype='float64')                     # 输出: dtype: float64
pd.Series([1, 2, None], dtype='Int64')                    # 输出: 1 2 <NA>(可空整型,缺失用 pd.NA)
pd.Series(['A', 'B']).dtype                               # 输出: str(3.x 默认字符串 dtype,不再是 object)
pd.Series([1, 2, 'x']).dtype                              # 输出: object(混合类型退回 object,应尽量避免)

⚠️ index 长度与数据不一致直接 ValueError;标签允许重复但后患无穷(见 2.9)。索引元素必须是不可变类型(str / int / tuple / Timestamp)。

索引与标签

Python4 行
s.index              # 输出: Index(['张三','李四','王五','赵六','孙七'], dtype='str', name='学生')
s.values             # 输出: array([85, 92, 78, 95, 88])
s.to_numpy()         # 输出: array([85, 92, 78, 95, 88])(含扩展 dtype 时比 .values 可靠)
len(s), '张三' in s   # 输出: 5  True(in 判的是**标签**,不是值)
意图 写法 说明
按标签取单个 s['张三'] / s.loc['张三'] 推荐 .loc,意图显式
按位置取单个 s.iloc[0] 与 Python 下标一致,支持负数
标签切片 s.loc['李四':'赵六'] 两端都包含
位置切片 s.iloc[1:4] 左闭右开
多个标签 s[['张三','王五']] 双层方括号,外层索引、内层列表
多个位置 s.iloc[[0, 2]] 同上,按位置
布尔筛选 s[s >= 90] 见 2.6
标签赋值 s.loc['张三'] = 90 标签存在则改,不存在则新增一行
Python6 行
s.loc['李四':'赵六']       # 输出: 李四 92 / 王五 78 / 赵六 95(含赵六)
s.iloc[1:4]               # 输出: 李四 92 / 王五 78 / 赵六 95(不含下标 4)
s.iloc[::-1]              # 输出: 孙七 88 / 赵六 95 / 王五 78 / 李四 92 / 张三 85(倒序)
s2 = s.copy()
s2.loc['周八'] = 80       # 标签不存在 → 追加一行,不报错(打错字会悄悄多一行)
len(s2)                   # 输出: 6

⚠️ 整数索引的坑:si = pd.Series([10, 20, 30], index=[10, 11, 12]),此时 si[0] 会 KeyError——3.x 中整数键一律按标签解释,不再静默退化为位置。si.iloc[0] → 10,si.loc[10] → 10。永远用 .loc / .iloc 区分语义。

⚠️ 重复索引:dup = pd.Series([1, 2, 3], index=['a','a','b']),dup['a'] 返回的是两行的 Series 而不是标量,dup.loc['a'] = 9 会把两行一起改掉,做运算还可能触发笛卡尔积。先查 dup.index.is_unique,再去重(dup.groupby(level=0).sum())或 dup.reset_index() 后处理。

属性速查

属性 含义 示例值(基于 2.1 的 s)
dtype 元素类型 int64(数值)/ str(文本)/ category
shape 形状,恒为 (n,) (5,)
size 元素总数(= len(s)) 5
ndim 维数,恒为 1 1
index 索引对象 Index([...], dtype='str', name='学生')
values / to_numpy() 底层数组 array([85, 92, 78, 95, 88])
name Series 名(进 DataFrame 变列名) '成绩'
index.name 索引名 '学生'
T 转置,一维下返回自身 与 s 相同
is_unique 值是否无重复 True
hasnans 是否含缺失值 False
empty 长度是否为 0 False
Python4 行
s.dtype, s.shape, s.size, s.ndim, s.empty          # 输出: (int64, (5,), 5, 1, False)
s.name, s.index.name, s.is_unique, s.hasnans       # 输出: ('成绩', '学生', True, False)
s.index.is_unique, s.index.has_duplicates          # 输出: True  False(查的是索引而非值)
s.T.equals(s)                                      # 输出: True

常用方法

方法 作用 常用参数
head(n) / tail(n) 前 / 后 n 个,默认 5 —
sample(n=, random_state=) 随机抽样 frac= 按比例
isin([...]) 是否属于给定集合 → 布尔 Series 取反用 ~
unique() / nunique() 唯一值数组 / 唯一值个数 nunique(dropna=False)
value_counts() 取值计数(默认降序) normalize=True 出占比
sort_values() 按值排序 ascending=False
sort_index() 按索引排序 ascending=False
rank() 排名 method='min'/'dense'/'first'、ascending=False
describe() count / mean / std / 四分位 / min / max percentiles=[.1,.9]
astype(dtype) 类型转换 'float64' / 'Int64' / 'category'
copy() 深拷贝 —
drop(labels) 按标签删除,返回新对象 s.drop(index=['a'])
rename(...) 改索引标签 / 改 Series 名 rename(index={'a':'A'})、rename('新名')
rename_axis('x') 给索引起名 None 可取消
reset_index() 索引变列 → DataFrame drop=True 则返回 Series
set_axis(labels) 整体替换索引 长度必须相等
Python24 行
g = pd.Series(['A', 'B', 'A', 'C', 'B', 'A'])

g.unique()                          # 输出: ['A' 'B' 'C']
g.nunique()                         # 输出: 3
g.value_counts()                    # 输出: A 3 / B 2 / C 1
g.value_counts(normalize=True)      # 输出: A 0.5 / B 0.333333 / C 0.166667
g.isin(['A', 'C'])                  # 输出: True False True True False True
g[~g.isin(['A'])]                   # 输出: B / C / B
g.sample(2, random_state=42)        # 输出: 随机 2 个(random_state 固定即可复现)

s.sort_values(ascending=False)      # 输出: 赵六 95 / 李四 92 / 孙七 88 / 张三 85 / 王五 78
s.sort_index()                      # 输出: 按姓名排序(索引需同类型,否则先统一 dtype)
s.rank(ascending=False, method='min')  # 输出: 赵六 1.0 / 李四 2.0 / 孙七 3.0 / 张三 4.0 / 王五 5.0
s.describe()                        # 输出: count 5 / mean 87.6 / std 6.58 / min 78 / 25% 85 / 50% 88 / 75% 92 / max 95
s.head(2), s.tail(2)                # 输出: 张三 85 / 李四 92 ;赵六 95 / 孙七 88

s.astype('float64')                 # 输出: 85.0 92.0 ...(dtype: float64)
s.drop(index=['张三'])              # 输出: 去掉张三的 4 行新 Series
s.rename(index={'张三': 'Zhang'})   # 输出: 索引标签被替换
s.rename('期末成绩')                # 输出: 只改 name(传标量 = 改名,传 dict = 改标签)
s.rename_axis('姓名')               # 输出: 索引名变为「姓名」
s.reset_index()                     # 输出: DataFrame,两列(姓名 / 成绩)
s.reset_index(drop=True)            # 输出: Series,索引重置为 0..n-1
s.set_axis(['a', 'b', 'c', 'd', 'e', 'f'])   # 输出: 索引整体替换(长度须一致)

⚠️ 上述方法默认都返回新对象,原 Series 不变。别写 s.dropna(inplace=True) / s.sort_values(inplace=True):3.x 的 copy-on-write 下 inplace 已无性能收益,还会让链式调用失效,统一写 s = s.dropna()。

运算与索引对齐

Series 与 ndarray 最大的区别:运算按标签对齐,而不是按位置对齐。这是 Pandas 的核心直觉。

Python10 行
a = pd.Series([10, 20, 30], index=['a', 'b', 'c'])
b = pd.Series([1, 2, 4], index=['a', 'b', 'd'])

a + b                       # 输出: a 11.0 / b 22.0 / c NaN / d NaN(索引取并集,对不上的补 NaN)
a * 2, a - a.mean(), a ** 2  # 输出: [20 40 60];[-10. 0. 10.];[100 400 900](标量运算保留索引)
(a / 3).round(2)            # 输出: a 3.33 / b 6.67 / c 10.0
np.sqrt(a)                  # 输出: a 3.162 / b 4.472 / c 5.477(NumPy 通用函数直接可用)
a > 15                      # 输出: a False / b True / c True
a.add(b, fill_value=0)      # 输出: a 11.0 / b 22.0 / c 30.0 / d 4.0(缺失侧当 0 再算)
a.sub(b, fill_value=0), a.mul(b, fill_value=1), a.div(b, fill_value=1)   # 同样支持 fill_value

对齐三步:① 取两侧索引并集;② 各自缺失的位置补 NaN;③ 逐元素计算,结果 dtype 通常升为 float64。

Python4 行
a.index.equals(b.index)            # 输出: False ← 运算前先验对齐,别等 NaN 出现才发现
a.index.difference(b.index)        # 输出: Index(['c'])(a 有 b 没有)
a.index.union(b.index)             # 输出: Index(['a','b','c','d'])
a.align(b)                         # 输出: 两个已对齐(各自补 NaN)的 Series,供手工核对

统计聚合默认跳过 NaN:

Python4 行
s.count(), s.sum(), s.mean(), s.median(), s.std(), s.var(), s.min(), s.max()   # 非空计数 / 和 / 均值 / 中位数 / 标准差(ddof=1) / 方差 / 最小 / 最大
s.quantile([.25, .75])             # 输出: 0.25→85.0  0.75→92.0
s.abs(), s.cumsum(), s.cummax(), s.diff(), s.pct_change()   # 绝对值 / 累计和 / 累计最大 / 差分 / 环比
s.idxmax(), s.idxmin(), s.nlargest(2), s.nsmallest(2)       # 极值标签 / 极值位置上的 Top-N

布尔筛选与 where/mask (DataFrame 侧的系统梳理见 §4.7、§4.9)

Python11 行
s[s >= 90]                          # 输出: 李四 92 / 赵六 95
s[(s >= 80) & (s < 95)]             # 输出: 张三 85 / 李四 92 / 孙七 88(且:&,每个条件必须加括号)
s[(s < 80) | (s > 92)]              # 输出: 王五 78 / 赵六 95(或:|)
s[s != 92]                          # 输出: 除李四外全部
s[~s.index.isin(['王五'])]           # 输出: 去掉王五(按标签排除)
s.where(s >= 85)                    # 输出: 张三 85 / 李四 92 / 王五 NaN / 赵六 95 / 孙七 88(不满足 → NaN)
s.where(s >= 85, 0)                 # 输出: 不满足的位置填 0
s.mask(s >= 85)                     # 输出: 满足条件的被置 NaN(与 where 相反)
s.mask(s >= 85, -1)                 # 输出: 满足条件的被置 -1
s.clip(lower=80, upper=90)          # 输出: 全部截断到 [80, 90]
s.loc[s >= 90] = 100                # 写入:用 .loc,别写 s[s >= 90][...] = 100

⚠️ 多条件只能用 & / | / ~,用 and / or / not 会抛 ValueError(Python 关键字要求标量真值)。每个子条件都必须括起来,否则运算符优先级出错。

⚠️ 链式赋值在 3.x 下会静默失效:s[s >= 90]['李四'] = 0 改的是临时副本。任何写入都写成一次 s.loc[...] = ...。

缺失值处理 (系统梳理见后续)

Python12 行
t = pd.Series([1, np.nan, np.nan, 4, np.nan, 6])

t.isna(), t.notna()         # 输出: 布尔 Series(isnull/notnull 是等价别名,建议统一用 isna/notna)
t.isna().sum(), t.hasnans   # 输出: 3  True
t.dropna()                  # 输出: 1.0 / 4.0 / 6.0(索引保留原标签)
t.fillna(0)                 # 输出: 1.0 0.0 0.0 4.0 0.0 6.0
t.fillna(t.mean())          # 输出: 缺的都填 3.666667(mean 已跳过 NaN)
t.ffill()                   # 输出: 1.0 1.0 1.0 4.0 4.0 6.0(用前一个有效值)
t.bfill()                   # 输出: 1.0 4.0 4.0 4.0 6.0 6.0(用后一个有效值)
t.ffill(limit=1)            # 输出: 1.0 1.0 NaN 4.0 4.0 6.0(最多连续填 1 个)
t.interpolate()             # 输出: 1.0 2.0 3.0 4.0 5.0 6.0(线性插值,按位置等距)
t.replace(-999, np.nan)     # 输出: 把哨兵值 -999 换成 NaN(先清洗再统计)
手法 适用 写法
删 缺失少、可丢弃 dropna()
常量填 缺是有明确语义的(如「未填报」= 0) fillna(0)
统计量填 横截面数据、分布稳定 fillna(t.mean()) / fillna(t.median())
前后填 时间序列、状态延续 ffill() / bfill()
插值 连续型序列(温度、价格) interpolate()

⚠️ fillna(method='ffill') 已废弃,直接写 t.ffill()。字符串 dtype 的缺失值是 pd.NA 而非 np.nan,但同样用 isna() 检测,别用 == np.nan(永远为 False)。

与 NumPy / dict / DataFrame 互转

Python14 行
arr = s.to_numpy()                          # Series → ndarray(含扩展 dtype 时比 .values 稳)
pd.Series(arr, index=s.index)               # ndarray → Series

d = s.to_dict()                             # 输出: {'张三': 85, '李四': 92, ...}(Series → dict,标签须唯一)
pd.Series(d)                                # dict → Series(键变索引)

df = s.to_frame('成绩')                     # Series → DataFrame(列名即 name)
df['成绩']                                  # DataFrame 取一列 → Series(视图语义)
df['成绩'].copy()                           # 想独立修改就显式 copy
df.loc[:, '成绩'] = df['成绩'] * 2          # 写回必须走 .loc,不要 df['成绩'][0] = ...

s.to_list()                                 # 输出: [85, 92, 78, 95, 88](转 Python list)
list(s.items())                             # 输出: [('张三', 85), ...]((标签, 值) 对,可直接 for 遍历)
pd.concat([a, b])                           # 多个 Series 纵向拼接(索引不去重,可重复)

易错点

坑 现象 正确做法
s[0] 歧义 3.x 中整数键一律按标签;标签里没有 0 就 KeyError 位置用 s.iloc[0],标签用 s.loc['a']
整数索引 index=[10,11,12] 时 s[0] 报错,旧版却静默按位置取 一律显式 .loc / .iloc
切片端点 .loc['a':'c'] 含 c;.iloc[0:3] 不含 3 记住「标签闭、位置开」
索引不对齐 加减乘除不报错,静默产出 NaN 先 index.equals / difference 体检,或用 fill_value
重复索引 取值返回 Series、赋值一次改多行 先查 index.is_unique 再去重
inplace=True 无性能收益、破坏链式、CoW 下语义混乱 s = s.dropna() 重新赋值
链式赋值 写入静默丢失 一次 s.loc[cond] = v
.values 扩展 dtype 下返回的不是 ndarray 用 s.to_numpy()
标签不存在 s.loc['新键'] = v 是新增行而非报错 新增前确认拼写,别把打错的标签写进去

成绩统计与排名

一份 10 人的期末成绩,要一次给出描述统计、及格率、优秀人数、前三名、排名与分档分布——全程不写 for 循环,靠 Series 的向量化方法完成。

Python20 行
import pandas as pd

scores = pd.Series(
    {'张三': 85, '李四': 92, '王五': 78, '赵六': 95, '孙七': 88,
     '周八': 76, '吴九': 90, '郑十': 82, '钱一': 91, '孙二': 87},
    name='成绩')
scores.index.name = '学生'

scores.describe()                     # 输出: count 10 / mean 86.4 / std 6.17 / min 76 / 25% 82.75 / 50% 87.5 / 75% 90.75 / max 95
scores.mean(), scores.median()        # 输出: 86.4  87.5
(scores >= 60).mean() * 100           # 输出: 100.0(及格率:布尔当 0/1 求均值)
(scores >= 90).sum()                  # 输出: 3(优秀人数:布尔求和)
scores.nlargest(3)                    # 输出: 赵六 95 / 李四 92 / 钱一 91
scores.rank(ascending=False, method='min').astype('int64')   # 输出: 赵六 1 / 李四 2 / 钱一 3 / 吴九 4 / 孙七 5 / 孙二 6 / 张三 7 / 郑十 8 / 王五 9 / 周八 10
scores[scores > scores.mean()]        # 输出: 李四 92 赵六 95 孙七 88 吴九 90 钱一 91 孙二 87(共 6 人高于均分)

grades = pd.cut(scores, [0, 60, 70, 80, 90, 100], labels=['F', 'D', 'C', 'B', 'A'])   # 默认右闭区间,90 分落 B 档
grades.value_counts().sort_index()    # 输出: A 2 / B 5 / C 2 / D 0 / F 0
scores[grades == 'C']                 # 输出: 王五 78 / 周八 76(C 档名单)
scores.sort_values(ascending=False).head(3).to_dict()   # 输出: {'赵六': 95, '李四': 92, '钱一': 91}

要点:describe() 一把出分布,布尔Series.sum()/mean() 直接给人数和占比,rank() 给出名次,nlargest() 取 Top-N——统计类需求优先找 Series 自带方法,不要自己循环。

销量 × 单价的对齐核算

销量表和单价表来自两个系统,商品集合并不完全一致。直接相乘不会报错,但缺的一侧会静默变成 NaN——正确姿势是先体检索引、补齐口径,再算销售额与占比。

Python20 行
import pandas as pd

qty = pd.Series({'苹果': 150, '香蕉': 200, '橙子': 120, '葡萄': 80}, name='销量(斤)')
price = pd.Series({'苹果': 5.5, '香蕉': 3.2, '葡萄': 8.9, '西瓜': 2.5}, name='单价(元/斤)')

qty.index.equals(price.index)          # 输出: False ← 运算前必须体检
qty.index.difference(price.index)      # 输出: Index(['橙子'])(有销量没单价)
price.index.difference(qty.index)      # 输出: Index(['西瓜'])(有单价没销量)

qty * price                            # 输出: 苹果 825.0 / 香蕉 640.0 / 橙子 NaN / 葡萄 712.0 / 西瓜 NaN(不对齐 → NaN,不报错)
qty.mul(price, fill_value=0)           # 输出: 橙子 0.0 / 西瓜 0.0(缺的一侧当 0 再乘)

full_price = price.reindex(qty.index.union(price.index))   # 输出: 苹果 5.5 / 香蕉 3.2 / 橙子 NaN / 葡萄 8.9 / 西瓜 2.5
full_price = full_price.fillna(full_price.mean())          # 输出: 橙子 5.025(缺单价用均价 5.025 兜底)

revenue = (qty * full_price).dropna().sort_values(ascending=False)   # 输出: 苹果 825.0 / 葡萄 712.0 / 香蕉 640.0 / 橙子 603.0
revenue.sum()                          # 输出: 2780.0
revenue.idxmax(), revenue.max()        # 输出: 苹果  825.0
(revenue / revenue.sum() * 100).round(1)   # 输出: 苹果 29.7 / 葡萄 25.6 / 香蕉 23.0 / 橙子 21.7
revenue.cumsum().round(1)              # 输出: 苹果 825.0 / 葡萄 1537.0 / 香蕉 2177.0 / 橙子 2780.0

要点:* 是按标签对齐而非按位置,缺标签直接产 NaN 且不报错;动手前用 index.difference 找出缺口,用 fill_value 兜底或先 reindex 补齐口径,再相乘排序。

气温序列的缺失值清洗

一周气温有 3 天缺测,要先量缺口、按时间序列语义补齐,再做距平、环比与滑动平均——interpolate() 适用于连续量,ffill() 适用于状态量。

Python21 行
import pandas as pd
import numpy as np

temp = pd.Series([22.5, np.nan, np.nan, 25.5, np.nan, 27.0, 26.8],
                 index=['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                 name='气温(℃)')

temp.hasnans, temp.isna().sum()        # 输出: True  3(先量缺口)
temp.ffill()                           # 输出: 22.5 22.5 22.5 25.5 25.5 27.0 26.8(用前一天顶)
temp.bfill()                           # 输出: 22.5 25.5 25.5 25.5 27.0 27.0 26.8(用后一天顶)
temp.ffill(limit=1)                    # 输出: 22.5 22.5 NaN 25.5 25.5 27.0 26.8(最多连续顶 1 天)
t = temp.interpolate()                 # 输出: 22.5 23.5 24.5 25.5 26.25 27.0 26.8(线性插值,最贴合连续量)

t.mean().round(2), t.std().round(2)    # 输出: 25.15  1.66(均温 / 标准差)
t.idxmax(), t.max(), t.idxmin(), t.min()   # 输出: 周六  27.0  周一  22.5
(t - t.mean()).round(2)                # 输出: -2.65 -1.65 -0.65 0.35 1.10 1.85 1.65(距平)
t.diff().round(2)                      # 输出: NaN 1.0 1.0 1.0 0.75 0.75 -0.20(日环比,首日为 NaN)
t.rolling(3).mean().round(2)           # 输出: NaN NaN 23.5 24.5 25.42 26.25 26.68(3 日滑动平均)
t.where(t >= t.mean())                 # 输出: 低于均温的日子置 NaN(周四 25.5 周五 26.25 周六 27.0 周日 26.8 保留)
t[t > t.mean()].index.to_list()        # 输出: ['周四', '周五', '周六', '周日'](偏热日)
t.describe().round(2)                  # 输出: count 7 / mean 25.15 / std 1.66 / min 22.5 / 25% 23.5 / 50% 25.5 / 75% 26.53 / max 27.0

要点:先 isna().sum() 量缺口再选填法——连续量用 interpolate(),状态量用 ffill()/bfill(),横截面才用均值填;补齐后的 diff() / rolling() / where() 组合就是时间序列分析的最小套路。

DataFrame

DataFrame = 多个共享同一行索引的 Series 按列拼成的二维表:每行一条记录,每列一个字段,每列各自持有 dtype。

⚠️ 本章面向 Pandas 3.x:① 字符串列默认 dtype 为 str(不再是 object);② 前后向填充用 ffill() / bfill(),fillna(method=...) 已移除;③ Copy-on-Write 默认开启,禁止链式赋值,也不推荐 inplace——它不再省内存,还会让 CoW 语义变糊涂。

Python8 行
import pandas as pd
import numpy as np

df = pd.DataFrame({'姓名': ['张三', '李四', '王五'],
                   '年龄': [25, 30, 28],
                   '工资': [8000, 12000, 10000]})
df                                        # 输出: 3 行 × 3 列的表,行索引为 RangeIndex(0, 3)
type(df)                                  # 输出: <class 'pandas.core.frame.DataFrame'>

创建方式与三个关键字

来源 写法 要点
dict of list pd.DataFrame({'a': [1, 2], 'b': [3, 4]}) 最常用;键→列名,值→列数据,长度必须一致
list of dict pd.DataFrame([{'a': 1}, {'a': 2, 'b': 3}]) 每个 dict 一行,缺键自动补 NaN(API/JSON 场景)
list of list pd.DataFrame([[1, 'x'], [2, 'y']], columns=['a', 'b']) 每个子列表一行,必须手动给 columns
ndarray pd.DataFrame(np.arange(6).reshape(3, 2), columns=['a', 'b']) 必须二维,shape 要与 columns/index 对得上
dict of Series pd.DataFrame({'a': s1, 'b': s2}) 按索引对齐,对不上的行补 NaN
另一个 DataFrame pd.DataFrame(other) / other.copy() 本质是取子集后显式复制,别指望共享底层数据
文件 pd.read_csv(path) / pd.read_excel(path) 见 IO 章,index_col/usecols/dtype 在建表时就定好

三个构造参数的差别:columns 在 dict 来源下起「筛列 + 定序」作用,在 list/ndarray 来源下才是「命名」;index 指定行标签;dtype 可以是标量(全局)或 dict(逐列指定)。

Python16 行
d = {'姓名': ['张三', '李四'], '年龄': [25, 30], '城市': ['北京', '上海']}
pd.DataFrame(d, index=['a', 'b'])                     # 输出: 行标签变 a / b
pd.DataFrame(d, columns=['城市', '姓名'])              # 输出: 只留 2 列且顺序被改为 城市、姓名
pd.DataFrame(d, columns=['城市', '工号'])              # 输出: '工号' 不存在 → 整列 NaN

pd.DataFrame([[1, 'x'], [2, 'y']], columns=['a', 'b'])
pd.DataFrame(np.arange(6).reshape(3, 2), columns=['a', 'b'], dtype='float32')   # dtype 全局指定
pd.DataFrame(d).dtypes                                # 输出: 姓名 str / 年龄 int64 / 城市 str

s_name = pd.Series(['张三', '李四'], index=['a', 'b'])
s_age  = pd.Series([25], index=['a'])
pd.DataFrame({'姓名': s_name, '年龄': s_age})          # 输出: b 行年龄为 NaN(按索引对齐)

pd.DataFrame()                                        # 输出: 完全空表,columns=[] index=[]
pd.DataFrame(columns=['a', 'b'])                      # 输出: 有列名无数据行
pd.DataFrame(index=range(3), columns=['a', 'b'])      # 输出: 3×2 全 NaN 的占位表

⚠️ pd.DataFrame(d, columns=[...]) 里的列名若不在字典中,不会报错,只会得到一列全 NaN——拼写错经常在这里静默丢数据。

结构属性

属性 含义 示例值
shape (行数, 列数) (4, 5)
index 行索引对象 RangeIndex(start=0, stop=4)
columns 列索引对象 Index(['姓名','年龄'])
values 底层 ndarray(丢索引) array([[...]])
dtypes 每列 dtype 的 Series 姓名 str / 年龄 int64
ndim 维度,恒为 2 2
size 行数 × 列数 20
empty 是否无任何行或列 False
T 转置 行列互换
axes [index, columns] 列表 [RangeIndex(...), Index(...)]
Python10 行
df.shape, df.size, df.ndim, df.empty        # 输出: ((3, 3), 9, 2, False)
df.shape[0], df.shape[1]                    # 输出: (3, 3)  行数、列数
df.index.tolist()                           # 输出: [0, 1, 2]
df.columns.tolist()                         # 输出: ['姓名', '年龄', '工资']
df.axes                                     # 输出: [RangeIndex(0,3), Index(['姓名','年龄','工资'])]
df.values.shape                             # 输出: (3, 3)
df.T.index.tolist()                         # 输出: ['姓名', '年龄', '工资'](转置后原列名变行标签)
df.dtypes                                   # 输出: 姓名 str / 年龄 int64 / 工资 int64
df['姓名'].dtype                            # 输出: str(Pandas 3 默认,非 object)
df.dtypes.value_counts()                    # 输出: int64 2 / str 1
Python5 行
pd.DataFrame({
    '整数': [1], '浮点': [1.1], '文本': ['a'], '布尔': [True],
    '日期': pd.date_range('2024-01-01', periods=1), '分类': pd.Categorical(['A']),
}).dtypes
# 输出: int64 / float64 / str / bool / datetime64[ns] / category

查看概览

方法 作用
head(n) / tail(n) 前 / 后 n 行,默认 5
sample(n=) / sample(frac=) 随机抽行,random_state= 可复现
info() 行数、列名、非空计数、dtype、内存
describe() 数值列 count/mean/std/min/四分位/max;include='all' 带上非数值列
nunique() 每列去重后的取值个数
memory_usage(deep=True) 每列真实字节数(不加 deep 会低估对象列)
dtypes.value_counts() 各 dtype 有多少列
Python9 行
df.head(2), df.tail(1)                      # 输出: 前 2 行 / 最后 1 行
df.sample(2, random_state=0)                # 输出: 随机抽 2 行,结果可复现
df.info(memory_usage='deep')                # 输出: 3 entries, 3 columns,各列 non-null 计数与 dtype
df.describe()                               # 输出: 数值列的 count mean std min 25% 50% 75% max
df.describe(include='all')                  # 输出: 追加 unique / top / freq 三行
df.nunique()                                # 输出: 姓名 3 / 年龄 3 / 工资 3
df['年龄'].unique()                         # 输出: [25 30 28](单列取值数组)
df.memory_usage(deep=True).sum()            # 输出: 各列真实字节数之和(不加 deep 会低估文本列)
df.isna().sum()                             # 输出: 每列缺失值个数
Python3 行
big = pd.DataFrame({'i': np.arange(10000), 's': ['hello'] * 10000})
big2 = big.assign(i=big['i'].astype('int16'), s=big['s'].astype('category'))
big2.memory_usage(deep=True).sum()            # 输出: 远小于 big;big2.dtypes 为 int16 / category

⚠️ memory_usage() 不带 deep=True 时按 8 字节指针估算文本列,会把实际占用低估一个数量级;想省内存优先降整型精度(int64→int8/int16)和 astype('category')。

列操作

意图 写法
选单列(Series) df['姓名'],或列名合法时 df.姓名
选单列(DataFrame) df[['姓名']] ← 双层方括号
选多列 / 调顺序 df[['工资', '姓名']]
新增 / 改列 df['年薪'] = df['工资'] * 12(标量或等长序列)
指定位置插入 df.insert(1, '性别', [...])
批量新增并返回新表 df.assign(...)
改名 df.rename(columns={'name': '姓名'})
删除 df.drop(columns=['部门']) / del df['部门'] / df.pop('部门')
索引⇄列转换 set_index / reset_index / reindex
Python19 行
df['姓名'], df[['姓名']]                    # 输出: Series / DataFrame(双层方括号保二维)
df[['姓名', '工资']]                        # 输出: 选多列,同时也是调整列顺序

df2 = df.copy()
df2['国家'] = '中国'                        # 标量广播给所有行
df2['年薪'] = df2['工资'] * 12              # 向量化计算
df2['等级'] = pd.cut(df2['工资'], [0, 10000, np.inf], labels=['中', '高'])
df2.insert(1, '性别', ['男', '女', '男'])   # 插到第 1 个位置(原地修改)
df2.assign(奖金=lambda x: x['工资'] * 0.1, 总收入=lambda x: x['工资'] * 1.1)   # 返回新 DF,df2 不变
df2.rename(columns={'工 资': '工资'}, errors='raise')   # 输出: 键对不上直接报错,防止静默漏改
df2.rename(columns=str.upper).columns.tolist()          # 输出: 全大写列名列表

df2.drop(columns=['国家'])                  # 输出: 新表,原 df2 仍在
df2.pop('年薪')                             # 输出: 被弹出的 Series,同时 df2 少一列
# del df2['等级']                          # 原地删,无返回值

df2.set_index('姓名')                       # 输出: '姓名' 变行索引,并从列中移除
df2.set_index('姓名').reset_index()         # 输出: 索引退回普通列
df2.set_index('姓名').reset_index(drop=True)   # 输出: 丢弃旧标签,重排 RangeIndex
Python4 行
df.reindex(columns=['姓名', '城市', '工资'])          # 输出: '城市' 列不存在 → 整列 NaN(不是报错)
df.reindex(index=[0, 1, 5])                           # 输出: 索引 5 不存在 → 整行 NaN
df.reindex(columns=['姓名', '工资']).ffill()          # 输出: 沿行方向用上一个有效值填 NaN
df.reindex(columns=['姓名', '工资']).bfill(axis=1)    # 输出: 向后填;缺 axis 默认沿行方向

⚠️ 赋值的新列长度必须与行数一致,唯一例外是标量广播。df['x'] = some_series 会按索引对齐,长度对但标签对不上时静默铺 NaN,别用 Python list 之外的东西裸赋值。

⚠️ reindex 与 df[['城市']] 完全不同:前者缺列补 NaN,后者缺列直接 KeyError。需要「按给定顺序重排列」时用 reindex。

行操作

Python11 行
sub = df.head(3).set_index('姓名')
sub.loc['李四'], sub.loc[['李四']]           # 输出: Series / DataFrame
sub.loc['李四':'王五']                      # 输出: 标签切片,右端点包含
sub.iloc[0], sub.iloc[0:2]                  # 输出: Series / DataFrame;位置切片右端点不包含
sub.iloc[[0, 2]], sub.iloc[:, :2]           # 输出: 第 1、3 行 / 所有行的前两列

sub.drop(index=['李四'])                    # 输出: 按标签删行
sub.drop(sub.index[0])                      # 输出: 按位置删行(先换算成标签)
df[df['工资'] > 9000]                       # 输出: 布尔筛选,条件间用 & | ~ 且各自加括号
df.query('工资 > 9000 and 年龄 < 30')       # 输出: 字符串表达式筛选
df.drop_duplicates(subset=['姓名'])         # 输出: 按列去重,保留首次出现
Python4 行
new_rows = pd.DataFrame({'姓名': ['赵六'], '年龄': [35], '工资': [15000]})
pd.concat([df, new_rows], ignore_index=True)                        # 输出: 4 行,索引重排
pd.concat([df, new_rows], keys=['old', 'new'])                      # 输出: 外层多级索引保来源
pd.concat([df, new_rows[['姓名', '工资']]], ignore_index=True)      # 输出: 列不齐的行补 NaN

⚠️ df.append() 在 Pandas 2 起废弃、3 起已移除;循环内反复 pd.concat 也是 O(n²) 的整表复制。正确做法是把每行收集进 list,最后一次性 pd.DataFrame(list) 或 pd.concat。

⚠️ df.loc[...] = val 之外不要写 df['a'][0] = val——这是链式赋值,CoW 下可能原地丢 RuntimeWarning,也可能改不到原表。一律写成 df.loc[idx, 'a'] = val。

索引对象 Index

类型 产生方式 说明
Index 任意标签,字符串/日期等 不可变、可哈希,Index([...]) 可显式造
RangeIndex 默认行索引 只存 start/stop/step,不占内存
MultiIndex set_index([多列])、pd.MultiIndex.from_* 层级标签,细节见第 16 章
Python11 行
idx = pd.Index(['a', 'b', 'a'], name='key')
idx.name, idx.dtype, idx.is_unique          # 输出: ('key', dtype('str'), False)
idx.set_names(['K']).name                   # 输出: 'K'
idx.unique(), idx.value_counts()            # 输出: Index(['a','b']) / a 2, b 1
pd.Index(['a', 'b', 'c']).get_loc('b')      # 输出: 1(唯一索引返回标量位置)
idx.get_loc('a')                            # 输出: array([0, 2])(重复标签返回位置数组)
pd.RangeIndex(0, 1000).memory_usage()       # 输出: 远小于等效 Index

m = df.assign(部门=['技术', '销售', '技术']).set_index(['部门', '姓名'])
m.index.nlevels, m.index.names              # 输出: (2, FrozenList(['部门', '姓名']))
m.loc['技术']                               # 输出: 取外层标签,内层索引保留

⚠️ 行索引可以重复,此时 loc[标签] 返回多行、get_loc 返回数组,聚合口径容易翻车;需要主键语义时先 df.index.is_unique 检查。

与 NumPy / dict / list 互转

Python11 行
df.to_numpy()                               # 输出: object 二维数组(str 与 int 混在一起被向上提升)
df[['年龄', '工资']].to_numpy()             # 输出: int64 二维数组(同类型才保住 dtype)
df[['年龄', '工资']].to_numpy(dtype='float64')   # 显式指定目标 dtype
df2 = df.assign(分数=[90.5, 88.0, np.nan])
df2[['分数']].to_numpy(na_value=-1)         # 输出: NaN 换成 -1(.values 做不到)

df.to_dict(orient='list')                   # 输出: {'姓名': [...], '年龄': [...], ...}
df.to_dict(orient='records')                # 输出: [{'姓名': '张三', ...}, ...](喂给 API 常用)
pd.DataFrame(df.to_dict(orient='records'))  # 输出: records → 表,往返一致
df.columns.tolist(), df.index.tolist()      # 输出: 两个 Python list
df.values.tolist()                          # 输出: 嵌套 list(每行一个子列表)

⚠️ .values 不带参数,只能被迫接受内部推断出的 dtype;to_numpy(dtype=, na_value=, copy=) 才有控制力。混合类型列时二者都会塌成 object,丢掉全部向量化性能——先按类型选列再转。

⚠️ to_numpy() 得到的是 ndarray,行/列索引全部丢失;需要索引就转 List of record(to_dict('records'))或用 reset_index() 把索引变成列。

复制与视图

Python7 行
a = df[['年龄']]                            # Pandas 3 CoW:惰性副本,改它不污染 df
b = df[['年龄']].copy()                     # 深拷贝:独立新内存,可安全改
c = df[['年龄']].copy(deep=False)           # 浅拷贝:共享底层 buffer

df.drop(columns=['工资'])                   # ✅ 返回新表,原表不变
df = df.drop(columns=['工资'])              # ✅ 用赋值替代 inplace
# df.drop(columns=['工资'], inplace=True)   # ❌ 不再有性能收益,且会在 CoW 下徒增心智负担

⚠️ Pandas 3 起 Copy-on-Write 是唯一模式:任何子集都可当作副本,改动它不会污染原表,但同时也不保证 .to_numpy() 返回可写数组。inplace 既不省内存也不可链式,统一用「返回新对象 + 重新赋值」。

易错点速查

坑 现象 正确写法
单列 vs 单列表 df['a'] 得 Series,横向拼接行为不同 要始终二维用 df[['a']]
列名书写错误 df.citys 抛 AttributeError,静默的通常是 reindex ✅ df['city'](KeyError 才暴露问题)
长度不匹配 赋 list 长度不足 → ValueError;赋 Series 标签错位 → 全 NaN 先 reset_index(drop=True) 再赋 list
视图 / 副本 改了子集却没改到原表(或反之) 不确定就 .copy()
链式赋值 df['a'][0] = 1 可能无效 df.loc[0, 'a'] = 1
inplace=True 无返回值、破坏链式、CoW 下无收益 df = df.drop(...) / .rename(...) / .ffill()
dtype 提升 混入一列文本 → 整块变 object 转 ndarray 前先按 dtype 选列
Python3 行
df['工资'], df[['工资']]                    # 输出: Series / DataFrame(双层方括号保二维)
# df['分数'] = [90, 88]                     # ❌ 长度 2 ≠ 行数 3 → ValueError
df.loc[0, '年龄'] = 99                      # ✅ 不要写 df['年龄'][0] = 99(链式赋值)

从零搭一张员工表并自我体检

新起一个分析任务时,先用手头的字典把骨架搭出来:用 columns 定列顺序、用 insert/assign 补派生列、把唯一标识列 set_index,最后跑一遍 info + memory_usage 确认结构和内存都没跑偏。整个过程不需要碰文件 IO。

Python16 行
people = pd.DataFrame(
    {'姓名': ['张三', '李四', '王五', '赵六'], '部门': ['技术', '销售', '技术', '销售'],
     '入职年份': [2019, 2021, 2018, 2023], '基本工资': [18000, 12000, 22000, 9000]},
    columns=['姓名', '部门', '入职年份', '基本工资'], index=['E01', 'E02', 'E03', 'E04'],
)
people.insert(2, '性别', ['男', '女', '男', '女'])      # 指定位置插列
people = people.assign(工作年限=2026 - people['入职年份'], 年收入=lambda x: x['基本工资'] * 12)
people = people.rename(columns={'年收入': '年薪'}).set_index('姓名')   # 唯一标识上索引

people.shape, people.columns.tolist()      # 输出: (4, 6)  ['部门','性别','入职年份','基本工资','工作年限','年薪']
people.dtypes.value_counts()               # 输出: int64 4 / str 2
people.info(memory_usage='deep')           # 输出: 4 entries, 6 columns, 各列 non-null 4
people.describe()                          # 输出: 数值列的 count/mean/std/min/四分位/max
people.memory_usage(deep=True).sum()       # 输出: 含文本列真实开销的字节总数
people = people.astype({'基本工资': 'int32', '性别': 'category'})
people.memory_usage(deep=True).sum()       # 输出: 比上一行小(降整型精度 + 转 category)

要点:构造参数 columns 既能选列也能定序,set_index 把标识列抽走后再计数更干净;改 dtype 降内存属于收尾步骤,放在结构定型之后。

API 返回的字典列表对齐字段

后端一次性吐出若干条记录时,缺字段、字段命名风格不一致是常态。先把 records 直接喂给 pd.DataFrame 补 NaN,再统一列名、reindex 到目标列清单,最后用 ffill() 兜底缺失——全程不修改原始 list。

Python17 行
records = [
    {'empId': 'E01', 'empName': '张三', 'dept': '技术', 'salary': 18000},
    {'empId': 'E02', 'empName': '李四', 'salary': 12000},              # 缺 dept
    {'empId': 'E03', 'empName': '王五', 'dept': '技术', 'salary': 22000},
]

raw = pd.DataFrame(records)                 # 缺字段的行自动补 NaN
clean = (raw.rename(columns={'empId': '工号', 'empName': '姓名', 'dept': '部门', 'salary': '薪资'})
            .reindex(columns=['工号', '姓名', '部门', '薪资'])   # 输出: 定列序;多余列丢弃,缺失补 NaN
            .set_index('工号'))
clean['部门'] = clean['部门'].ffill()       # 输出: Pandas 3 的前向填充;定值填用 fillna(value=...)
clean = clean.reset_index()                 # 索引退回普通列,便于导出

clean.columns.tolist()                      # 输出: ['工号', '姓名', '部门', '薪资']
clean.isna().sum()                          # 输出: 各部门缺失 0,其余同
clean.to_dict(orient='records')             # 输出: 清洗后的 records,可直接回传给调用方
clean.to_numpy()                            # 输出: object 二维数组(str 与 int 混合被提升)

要点:pd.DataFrame(list_of_dict) 天然容忍缺键;rename + reindex(columns=...) 是把外部字段钉死到内部契约的固定套路,缺列的锅由 reindex 显式补 NaN 暴露出来。

循环采集时别一边跑一边拼表

爬接口、读多个文件时最容易写出的反模式是 df = df.append(row)(已移除)或循环里反复 concat。正确姿势是让循环只负责往 list 里追加字典,循环结束后一步建表;同时要求每条记录的字段集合一致,否则对齐时会凭空多出 NaN 列。

Python16 行
rows = []
for city, sales in [('北京', 320), ('上海', 415), ('广州', 288)]:
    rows.append({'城市': city, '销售额': sales, '达成': sales >= 300})   # 只攒数据,不碰 DataFrame

perf = pd.DataFrame(rows).set_index('城市') # 输出: 一次性建表,O(n)
perf.loc['深圳'] = [260, False]             # 单行追加,值须覆盖全部列
perf = perf.assign(达成率=lambda x: (x['销售额'] / 300).round(2))

bottom = perf.drop(index=perf['销售额'].idxmin())   # 删掉销售额最低的城市
bottom.loc[:, '销售额']                     # 输出: Series(loc[:, '列'] 仍是 Series)
bottom.loc[:, ['销售额']]                   # 输出: DataFrame(双层方括号)
bottom.to_dict(orient='index')              # 输出: 索引作外层键,如 {'北京': {...}}

c = perf.copy()
c.loc[c['达成'], '销售额'] = c['销售额'] * 1.05     # ✅ 布尔掩码 + 单一 loc
# perf['销售额'][perf['达成']] = 0                  # ❌ 链式赋值

要点:先把记录攒成 list 再一次 pd.DataFrame,既避开 O(n²) 复制也避开已移除的 append;所有「按条件改一列」都收敛到一次 .loc[mask, col] = ...,不与 CoW 较劲。

数据选择与索引

数据选择 = 按标签 / 按位置 / 按条件 从 DataFrame 里切出子集。十几套选择器各有一条语义边界;选错工具的典型后果不是报错,而是静默拿到错误结果。

⚠️ 本章面向 Pandas 3.x:① Copy-on-Write 默认开启,赋值一律走 .loc[...] = ...,禁止链式赋值;② inplace 不再省内存,不要用;③ 字符串列默认 dtype 为 str,.str 访问器的空值要用 na=False / isna() 兜底。

Python13 行
import pandas as pd
import numpy as np

df = pd.DataFrame({
    '姓名': ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十'],
    '性别': ['男', '女', '男', '女', '男', '女', '男', '女'],
    '年龄': [18, 19, 18, 20, 19, 18, 20, 19],
    '班级': ['一班', '一班', '二班', '二班', '一班', '二班', '一班', '二班'],
    '数学': [85, 92, 78, 88, 95, 73, 90, 82],
    '英语': [90, 88, 85, 92, 87, 78, 93, 95],
    '物理': [78, 85, 92, 80, 88, 85, 82, 90],
})
df.shape                                 # 输出: (8, 7)

选择器速查表:什么时候用哪个

选择器 依据 写法 什么时候用
df['col'] 列名 df['数学'] 取一列,结果 Series;最省事但语义弱
df[['c1','c2']] 列名列表 df[['数学','英语']] 取多列,结果 DataFrame(双层方括号)
df[a:b] 行位置切片 df[2:5] 快速看一段;半开区间
df[mask] 布尔掩码 df[df['数学'] > 85] 只读条件筛选,最简洁
.loc[行, 列] 标签 df.loc['张三', '数学'] 默认首选:按名字取,切片闭区间,且是唯一安全的赋值入口
.iloc[行, 列] 位置 df.iloc[0, 4] 「前 n 行 / 后 n 列 / 隔 k 取」;切片半开
.at[r,c] / .iat[r,c] 标签 / 位置 df.at[0, '数学'] 循环里读写单个标量,比 loc/iloc 快
.query(expr) 字符串表达式 df.query('数学 > @th') 条件多、想少写一堆 df[
.where() / .mask() 布尔 + 对齐 s.where(s > 0, 0) 保形状的条件替换(不缩行)
.filter() 名字模式 df.filter(like='数') 按列名/索引名的模式批量选
.get() 列名 df.get('化学', None) 列名可能不存在,不想 KeyError
.take() 位置数组 df.take([0, -1]) 手上已有位置数组(可负)时
.xs() 标签 + level df.xs('王五', level='姓名') MultiIndex 跨层取横截面
.sample() 随机 df.sample(5, random_state=0) 抽样 / 打乱
.reindex() 标签集合 df.reindex(idx) 按目标标签重排并补齐,缺失补 NaN

三条硬规则:按名字用 loc,按位置用 iloc,只读简写用 []/布尔掩码;凡是赋值一律 .loc[...] = ...。

[] 操作符:选列与切行

Python6 行
df['数学']                               # 输出: Series(Name: 数学,索引 0..7)
df[['数学']]                             # 输出: DataFrame(仍是 1 列)
df[['姓名', '数学', '英语']]              # 输出: DataFrame(3 列)
df[0:3]                                  # 输出: 位置 0,1,2 三行(半开区间)
df['数学'][0]                            # 输出: 85 ← 链式**取值**可读,链式**赋值**禁止
# df[:, '数学':'英语']                    # ❌ TypeError:[] 不支持二维

⚠️ [] 靠参数类型猜语义:字符串 → 列、列表 → 列、切片/布尔 Series → 行。同一个符号干三件事,正是它被 loc/iloc 取代的原因。

⚠️ df['数学'] = ... 是整列赋值,等价于 df.loc[:, '数学'] = ...;而 df[df['数学'] > 85]['英语'] = 0 是链式赋值,CoW 下必定不生效。

loc:基于标签

Python9 行
s = df.set_index('姓名')                 # 之后行标签为姓名
s.loc['张三', '数学']                    # 输出: 85(标量)
s.loc['张三']                            # 输出: Series(index 是列名,name='张三')
s.loc[:, ['数学', '英语']]               # 输出: DataFrame(所有行,两列)
s.loc[['张三', '李四'], ['数学', '英语']] # 输出: 2×2
s.loc['张三':'王五']                     # 输出: 张三、李四、王五 3 行(**闭区间**)
s.loc[:, '数学':'英语']                  # 输出: 数学、英语 两列(同样闭区间)
s.loc[lambda d: d['数学'] > 90]          # 输出: 李四、钱七(callable 收到整个 DataFrame)
s.loc[s['数学'] > 90, '班级']            # 输出: 李四 一班 / 钱七 一班

⚠️ 索引是整数时 df.loc[0:3] 依然按标签算闭区间 → 4 行;而 df[0:3] 与 df.iloc[0:3] 都是 3 行。这是本章第一号混淆点。

⚠️ 标签切片要求索引唯一且单调,否则 KeyError;切片前先 sort_index()。iloc 与布尔掩码不受此限制。

iloc:基于位置

Python8 行
df.iloc[0, 4]                            # 输出: 85(第 0 行第 4 列 = 张三的数学)
df.iloc[[0, 2]]                          # 输出: 第 0、2 行
df.iloc[[0, 0, 1]]                       # 输出: 位置可重复(第 0 行取两遍)
df.iloc[0:3, 4:6]                        # 输出: 3 行 × 2 列(半开区间)
df.iloc[-1, -1]                          # 输出: 90(最后一行最后一列)
df.iloc[:, 4:7].columns.tolist()         # 输出: ['数学', '英语', '物理']
rng = np.random.default_rng(0)
df.iloc[rng.choice(len(df), 3, replace=False)]      # 输出: 随机 3 行(比 sample 更可控)

⚠️ iloc 越界抛 IndexError(loc 抛 KeyError),且绝不接受标签——索引里存的是字符串数字也不行。

⚠️ 布尔掩码传给 .loc 时,Series 会先按索引对齐,索引对不上直接 IndexingError;想绕过对齐就传 .to_numpy()。iloc 只认长度相等的裸数组。

loc vs iloc 切片语义对照

维度 loc iloc
依据 标签 / 列名 整数位置(可负)
切片 闭区间 a:b 含 b 半开区间 a:b 不含 b
越界 KeyError IndexError
布尔 Series(需索引对齐)/ ndarray ndarray / list 更常见
callable 支持 df.loc[lambda d: ...] 支持 df.iloc[lambda d: ...]
典型用途 业务条件、按名取值、赋值 前 n 行、后 n 列、隔 k 取、随机位置
Python3 行
df.loc[0:3].shape                        # 输出: (4, 7) ← 标签闭区间
df.iloc[0:3].shape                       # 输出: (3, 7) ← 位置半开
# df.loc[0, 4]                            # ❌ 语义不明:0 是标签、4 是位置,不可混用

at / iat:单个标量

Python3 行
df.at[0, '数学']                         # 输出: 85
df.at[0, '数学'] = 100                   # ✅ 单格赋值最快
# df.at[0:2, '数学']                      # ❌ at/iat 不支持切片和列表

⚠️ at/iat 只能取改一个标量(at 按标签、iat 按位置),需要切片/列表/掩码时回到 loc/iloc。循环里逐格改值用 iat 没问题,但先问一句:能不能整列向量化。

布尔索引与多条件组合

Python13 行
mask = df['数学'] > 85                   # 输出: [F,T,F,T,T,F,T,F] 的 bool Series
df[mask]                                 # 输出: 李四、赵六、钱七、周九 4 行
df.loc[mask, ['姓名', '数学']]            # 输出: 同上但只留两列
df.loc[~mask, '数学']                    # 输出: 张三85 王五78 孙八73 吴十82
df[(df['数学'] > 85) & (df['英语'] > 90)]                    # 输出: 赵六、周九
df[(df['数学'] > 90) | (df['英语'] > 90)]                    # 输出: 李四、赵六、钱七、周九、吴十
df[~((df['数学'] > 85) & (df['英语'] > 90))]                 # 输出: 其余 6 行
df[(df['数学'] > 85) & (df['英语'] > 85) & (df['物理'] > 85)] # 输出: 钱七
df[df['姓名'].isin(['张三', '李四', '王五'])]                 # 输出: 3 行
df[~df['姓名'].isin(['张三', '李四'])]                        # 输出: 其余 6 行(isin 反向筛选)
df[df['数学'].between(80, 90)]                                # 输出: 张三、赵六、周九、吴十
df[df['数学'].between(80, 90, inclusive='neither')]           # 输出: 张三、赵六、吴十(80<x<90)
df[df['姓名'].str.startswith('张')]                           # 输出: 张三

⚠️ 组合只能用 & | ~,不能用 and or not(Python 关键字对 Series 求真值 → ValueError)。且 & | 优先级高于比较运算符,每个条件必须单独加括号——df[df['a'] > 85 & df['b'] > 90] 会先算 85 & df['b'] 再报 TypeError。

⚠️ 掩码中的 NaN 按 False 处理。.str.contains() 遇到非字符串/空值返回 NaN,必须写 .str.contains('三', na=False),否则筛选结果里混进意外行。

⚠️ isin 比一串 | 更快也更可读;between(a, b) 默认 inclusive='both',半开/开区间用 'left' / 'right' / 'neither'。

query():字符串表达式

Python8 行
th, names = 85, ['张三', '李四', '王五']
df.query('数学 > @th')                              # 输出: 李四、赵六、钱七、周九
df.query('数学 > @th and 英语 > 90')                 # 输出: 赵六、周九
df.query('(数学 > 90 or 英语 > 90) and 性别 == "女"') # 输出: 李四、赵六、吴十
df.query('姓名 in @names')                          # 输出: 张三、李四、王五
df.query('班级 == "一班" and 数学 > @th')            # 输出: 李四、钱七、周九
df.query('数学 > 85')[['姓名', '数学']]              # query 返回新表,可继续取列
df.rename(columns={'数学': '数学 成绩'}).query('`数学 成绩` > 90')   # 输出: 李四、钱七(列名带空格用反引号)

⚠️ query 里用 and / or / not(不是 & | ~);字符串字面量需要内外两层引号(外单内双);@ 只引用局部变量;列名带空格要用反引号。

⚠️ query 里没法调 .str 访问器、没法用对象方法——复杂条件回到布尔掩码。大表上 query 走 numexpr 有加速,小表反而略慢。

where / mask / np.where / np.select:保形状的条件替换

Python9 行
s = df['数学']
s.where(s >= 85, 0)                      # 输出: 85 92 0 88 95 0 90 0(不满足 → 替换值)
s.where(s >= 85)                         # 输出: 85 92 NaN 88 95 NaN 90 NaN(省略第二参数)
s.mask(s >= 85, -1)                      # 输出: -1 -1 78 -1 -1 73 -1 82(满足条件 → 替换)
df.where(df[['数学', '英语']] >= 85)      # 输出: 形状不变,不满足的格子变 NaN
np.where(df['数学'] >= 85, '达标', '补考') # 输出: 长度 8 的 ndarray
avg = df[['数学', '英语', '物理']].mean(axis=1)
df['等级'] = np.select([avg >= 88, avg >= 85, avg >= 75],
                       ['优秀', '良好', '及格'], default='待提高')

⚠️ where = 保留满足、替换不满足;mask = 反过来替换满足。二者保形状,这正是它们与 df[mask](会缩行)的根本区别。

⚠️ np.select 的 condlist 顺序即优先级,第一个命中的生效;default 务必显式给,否则落 0。三档以上用它比嵌套 np.where 清晰得多。

filter / get / take / xs / sample

Python10 行
df.filter(items=['姓名', '数学'])         # 输出: 2 列(不存在的列名静默跳过)
df.filter(like='数')                     # 输出: 数学 列
df.get('化学', default=None)             # 输出: None(不抛 KeyError)
df.take([0, 3, -1])                      # 输出: 第 0、3、末 三行
df.take([4, 5], axis=1)                  # 输出: 数学、英语 两列
df.sample(3, random_state=42)            # 输出: 随机 3 行(可复现)
df.sample(frac=0.5, random_state=0)      # 输出: 随机 50%
mi = df.set_index(['班级', '姓名'])
mi.loc[('一班', '张三'), '数学']          # 输出: 85(多级索引用元组)
mi.xs('张三', level='姓名')               # 输出: 跨班级取张三那一行

⚠️ filter(items=...) 对不存在的列名静默忽略,不像 df[cols] 会 KeyError——拿它做字段白名单时要额外校验列数;like / regex 分别按子串和正则匹配列名。

⚠️ take 接受负位置,越界报 IndexError,等价于 iloc 但可直接吃 ndarray。多级索引用元组取,跨层抽取用 xs(key, level=...)。

赋值:只用 .loc,别用链式

Python11 行
df.loc[0, '数学'] = 100                                    # ✅ 单格
df.loc[:, '总分'] = df[['数学', '英语', '物理']].sum(axis=1)  # ✅ 新增列
df.loc[df['数学'] > 85, '英语'] = 95                        # ✅ 条件改单列
df.loc[df['数学'] > 85, ['英语', '物理']] = 0                # ✅ 条件改多列
df.loc[len(df)] = ['新同学', '男', 18, '一班', 80, 80, 80]   # ✅ loc 扩容追加一行
m = df['数学'] > 85
df.loc[m, '等级'] = 'A'                                    # ✅ 先存掩码,再一次赋值
# df[df['数学'] > 85]['英语'] = 0                           # ❌ 链式赋值
# df['数学'][0] = 100                                       # ❌ 同样是链式
sub = df[df['班级'] == '一班'].copy()                       # ✅ 要独立改就显式 copy
sub['英语'] = 0                                            # 不影响 df

⚠️ Pandas 3 的 CoW 下,任何取出的子对象都表现得像副本,SettingWithCopyWarning 已不再有意义。链式赋值不再是「有时生效」,而是永远不生效(2.x 开启 CoW 时会抛 ChainedAssignmentError)。所以“没看到警告”不等于改成功了。

⚠️ .loc[mask, col] = Series 会按索引对齐,对不上的行留 NaN;要按位置写就传 .to_numpy()。给新列赋值时注意 dtype:往 int 列写 float 会整列 upcast。别用 inplace=True——CoW 下它不省内存,还会把「改的是谁」这件事搅浑。

索引的坑:重复索引 / sort_index / reindex / get_indexer

Python12 行
dup = pd.DataFrame({'数学': [80, 90, 85]}, index=['张三', '张三', '李四'])
dup.index.is_unique                                  # 输出: False
dup.loc['张三']                                       # 输出: 2 行的 DataFrame(不是 Series!)
dup.loc['张三', '数学']                                # 输出: Series(两行)
# dup.reindex(['张三', '李四'])                        # ❌ ValueError:索引有重复标签
dup[~dup.index.duplicated(keep='first')]             # 输出: 保留首次出现的行
d = df.set_index('姓名').sort_index()
d.index.is_monotonic_increasing                      # 输出: True(切片前提)
d.loc['张三':'王五']                                  # 输出: 张三、李四、王五
d.reindex(['张三', '钱七', '不存在'])                  # 输出: '不存在' 整行 NaN
d.reindex(columns=['数学', '物理', '化学'])            # 输出: 化学 列全 NaN
d.index.get_indexer(['张三', '不存在'])                # 输出: array([3, -1])(-1 = 缺失)

⚠️ 重复索引是静默放大器:loc 取到多行、reindex 直接报错、join/groupby 结果条数翻倍。设索引前先查 df.index.is_unique 或 df.index.duplicated().sum()。

⚠️ 非单调索引上做标签切片会 KeyError,先 sort_index();iloc 与布尔掩码不受影响。reindex 用 NaN 表达缺失,get_indexer 用 -1 表达缺失——批量对齐回写时用后者配合 np.where(pos >= 0, ...) 最省事。

组合技:idxmax / nlargest / duplicated / np.select (排序与 TopN 见 §14)

Python8 行
df['数学'].idxmax(), df['数学'].max()                 # 输出: (4, 95) → 钱七
df.loc[df['数学'].idxmax(), '姓名']                    # 输出: '钱七'
df['数学'].idxmin()                                   # 输出: 5(孙八 73)
df.nlargest(3, '数学')[['姓名', '数学']]               # 输出: 钱七95 李四92 周九90
df[~df['姓名'].duplicated(keep='first')]              # 输出: 按姓名去重(保留首次)
df.loc[df.duplicated(subset=['班级', '性别']), ['姓名', '班级', '性别']]   # 输出: 重复组合
df.loc[:, '数学'] = df['数学'].mask(df['数学'] < 0)     # 输出: 非法负值 → NaN
df['档位'] = np.where(df['数学'] >= 90, 'A', np.where(df['数学'] >= 80, 'B', 'C'))

⚠️ idxmax/idxmin 返回的是索引标签不是位置,要位置用 df['col'].to_numpy().argmax()。含 NaN 时 idxmax 跳过 NaN,全 NaN 才报错。

⚠️ nlargest 默认 keep='first',并列时只取先出现的那行;要全部并列用 keep='all'。

成绩分档与偏科筛查

教务处要一份「平均分 + 等级 + 偏科标记」名单:先算三科均分并用 np.select 分档,再用 idxmax / nlargest 定位尖子,最后把偏科与需辅导的标记一次性回写原表——全程不写循环,也不碰链式赋值。

Python23 行
score = pd.DataFrame({
    '姓名': ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十'],
    '班级': ['一班', '一班', '二班', '二班', '一班', '二班', '一班', '二班'],
    '数学': [85, 92, 78, 88, 95, 73, 90, 82],
    '英语': [90, 88, 85, 92, 87, 78, 93, 95],
    '物理': [78, 85, 92, 80, 88, 85, 82, 90],
}).set_index('姓名')

subj = ['数学', '英语', '物理']
avg = score[subj].mean(axis=1)                       # 输出: 张三 84.33 … 钱七 90.00
score['平均分'] = avg.round(2)
score['等级'] = np.select([avg >= 88, avg >= 85, avg >= 75],
                          ['优秀', '良好', '及格'], default='待提高')
top = score['平均分'].idxmax()                        # 输出: '钱七'
score.loc[top, ['平均分', '等级']]                    # 输出: 90.0 / 优秀
score.nlargest(3, '平均分')[['班级', '平均分', '等级']] # 输出: 钱七90、吴十89、李四/周九88.33

spread = score[subj].max(axis=1) - score[subj].min(axis=1)
score['偏科'] = False
score.loc[spread > 12, '偏科'] = True                # ✅ 王五(14)、吴十(13) 置 True
help_idx = score.index[(score[subj] < 80).any(axis=1)]  # 输出: Index(['张三','王五','孙八'])
score.loc[help_idx, '等级'] = '需辅导'                # ✅ 用标签索引一次性回写
score.loc[score['班级'] == '一班', '平均分'].mean()    # 输出: 87.75

要点:分档用 np.select 一次到位,标记列先建全 False 再 .loc[mask] = True,idxmax/nlargest 只负责定位——所有写操作都收敛到一次 .loc[...] = ...。

订单的时间窗口与 Top-N 筛选

销售表要按「1 月订单」和「高单价高销量」两个口径各出一份名单,并把重点客户标记回原表,再按日期索引做月度切片。注意 query 返回的是新表,必须经 .index 接回 .loc 才能落回原表。

Python21 行
orders = pd.DataFrame({
    '日期': pd.to_datetime(['2024-01-03', '2024-01-08', '2024-01-12',
                            '2024-01-20', '2024-02-02', '2024-02-15']),
    '地区': ['北京', '上海', '广州', '北京', '上海', '广州'],
    '销售额': [3200, 1800, 4500, 2900, 5100, 2400],
    '销量': [30, 12, 45, 28, 52, 20],
})
lo, hi = pd.Timestamp('2024-01-01'), pd.Timestamp('2024-01-31')
orders[orders['日期'].between(lo, hi)]               # 输出: 前 4 行(1 月订单)
big = orders.query('销售额 > 2500 and 销量 > 25')     # 输出: 北京3200、广州4500、上海5100

orders['重点'] = False
orders.loc[big.index, '重点'] = True                 # ✅ query 结果经 .index 接回 loc
best = orders['销售额'].idxmax()                     # 输出: 4(上海 5100)
orders.loc[best, ['地区', '销售额']]                 # 输出: 上海 / 5100
orders.nlargest(2, '销售额')[['地区', '销售额']]      # 输出: 上海5100、广州4500

oi = orders.set_index('日期').sort_index()           # 时间索引必须先排序
oi.loc['2024-01']                                    # 输出: 1 月 4 行(loc 部分字符串匹配)
oi.loc['2024-01-05':'2024-02-05']                    # 输出: 闭区间,含两端
oi.index.is_monotonic_increasing                     # 输出: True(切片前提)

要点:时间窗可用 between(lo, hi),也可用 loc['2024-01'] 部分字符串切片(要求 DatetimeIndex 已排序);query 的结果必须经 .index 交给 .loc 才能回写。

脏表去重、清洗与跨表回写

接口返回的员工表有重复记录、首尾空格、哨兵值 -1,还要把调薪表按工号对齐写回。流程是:duplicated 定位重复 → 显式 copy → 逐项 .loc 修复 → 去重后再 set_index 保证索引唯一 → 用 .loc[series.index, col] = series 对齐回写。

Python23 行
raw = pd.DataFrame({
    '工号': ['E01', 'E02', 'E02', 'E03', 'E04'],
    '姓名': ['张三', '李四', '李四', '王五', '赵六'],
    '城市': ['北京', '上海', '上海', '广州', '  '],
    '薪资': [8000, 12000, 12000, -1, 9500],
})
raw[raw.duplicated(keep=False)]                      # 输出: E02 两行(重复记录全标出)
clean = raw[~raw.duplicated()].copy()                # ✅ 显式 copy,后续修改不污染 raw

clean.loc[:, '城市'] = clean['城市'].str.strip()      # 输出: 首尾空格被去掉
clean.loc[clean['城市'] == '', '城市'] = '未知'       # 输出: 赵六 → 未知
clean.loc[:, '薪资'] = clean['薪资'].mask(clean['薪资'] < 0)   # 输出: 王五薪资 → NaN
clean['薪资'] = clean['薪资'].fillna(clean['薪资'].median())   # 输出: 王五 → 9500

clean = clean.set_index('工号')                      # 先去重再设索引
clean.index.is_unique                                # 输出: True
clean.loc['E02', '薪资']                             # 输出: 12000(标量,不是 Series)
clean.reindex(['E04', 'E01', 'E09'])                 # 输出: E09 整行 NaN,其余按序重排
clean.index.get_indexer(['E01', 'E09'])              # 输出: array([0, -1])(-1 = 工号不存在)

raised = pd.Series({'E01': 8500, 'E04': 10000})
clean.loc[raised.index, '薪资'] = raised             # ✅ 按索引对齐回写,无需循环
clean.loc[:, '薪资']                                 # 输出: E01 8500 / E02 12000 / E03 9500 / E04 10000

要点:去重必须先于 set_index,否则重复索引会让 loc['E02'] 返回 DataFrame、reindex 直接报错;跨表回写用 .loc[series.index, col] = series 让 Pandas 自己做索引对齐。

数据清洗

缺失值:检测、删除、阈值 (与 §13 重叠,这里只讲清洗场景下的取舍)

调用 作用
df.isna() / df.notna() 逐元素布尔矩阵(isnull/notnull 的等价旧名)
df.isna().sum() 每列缺失计数
df.isna().mean() 每列缺失占比——决定处理策略时比计数更有用
df.dropna() 删含 NaN 的行(默认 how='any'、axis=0)
df.dropna(axis=1) 删含 NaN 的列
df.dropna(subset=[...]) 只在指定列上判定整行去留
df.dropna(thresh=n) 保留非缺失值 ≥ n 的行
df.dropna(how='all') 只删整行全空的行
Python20 行
import pandas as pd
import numpy as np

df = pd.DataFrame({
    'A': [1, 2, np.nan, 4, 5],
    'B': [np.nan, 2, 3, 4, 5],
    'C': [1, 2, 3, np.nan, 5],
    'D': [1, 2, 3, 4, 5],
})

df.isna().sum()               # 输出: A 1  B 1  C 1  D 0
df.isna().sum().sum()         # 输出: 3(全表缺失总数)
df.isna().mean().round(2)     # 输出: A 0.2  B 0.2  C 0.2  D 0.0
df.loc[df['B'].isna()]        # 输出: 第 0 行整行(挑出 B 缺失的记录)

df.dropna().shape             # 输出: (2, 4)(只剩 index 1、4)
df.dropna(axis=1).shape       # 输出: (5, 1)(只剩 D 列)
df.dropna(subset=['B']).shape # 输出: (4, 4)
df.dropna(thresh=4).shape     # 输出: (2, 4)(要求 4 列全非空)
df.dropna(how='all').shape    # 输出: (5, 4)(无全空行时原样返回)

⚠️ 先算缺失占比再定策略:>50% 一般弃列,<5% 可直接删行,中间地带才考虑填充。宽表上无脑 dropna() 极易把数据删空——它会因任意一个 NaN 就丢掉整行。

填充与插值 (另见 §13.4 / §13.5)

方式 写法 适用
标量 df.fillna(0) 缺失本身有“零值”语义
按列字典 df.fillna({'A': 0, 'B': 1}) 各列兜底值不同
前后向 df.ffill() / df.bfill() 有序数据(时间序列、分层表头)
统计量 df.fillna(df.mean(numeric_only=True)) 数值型列,分布近似对称
众数 df.fillna(df.mode().iloc[0]) 类别列
线性插值 s.interpolate() 数值随时间/序号平滑变化
时间插值 s.interpolate(method='time') 索引为 DatetimeIndex 且间隔不均
Python12 行
df['A'].ffill()                                  # 输出: [1.0, 2.0, 2.0, 4.0, 5.0]
df['B'].bfill()                                  # 输出: [2.0, 2.0, 3.0, 4.0, 5.0]
df.fillna({'A': 0, 'B': 1, 'C': 2})['C'].tolist()# 输出: [1, 2, 3, 2, 5]
df.mean(numeric_only=True)                       # 输出: A 3.0  B 3.5  C 2.75  D 3.0
df.fillna(df.mean(numeric_only=True))['A'].tolist()  # 输出: [1.0, 2.0, 3.0, 4.0, 5.0]

s = pd.Series([1, np.nan, np.nan, 4, 5])
s.interpolate()                                  # 输出: [1.0, 2.0, 3.0, 4.0, 5.0]
s.interpolate(limit=1)                           # 输出: [1.0, 2.0, NaN, 4.0, 5.0](最多连填 1 个)
ts = pd.Series([1, np.nan, np.nan, 4, 5],
               index=pd.date_range('2024-01-01', periods=5))
ts.interpolate(method='time')                    # 输出: 等距日期下与线性插值一致

⚠️ ffill() 填不了开头的 NaN(没有前值),需要先 bfill() 兜一层。Pandas 2.x 起 fillna(method='ffill') 已弃用,统一写 ffill()/bfill()。

重复值:duplicated / drop_duplicates

keep 决定保留哪一条:'first'(默认,保留首次出现)、'last'(保留最后一次)、False(全部重复项都删掉,只留真正唯一的)。

Python15 行
dup = pd.DataFrame({
    'A': [1, 2, 2, 3, 3, 3],
    'B': [10, 20, 20, 30, 30, 30],
    'C': [100, 200, 200, 300, 300, 400],
})

dup.duplicated().tolist()            # 输出: [False, False, True, False, True, False]
dup.duplicated(subset=['A']).tolist()# 输出: [False, False, True, False, True, True]
dup.duplicated(subset=['A']).sum()   # 输出: 3(按业务主键看的重复数)

dup.drop_duplicates().shape              # 输出: (4, 3)(整行完全相同才删)
dup.drop_duplicates(subset=['A','B']).shape  # 输出: (3, 3)
dup.drop_duplicates(subset=['A'], keep='last').shape   # 输出: (3, 3)(留 idx 0、2、5)
dup.drop_duplicates(subset=['A'], keep=False).shape    # 输出: (1, 3)(只留 A=1)
dup.sort_values('C', ascending=False).drop_duplicates(subset=['A'])  # 先排序再去重 = 取每键最优行

⚠️ drop_duplicates 不重置索引,结果仍是原始 index;后续用 .loc 定位或再次合并前记得 reset_index(drop=True)。业务上“去重”往往不是删全行重复,而是 subset=[主键] + keep='last'(留最新一条)。

异常值:IQR、z-score、clip

Python14 行
s = pd.Series([10, 11, 12, 13, 14, 15, 100, 1])

q1, q3 = s.quantile(0.25), s.quantile(0.75)   # 输出: 10.75  14.25
iqr = q3 - q1                                  # 输出: 3.5
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr        # 输出: 5.5  19.5
mask = ~s.between(lo, hi)
s[mask]                                        # 输出: [100, 1](顺序保持原表)

z = (s - s.mean()) / s.std()                   # z-score
(s[(z.abs() > 2)])                             # 输出: 100(同量纲下比 IQR 更钝)

s.clip(lower=lo, upper=hi).tolist()            # 输出: [10..15 不变, 19.5, 5.5](缩尾 Winsorize)
s.mask(mask).tolist()                          # 输出: 异常值置 NaN,位置保留
s.mask(mask, s.median()).tolist()              # 输出: 异常值替换为中位数 12.5

⚠️ z-score 的均值和标准差本身会被异常值拉高,小样本或偏态分布下容易漏判,工程上优先 IQR。三条处理路线取舍:删(最干净,丢样本)、缩尾 clip(保留行数,压缩极值影响)、替换为 NaN 后填充(后续可调和 §5.2 的缺失值策略)。

类型转换 (系统梳理见 §12)

Python18 行
mix = pd.DataFrame({
    'A': ['1', '2', '3', '4', '5'],
    'B': ['1.1', '2.2', '3.3', '4.4', '5.5'],
    'C': ['True', 'False', 'True', 'False', 'True'],
    'D': ['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05'],
})
mix.dtypes['A']                          # 输出: object(读自 CSV 的数字列常是 object)

mix['A'] = mix['A'].astype('int64')      # 输出: dtype('int64')
mix['B'] = mix['B'].astype('float64')    # 输出: dtype('float64')
mix['D'] = pd.to_datetime(mix['D'])      # 输出: dtype('datetime64[ns]')
mix['C'] = mix['C'].map({'True': True, 'False': False})   # map 未命中会变 NaN,比 astype(bool) 安全

bad = pd.Series(['1', '2', 'abc', '4'])
pd.to_numeric(bad, errors='coerce')      # 输出: [1.0, 2.0, NaN, 4.0]
pd.to_numeric(bad, errors='coerce').isna().sum()       # 输出: 1(顺手统计脏值条数)
pd.to_datetime(['2024-01-01', 'bad'], errors='coerce') # 输出: [2024-01-01, NaT]
mix.astype({'A': 'int32', 'B': 'float32'})             # 字典形式批量降精度省内存

⚠️ astype(bool) 的判据是“非空字符串即 True”——'False' 会变成 True,布尔列必须用 map/replace 显式映射。Pandas 2.x/3.x 里别依赖数值列的默认推断:df.convert_dtypes() 可一次性转成 nullable dtype(Int64、string),让整数列能带缺失值而不退化成 float。

字符串清洗 (系统梳理见 §15)

方法 作用
str.strip/lstrip/rstrip 去首尾/左/右空白(含 \t、\n)
str.lower/upper/title/capitalize 大小写归一
str.replace(pat, rep, regex=) 替换,正则需显式 regex=True
str.contains/extract/split/get 匹配、抽取捕获组、拆分、按下标取片段
str.len/count/pad/zfill 长度、计数、补位
str.normalize? → 用 unicodedata 全角/重音字符归一(需标准库配合)
Python11 行
raw = pd.Series(['  Alice  ', 'bob ', '  CHARLIE', 'dave  '])
raw.str.strip().str.title().tolist()     # 输出: ['Alice', 'Bob', 'Charlie', 'Dave']
raw.str.len().tolist()                   # 输出: [9, 4, 9, 6](清洗前,含空格)

phone = pd.Series(['138-0013-8000', '138 0013 8001', '(010) 8888-6666'])
phone.str.replace(r'\D', '', regex=True).tolist()   # 输出: ['13800138000', ...]

mail = pd.Series(['a@x.com', 'b@y.org', 'bad'])
mail.str.contains(r'^[\w.]+@[\w.]+\.\w+$', regex=True).tolist()  # 输出: [True, True, False]
mail.str.extract(r'@([\w.]+)')[0].tolist()          # 输出: ['x.com', 'y.org', NaN]
mail.str.split('@', expand=True)                    # 输出: 两列 0=用户名 1=域名

⚠️ Pandas 2.0 起 str.replace 默认 regex=False(按字面替换),写正则必须显式 regex=True。链式 .str.strip().str.lower() 遇到 NaN 会保持 NaN、不报错——这是它比 Python 侧循环省心的关键。

重命名列与 replace 映射

Python12 行
t = pd.DataFrame({'Order Id': [1, 2], 'Sales Amt': [10, 20], ' Region ': ['N', 'S']})
t.columns.str.strip().str.lower().str.replace(' ', '_').tolist()  # 输出: ['order_id', 'sales_amt', 'region']
t = t.set_axis(t.columns.str.strip().str.lower().str.replace(' ', '_'), axis=1)
t.rename(columns={'sales_amt': 'amount'})            # 输出: 只改指定列,返回新对象
t.rename(columns=str.upper)                          # 输出: 列名统一转大写(可接受函数)

g = pd.Series(['M', 'm', 'F', 'f', '未知'])
mp = {'M': 'Male', 'm': 'Male', 'F': 'Female', 'f': 'Female'}
g.replace(mp).tolist()        # 输出: ['Male','Male','Female','Female','未知'](未命中保留原值)
g.map(mp).tolist()            # 输出: ['Male','Male','Female','Female',NaN](未命中变 NaN)
df.replace(-999, np.nan)      # 输出: 哨兵值 -999 统一换成缺失值
df.replace({'Y': 1, 'N': 0})  # 整表生效(字符串或数值均可)

⚠️ map 与 replace 的差异只有一条但很致命:map 未命中即 NaN(数据会静默丢失),replace 未命中则原样保留。类别列补全映射表时用 map + 事后 isna().sum() 校验;只想替换已知脏值用 replace。

清洗流水线套路

固定顺序:列名归一 → 去重 → 类型/字符串归一 → 缺失处理 → 异常值处理 → 范围校验 → 重置索引。用 pipe + assign 串起来,每一步只读上一步产物,方便单独注释掉某一步复盘。

步骤 典型写法
列名归一 d.set_axis(d.columns.str.strip().str.lower().str.replace(' ', '_'), axis=1)
去重 .drop_duplicates(subset=[主键], keep='last')
值归一 .assign(col=lambda d: d['col'].str.strip().str.title())
类型 .assign(price=lambda d: pd.to_numeric(d['price'], errors='coerce'))
缺失 .dropna(subset=[...]) 或 .fillna({...})
异常 .loc[mask, col] = 边界值 / 中位数
收尾 .reset_index(drop=True)
Python12 行
def clean(df):
    return (df
        .pipe(lambda d: d.set_axis(d.columns.str.strip().str.lower(), axis=1))
        .drop_duplicates()
        .assign(price=lambda d: pd.to_numeric(d['price'], errors='coerce'),
                city=lambda d: d['city'].str.strip().str.title())
        .dropna(subset=['price'])
        .query('price > 0')          # 业务规则直接写进管道
        .reset_index(drop=True))

clean(pd.DataFrame({'Price': ['10', 'x', '-5'], 'City': [' bj ', 'sh', 'bj']}))
# 输出: 1 行 → price 10.0 / city 'Bj'

⚠️ 全程用 .loc[行掩码, 列名] = 值 赋值,不要写 df[df['A'] > 1]['B'] = 0 这种链式赋值——它可能只改到副本,静默失效。也不要用 inplace=True(Pandas 3 中已被逐步移出推荐路径),一律 df = df.xxx()。

用户表端到端清洗

一份带重复行、脏字符串、非法年龄、非数字薪资的用户表,要走完整条流水线才能入库:列名先归一、drop_duplicates 去整行重复、金额列 to_numeric 强转、非法年龄用合法行的中位数回填。

Python25 行
users = pd.DataFrame({
    'User Id': [1, 2, 3, 3, 4, 5],
    ' Name ': ['Alice', 'bob', ' CHARLIE ', ' CHARLIE ', 'Dave', None],
    'City':   ['BJ', ' sh ', 'SH', 'SH', 'gz', 'BJ'],
    'Age':    [25, 30, 200, 200, -5, 28],
    'Salary': ['5000', '8000', '9000', '9000', 'abc', '7000'],
})

clean = (users
    .pipe(lambda d: d.set_axis(d.columns.str.strip().str.lower().str.replace(' ', '_'), axis=1))
    .drop_duplicates()                                   # 输出: 6 行 → 5 行
    .assign(age=lambda d: d['age'].astype('float64'),
            salary=lambda d: pd.to_numeric(d['salary'], errors='coerce'),
            name=lambda d: d['name'].str.strip().str.title(),
            city=lambda d: d['city'].str.strip().str.upper())
    .dropna(subset=['name', 'salary']))                  # 输出: 5 行 → 3 行

clean.shape                       # 输出: (3, 5)
clean.dtypes['salary']            # 输出: float64('abc' 行已因 NaN 被丢弃)
bad_age = ~clean['age'].between(0, 120)
clean.loc[bad_age, 'age'] = clean.loc[~bad_age, 'age'].median()
clean['age'].tolist()             # 输出: [25.0, 30.0, 27.5](200 被替换为合法行中位数)
clean['city'].tolist()            # 输出: ['BJ', 'SH', 'SH']
clean.isna().sum().sum()          # 输出: 0
clean.reset_index(drop=True)      # 输出: index 归零的终表

要点:set_axis 处理列名比逐个 rename 省事;先把整型年龄列转成 float 再回填小数中位数,避免向整型列写浮点引发的隐式 dtype 变更。

销售额异常值识别与缩尾

八条销售记录里混进了一个数量级错误的金额:用 IQR 定出上下界,对比“删除 / 缩尾 / 换中位数”三种处置对本均值和记录数的影响,再决定走哪条路。

Python17 行
sales = pd.DataFrame({
    'order':  ['O1', 'O2', 'O3', 'O4', 'O5', 'O6', 'O7', 'O8'],
    'amount': [100, 110, 95, 105, 98, 102, 5000, 88],
})

q1, q3 = sales['amount'].quantile([0.25, 0.75])   # 输出: 97.25  106.25
iqr = q3 - q1                                     # 输出: 9.0
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr           # 输出: 83.75  119.75
out = ~sales['amount'].between(lo, hi)
sales.loc[out, 'order'].tolist()                  # 输出: ['O7']

sales.loc[out, 'amount']                          # 输出: 5000
sales['clip'] = sales['amount'].clip(lower=lo, upper=hi)
sales.loc[out, 'clip']                            # 输出: 119.75(缩尾后仍在表内)
sales.loc[out, 'amount'] = sales.loc[~out, 'amount'].median()
sales['amount'].mean().round(2)                   # 输出: 99.75(异常值存在时为 712.25)
sales['amount'].max()                             # 输出: 110.0(极值消失)

要点:clip 保行数、换中位数保分布中心,不要在没看分位数的情况下直接 drop——异常值有时恰恰是业务上最值得看的那条记录。

订单流水去重与最新状态

同一订单在流水表里有多条状态变更记录,且部分记录是整行重复的脏数据:先用整行去重吃掉脏数据,再按时间戳排序取每个订单的最后一条状态。

Python19 行
log = pd.DataFrame({
    'order_id': ['A1', 'A1', 'A1', 'A2', 'A2', 'A3'],
    'status':   ['paid', 'paid', 'refunded', 'paid', 'paid', 'paid'],
    'amount':   [100, 100, 100, 50, 50, 70],
    'ts': pd.to_datetime(['2024-01-01 09:00', '2024-01-01 09:00', '2024-01-01 11:00',
                          '2024-01-02 09:00', '2024-01-02 09:00', '2024-01-03 09:00']),
})

log.duplicated().sum()                    # 输出: 2(整行完全相同的脏记录)
log.duplicated(subset=['order_id']).sum() # 输出: 3(按业务主键看)

uniq = log.drop_duplicates()                                        # 输出: 6 行 → 4 行
latest = (uniq.sort_values('ts')
              .drop_duplicates(subset=['order_id'], keep='last'))   # 输出: 3 行
latest['status'].tolist()                      # 输出: ['refunded', 'paid', 'paid']
latest['order_id'].tolist()                    # 输出: ['A1', 'A2', 'A3']
uniq.groupby('order_id').size().tolist()       # 输出: [2, 1, 1](A1 保留两条状态)
uniq.pivot_table(index='order_id', columns='status',
                 values='amount', aggfunc='sum', fill_value=0)  # 输出: 状态宽表,缺省补 0

要点:“先整行去重、再按主键 keep='last'“是流水表的标准两步;sort_values 必须写在 drop_duplicates 之前,否则 keep='last' 取的是原始顺序里的最后一条,而不是时间戳最新的那条。

数据合并与连接

concat:沿轴堆叠

参数 作用
axis=0 纵向堆叠行(默认);axis=1 横向并排
join='outer' 保留并集(默认);'inner' 只保留交集
ignore_index=True 丢弃原有 index,重排成 0..n-1
keys=[...] 生成最外层 MultiIndex,标记每块来源
verify_integrity=True 结果 index 若重复则报错
Python14 行
import pandas as pd
import numpy as np

a = pd.DataFrame({'x': [1, 2], 'y': [3, 4]})
b = pd.DataFrame({'x': [5, 6], 'y': [7, 8]})

pd.concat([a, b]).shape                      # 输出: (4, 2),但 index 为 0,1,0,1
pd.concat([a, b], ignore_index=True).index   # 输出: RangeIndex(0, 4)
pd.concat([a, b], keys=['a', 'b']).loc['b']  # 输出: b 块的两行(来源可追溯)
# pd.concat([a, b], verify_integrity=True)   # ✗ ValueError:检测到重复 index

c = pd.DataFrame({'y': [9, 10], 'z': [11, 12]})
pd.concat([a, c]).columns.tolist()           # 输出: ['x', 'y', 'z'](outer:缺值补 NaN)
pd.concat([a, c], join='inner').columns.tolist()  # 输出: ['y'](只留公共列)

⚠️ axis=1 时是按索引标签对齐(outer),不是按位置硬拼:两侧 index 不同会产生大量 NaN。想按位置并排,先对两侧 reset_index(drop=True)。另外 append() 已在 Pandas 2.0 移除,追加行一律用 pd.concat([df, pd.DataFrame([新行字典])], ignore_index=True)。

merge:how 的四种连接

Python7 行
left  = pd.DataFrame({'k': ['A', 'B', 'C', 'D'], 'v1': [1, 2, 3, 4]})
right = pd.DataFrame({'k': ['B', 'D', 'E', 'F'], 'v2': [5, 6, 7, 8]})

pd.merge(left, right, on='k').shape            # 输出: (2, 3)(inner,只留 B、D)
pd.merge(left, right, on='k', how='left').shape   # 输出: (4, 3)(左表全留,E/F 无匹配)
pd.merge(left, right, on='k', how='right').shape  # 输出: (4, 3)(右表全留)
pd.merge(left, right, on='k', how='outer').shape  # 输出: (6, 3)(键并集 A..F)
how 结果行集 典型用途
inner 两侧键的交集 只分析能配上的记录
left 左表全部,配不上补 NaN 补全维表属性(最常用)
right 右表全部 以右表为主视角
outer 键的并集 做两侧差异对账
cross 笛卡尔积 生成组合全集
Python4 行
m = pd.merge(left, right, on='k', how='left')
m.loc[m['v2'].isna(), 'k'].tolist()   # 输出: ['A', 'C'](左连接后配不上的键)
pd.merge(left, right, on='k', how='outer', indicator=True)
# 输出: 多一列 _merge,取值 left_only / right_only / both

merge 的键与列冲突参数

参数 作用
on 两侧同名键;多键写 on=['k1','k2']
left_on / right_on 两侧键名不同
left_index / right_index 用某一侧的索引当键(可与 *_on 混用)
suffixes=('_x','_y') 非键的同名列自动加后缀
indicator=True / ='列名' 标出每行来源,便于对账
validate='1:1' 声明预期基数,不符立即报错
sort=False 是否按键排序结果(默认保持左表顺序更快)
Python14 行
l = pd.DataFrame({'lk': ['A', 'B', 'C'], 'v1': [1, 2, 3]})
r = pd.DataFrame({'rk': ['B', 'C', 'D'], 'v2': [4, 5, 6]})
pd.merge(l, r, left_on='lk', right_on='rk')            # 输出: 键同时以 lk/rk 两列出现
pd.merge(l, r, left_on='lk', right_on='rk').drop(columns='rk')  # 输出: 3 行 3 列,键只剩 lk

li = pd.DataFrame({'v1': [1, 2, 3]}, index=['A', 'B', 'C'])
ri = pd.DataFrame({'v2': [4, 5, 6]}, index=['B', 'C', 'D'])
pd.merge(li, ri, left_index=True, right_index=True, how='outer').shape  # 输出: (4, 2)
pd.merge(l, pd.DataFrame({'v2': [4, 5, 6]}, index=['B', 'C', 'D']),
         left_on='lk', right_index=True)      # 输出: 左表列对右表索引

pd.merge(pd.DataFrame({'k': ['A'], 'v': [1]}),
         pd.DataFrame({'k': ['A'], 'v': [2]}),
         on='k', suffixes=('_l', '_r')).columns.tolist()  # 输出: ['k', 'v_l', 'v_r']

⚠️ left_on='lk', right_on='rk' 后两列都在,别忘 drop(columns='rk'),否则下游再 merge 会因重名报错。

join:以索引为主的简写

DataFrame.join 本质是 merge 的语法糖:右侧默认用索引当键;左表要用列当键就传 on=(此时该列去匹配右表的索引)。

Python12 行
lj = pd.DataFrame({'v1': [1, 2]}, index=['A', 'B'])
rj = pd.DataFrame({'v2': [3, 4]}, index=['A', 'C'])
rj2 = pd.DataFrame({'v3': [5, 6]}, index=['A', 'B'])

lj.join(rj)                      # 输出: 左连接,C 行不进结果 → shape (2, 3)
lj.join(rj, how='outer').shape   # 输出: (3, 3)(A、B、C)
lj.join([rj, rj2])               # 输出: 一次接多张表(都按索引对齐)
lj.join(rj, lsuffix='_l', rsuffix='_r')   # 同名列冲突时手动指定后缀

df = pd.DataFrame({'key': ['A', 'B'], 'v1': [1, 2]})
pd.DataFrame({'v2': [7, 8]}, index=['A', 'B']).pipe(lambda d: df.join(d, on='key'))
# 输出: 用左表 key 列去匹配右表 index

⚠️ join 的 on 只能指定左表的列,右表永远是索引;需要“列名对列名”请用 merge。索引上有重复值时 join 会做笛卡尔积,行数悄悄膨胀。

合并后行数变化与 validate 诊断

行数暴涨的根因几乎只有两个:键重复(多对多笛卡尔积)和键类型不匹配(后者相反,表现为 0 匹配、满列 NaN)。

Python11 行
o = pd.DataFrame({'k': ['A', 'B', 'C'], 'v1': [1, 2, 3]})
p = pd.DataFrame({'k': ['A', 'A', 'B', 'D'], 'v2': [4, 5, 6, 7]})

p['k'].value_counts().pipe(lambda s: s[s > 1])    # 输出: A 2(右表存在重复键)
mg = pd.merge(o, p, on='k', how='left')
len(o), len(p), len(mg)                           # 输出: (3, 4, 4)(左连接 3 → 4 行)
mg[mg['k'] == 'A'].shape                          # 输出: (2, 3)(一行被拆成两行)

# 声明预期基数,让 Pandas 替你报错
pd.merge(o, p, on='k', validate='m:1')            # 输出: MergeError,指出右表键不唯一
pd.merge(o, p.drop_duplicates('k'), on='k', how='left', validate='m:1').shape  # 输出: (3, 3)
validate 语义
'1:1' 两侧键都唯一
'1:m' 左唯一、右可重复(左表是一的一方)
'm:1' 左可重复、右唯一(最常用:事实表配维表)
'm:m' 允许任意(默认,不校验)
Python8 行
bad_l = pd.DataFrame({'k': [1, 2], 'v1': [1, 2]})
bad_r = pd.DataFrame({'k': ['1', '2'], 'v2': [3, 4]})
try:
    pd.merge(bad_l, bad_r, on='k', how='left')
except ValueError as e:
    type(e).__name__     # 输出: ValueError(Pandas 3 直接拒绝类型不同的键)
pd.merge(bad_l, bad_r.assign(k=bad_r['k'].astype('int64')),
         on='k', how='left')['v2'].isna().sum()                 # 输出: 0(先对齐 dtype)

⚠️ Pandas 3 起键的 dtype 不一致(int64 vs object)会直接报错;在 2.x 及更早版本里则是静默零匹配、满列 NaN。合并前先对一遍 df.dtypes,并用 indicator=True 复核 left_only 的占比。

combine_first 与 update

两者都用于“用一张表补/改另一张表”,但语义完全不同。

combine_first(other) update(other)
作用 用 other 填补自身 NaN 用 other 的非 NaN 值覆盖自身
行列 取并集,会新增列 只覆盖交集,不新增行列
返回 新对象 原地修改,返回 None
Python11 行
A = pd.DataFrame({'x': [1, np.nan, 3], 'y': [np.nan, 2, 3]})
B = pd.DataFrame({'x': [10, 20, np.nan], 'z': [7, 8, 9]})

A.combine_first(B)['x'].tolist()          # 输出: [1.0, 20.0, 3.0](NaN 被 B 的对应值填上)
A.combine_first(B).columns.tolist()       # 输出: ['x', 'y', 'z'](并集,多出 z 列)

C = A.copy()
C.update(B)
C['x'].tolist()                            # 输出: [10.0, 20.0, 3.0](非 NaN 一律覆盖)
C.columns.tolist()                         # 输出: ['x', 'y'](z 列没有被加进来)
A.compare(C)                               # 输出: 逐值对比 A 与修改后的 C 差异

⚠️ update 是唯一主流仍以原地修改为准的合并类方法,务必先 copy() 再 update;它按 index/columns 标签对齐,两侧标签对不上时那部分数据会被静默忽略。

merge_asof:按最近时间匹配 (进阶用法见 §16.8)

事实表(成交)与报价表时间戳无法精确相等时,merge_asof 取“不晚于该时刻的最近一条”,是时间序列配对的专用武器。

Python11 行
q = pd.DataFrame({'t': pd.to_datetime(['09:00', '09:05', '09:10'], format='%H:%M'), 'price': [100, 102, 98]})
tr = pd.DataFrame({'t': pd.to_datetime(['09:03', '09:08'], format='%H:%M'), 'vol': [50, 100]})

pd.merge_asof(tr.sort_values('t'), q.sort_values('t'), on='t')
# 输出: 09:03 → price 100;09:08 → price 102(默认 direction='backward')
pd.merge_asof(tr, q, on='t', direction='forward')     # 输出: 取不早于该时刻的最近一条
pd.merge_asof(tr, q, on='t', direction='nearest')     # 输出: 取时间距离最近的一条
pd.merge_asof(tr, q, on='t', tolerance=pd.Timedelta('2min'))  # 输出: 超窗置 NaN
q2, tr2 = q.assign(symbol=['X', 'X', 'Y']), tr.assign(symbol=['X', 'Y'])
pd.merge_asof(tr2.sort_values('t'), q2.sort_values('t'), on='t', by='symbol')
# 输出: 先按 symbol 分组再各自就近匹配

⚠️ merge_asof 要求两侧按键升序排好(先用 sort_values),否则直接报错;它也不支持 validate,配完请自行核对行数是否与左表一致。

订单-客户-产品三表拼宽

典型的星型模型:订单事实表分别关联客户维表和产品维表补齐维度,再计算金额并按客户汇总。关键是用 validate='m:1' 断言两张维表的键唯一。

Python27 行
orders = pd.DataFrame({
    'order_id': [1, 2, 3, 4, 5],
    'customer_id': [101, 102, 103, 101, 104],
    'product_id': [201, 202, 201, 203, 202],
    'qty': [2, 1, 3, 1, 2],
})
customers = pd.DataFrame({
    'customer_id': [101, 102, 103, 104, 105],
    'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
    'city': ['BJ', 'SH', 'GZ', 'SZ', 'HZ'],
}).drop_duplicates('customer_id')
products = pd.DataFrame({
    'product_id': [201, 202, 203, 204],
    'pname': ['Laptop', 'Mouse', 'Keyboard', 'Monitor'],
    'price': [5000, 50, 200, 1500],
})

step1 = pd.merge(orders, customers, on='customer_id', how='left', validate='m:1')
full = pd.merge(step1, products, on='product_id', how='left', validate='m:1')
full.shape                       # 输出: (5, 8)(行数没变,说明两张维表键都唯一)
full['amount'] = full['qty'] * full['price']
full.loc[full['price'].isna()]   # 输出: 空——所有 product_id 都能配上

by_cust = (full.groupby('name', as_index=False)
               .agg(orders=('order_id', 'size'), gmv=('amount', 'sum')))
by_cust.sort_values('gmv', ascending=False)['name'].tolist()  # 输出: ['Charlie', 'Alice', 'David', 'Bob']
full.groupby('city', as_index=False)['amount'].sum().shape    # 输出: (4, 2)(四个城市均有成交)

要点:维表 join 写 how='left' + validate='m:1',行数一旦变化立刻报错;how='inner' 会静默吞掉配不上的订单,做宽表时是数据丢失的常见来源。

合并后行数爆炸排查

右表主键不唯一时左连接会把左表行数放大。现场排查四步:比对行数 → 查重复键 → indicator 看来源分布 → 用 validate 固化结论。

Python21 行
orders = pd.DataFrame({'order_id': [1, 2, 3, 4], 'sku': ['A', 'B', 'C', 'D'], 'qty': [1, 2, 3, 4]})
prods = pd.DataFrame({'sku': ['A', 'A', 'B', 'C'],
                      'price': [10, 12, 20, 30],
                      'valid_from': ['2024-01-01', '2024-06-01', '2024-01-01', '2024-01-01']})

mg = pd.merge(orders, prods, on='sku', how='left')
len(orders), len(prods), len(mg)                 # 输出: (4, 4, 5)(订单被放大成 5 行)
prods['sku'].value_counts().loc[lambda s: s > 1] # 输出: A 2(价格有历史版本)
mg[mg['sku'] == 'A']                             # 输出: 两行差价记录,金额口径会重复计算

try:
    pd.merge(orders, prods, on='sku', how='left', validate='m:1')
except Exception as e:
    type(e).__name__                             # 输出: MergeError(第一时间暴露问题)

# 方案一:只取有效最新价
latest = prods.sort_values('valid_from').drop_duplicates('sku', keep='last')
pd.merge(orders, latest, on='sku', how='left', validate='m:1').shape   # 输出: (4, 5)
# 方案二:保留历史,但显式标注 —— 用 indicator 复核来源
pd.merge(orders, prods, on='sku', how='left', indicator=True)['_merge'].value_counts().to_dict()
# 输出: {'both': 5, 'left_only': 0}

要点:先把“一的一方”压成唯一(sort_values + drop_duplicates(keep='last'))再合并,比事后去重可靠;validate 是把这条规则写进代码的唯一方式。

多源指标的对齐与补全

两张来自不同系统的指标快照:一份列更全、一份更新更及时。用 concat 纵向堆叠不同批次,用 combine_first 横向补全字段,最后 groupby 去重聚合。

Python19 行
snap_jan = pd.DataFrame({'city': ['BJ', 'SH'], 'gmv': [100, 200], 'uv': [10, 20]})
snap_feb = pd.DataFrame({'city': ['BJ', 'SH'], 'gmv': [110, 190], 'uv': [12, np.nan]})
extra = pd.DataFrame({'city': ['BJ', 'SH'], 'region': ['North', 'East']})

all_snap = pd.concat([snap_jan.assign(month='01'), snap_feb.assign(month='02')],
                     ignore_index=True)
all_snap.shape                        # 输出: (4, 4),来源由 month 列标记
all_snap.groupby('city', as_index=False)['gmv'].sum()   # 输出: BJ 210, SH 390

feb = all_snap[all_snap['month'] == '02'].reset_index(drop=True)
merged = feb.combine_first(extra)     # 输出: 补出 region 列
merged.columns.tolist()               # 输出: ['city', 'gmv', 'uv', 'month', 'region']
merged['uv'].isna().sum()             # 输出: 1(extra 没有 uv,NaN 不会被填)

filled = merged.copy()
filled.update(snap_jan[['uv']])   # 行索引标签对齐后整列覆盖
filled['uv'].tolist()             # 输出: [10.0, 20.0](非 NaN 也被改写,与"只补缺"形成对比)
pd.concat([snap_jan.set_index('city'), extra.set_index('city')], axis=1)
# 输出: 按 city 标签横向对齐(额外多列)

要点:concat(keys=...) 或加一列来源标记,是保留血缘最省事的做法;combine_first 只补不看,横向合并后再用 groupby 收敛粒度,比层层 join 更好回溯。

分组与聚合

split-apply-combine 心智模型

groupby 的全部操作都可拆成三步:split(按键把行切成互不相交的组)→ apply(对每组独立施加函数)→ combine(把每组结果按一定形状拼回来)。记住这三步,就能预判任何 groupby 调用返回的形状。

阶段 做了什么 产物
split df.groupby('k') 得到 GroupBy 对象 惰性,不真正计算
apply .agg/.transform/.apply/.sum() 触发计算 每组一个结果
combine 按结果类型拼接 聚合表 / 等长列 / 任意形状(对应 §7.5 三个方法)
Python16 行
import pandas as pd
import numpy as np

d = pd.DataFrame({
    'city':  ['BJ', 'SH', 'BJ', 'SH', 'BJ'],
    'cat':   ['A', 'A', 'B', 'B', 'A'],
    'amount':[100, 200, 150, 50, 90],
    'qty':   [1, 2, 3, 1, 2],
})

g = d.groupby('city')            # 惰性:不触发计算
g.ngroups                        # 输出: 2
g.size()                         # 输出: BJ 3  SH 2
g.get_group('BJ')                # 输出: city 全为 BJ 的 3 行子表(按组名取子表)
for name, sub in g:              # 迭代 → (组名, 该组子 DataFrame)
    print(name, len(sub))        # 输出: BJ 3 / SH 2

⚠️ 迭代 for name, sub in g 会按组逐次触发 Python 层切片,组数上千时非常慢——能用 .agg/.transform 表达就别手写循环。

groupby 的参数

参数 作用
by 分组键:列名、列名列表、Series、数组、函数、字典
level 按索引层级分组(多层索引时替代 by)
as_index=False 分组键不作为 index,直接变普通列
dropna=False 键为 NaN 的行保留独立一组(默认 True 丢弃)
observed 分组键是 categorical 时,是否只显示出现过的类别
sort=True 结果按键排序(设为 False 更快,且保持首次出现顺序)
Python12 行
d.groupby(['city', 'cat'])['amount'].sum()          # 多键 → 结果二级索引
d.groupby(['city', 'cat'], as_index=False)['amount'].sum().shape   # 输出: (4, 3)
d.groupby(level=0)['amount'].sum()                  # 按行索引第一层分组

na = pd.DataFrame({'k': ['a', None, 'a'], 'v': [1, 2, 3]})
na.groupby('k')['v'].sum()                          # 输出: 只有 a → 4(NaN 组被丢)
na.groupby('k', dropna=False)['v'].sum()            # 输出: a 4,NaN 组单独一组 2

sd = pd.DataFrame({'c': pd.Categorical(['x', 'y'], categories=['x', 'y', 'z']), 'v': [1, 2]})
sd.groupby('c', observed=True)['v'].sum()           # 输出: 2 组(x、y)
sd.groupby('c', observed=False)['v'].sum()          # 输出: 3 组(含空的 z,补 NaN)
d.groupby(d['amount'] > 100)['qty'].sum()           # 布尔 Series 当键 → True/False 两组

⚠️ observed 的默认值在 2.x/3.x 之间发生过变更,用 categorical 分组时必须显式写出 observed=,否则同一份代码在不同版本下返回行数不同。

常用聚合函数与 size/count/nunique

Python12 行
t = pd.DataFrame({
    'team': ['A', 'A', 'A', 'B', 'B'],
    'pt':   [10, np.nan, 30, 40, 40],
    'usr':  ['u1', 'u1', 'u2', 'u3', 'u3'],
})
g = t.groupby('team')
g.size()                # 输出: A 3  B 2(行数,含 NaN,返回 Series)
g['pt'].count()         # 输出: A 2  B 2(非缺失值个数)
g['usr'].nunique()      # 输出: A 2  B 1(去重计数)
g['pt'].mean()          # 输出: A 20.0  B 40.0(分母用 count,跳过 NaN)
g['pt'].agg(['sum', 'mean', 'min', 'max', 'std', 'median'])   # 输出: 6 列聚合表
g.describe()            # 输出: 每组的 count/mean/std/min/25%/50%/75%/max
函数 语义 备注
size() 组行数 含 NaN,可用于整个 DataFrame
count() 非缺失值个数 逐列,NaN 不计数
nunique() 去重后个数 不含 NaN(dropna=True)
sum/mean/median 跳过 NaN min_count=1 可让全 NaN 组结果仍为 NaN
min/max/idxmax/idxmin 极值及其所在行标签 idxmax 常用于定位“每组最优行”
first/last/nth() 组内第 n 条 需先排好序
std/var/sem 离散度 默认 ddof=1
head(n)/tail(n) 每组首/末 n 行 配合 sort_values 使用

⚠️ size() 与 count() 在含缺失列上会给出不同数字,报表口径务必写清;要“每个组有几笔订单”用 size(),要“这个字段填了几个”用 count()。

agg:单函数、多函数、命名聚合

Python18 行
agg1 = d.groupby('city')['amount'].agg('sum')          # 单函数,结果 Series
agg2 = d.groupby('city')['amount'].agg(['sum', 'mean'])# 多函数 → 二级列索引
agg3 = d.groupby('city').agg({'amount': ['sum', 'mean'], 'qty': 'max'})  # 逐列指定
agg3.columns.tolist()   # 输出: [('amount','sum'), ('amount','mean'), ('qty','max')]

# 命名聚合:一步到位产出扁平列名(推荐)
summary = d.groupby('city').agg(
    orders=('amount', 'size'), gmv=('amount', 'sum'),
    aov=('amount', 'mean'), max_qty=('qty', 'max'),
).round(2)
summary.columns.tolist()       # 输出: ['orders', 'gmv', 'aov', 'max_qty'](无二级索引)

# 中文/带空格列名必须用 pd.NamedAgg
d.groupby('city').agg(**{
    '订单数': pd.NamedAgg(column='amount', aggfunc='size'),
    '销售额': pd.NamedAgg(column='amount', aggfunc='sum'),
})
d.groupby('city')['amount'].agg(lambda s: s.max() - s.min())   # 自定义 lambda

⚠️ 字典形式 agg({...}) 与列表形式会产出 MultiIndex 列,下游取列要写 df[('amount','sum')]——优先用命名聚合,能直接得到扁平列,省掉一次 columns 拍平。

agg / transform / apply 三者的区别

这是 groupby 最容易混淆的一组 API,判断标准只有一条:看返回结果的形状。

方法 返回形状 可否广播回原表 典型用途 速度
agg 每组一个标量(行数 = 组数) 否 汇总报表 快(走 C 实现)
transform 与原表等长 是 新增组内特征列 快
apply 任意(标量 / Series / DataFrame) 视情况 无法向量化的复杂逻辑 慢(逐组 Python)
Python6 行
a = d.groupby('city')['amount'].agg('sum')        # 输出: BJ 340  SH 250(2 行)
d['city_sum'] = d.groupby('city')['amount'].transform('sum')   # 输出: 5 行,值按组广播
d['z'] = d.groupby('city')['amount'].transform(lambda x: (x - x.mean()) / x.std())
ret = d.groupby('city').apply(lambda g: g.nlargest(1, 'amount'))  # 输出: 每组取金额最高的一行
d.groupby('city')['qty'].cumsum()                 # 组内累计和(cum 家族天然是 transform 语义)
d.groupby('city').cumcount()                      # 组内自增序号 0,1,2...

⚠️ agg 的函数必须把一组压成一个标量;返回 Series 的函数要交给 apply。反过来,transform 传入的函数返回标量时会被广播到整组,返回等长 Series 时按 index 对齐——两者长度不符会直接报错。

groupby.apply 的性能坑

apply 会对每个组做一次完整的 Python 函数调用和结果拼装,组数从 10 涨到 10 万时耗时线性爆炸。

Python10 行
big = pd.DataFrame({
    'k': np.random.default_rng(0).choice(np.arange(2000), 20_000),
    'v': np.random.default_rng(1).normal(size=20_000),
})

slow = big.groupby('k')['v'].apply(lambda s: s - s.mean())     # 慢:逐组 Python 调用
fast = big['v'] - big.groupby('k')['v'].transform('mean')      # 快:等价写法,全程向量化
np.allclose(slow.sort_index().to_numpy(), fast.sort_index().to_numpy())   # 输出: True
big['z'] = big.groupby('k')['v'].transform(lambda x: (x - x.mean()) / x.std())
big.sort_values('v', ascending=False).groupby('k').head(3).shape  # 输出: TopN 也快(行数 ≤ 6000)

⚠️ Pandas 2.2+ 起 groupby.apply 默认不再把分组列当作可操作列传入(旧行为会给出 FutureWarning)。自定义函数里不要依赖 g['k'],需要分组值就把 k 也放进聚合或在外部 set_index。能用内置聚合名字符串('sum'/'mean'/'size')就别写 lambda——字符串走的是高度优化的 C 路径。

组内取值与组过滤

filter 的输入是“整组子表”,返回 True 的组整组保留,行数会变但列不变——注意它和布尔掩码过滤完全不同。rank/cum*/head 等返回的则是组内相对位置或等长列。

Python16 行
s = pd.DataFrame({'team': ['A', 'A', 'A', 'B', 'B', 'B'], 'pt': [3, 1, 2, 6, 5, 4]})
s['rank'] = s.groupby('team')['pt'].rank(ascending=False)      # 输出: 组内降序排名
s['cum'] = s.groupby('team')['pt'].cumsum()                    # 输出: 组内累计和
s.loc[s.groupby('team')['pt'].idxmax()]                        # 输出: 用 idxmax 回捞每组最大行
s.sort_values('pt', ascending=False).groupby('team').head(1)   # 输出: 每组 Top1
s.groupby('team')['pt'].nlargest(2)      # 输出: 每组前 2 大(MultiIndex)

f = pd.DataFrame({'g': ['A', 'A', 'A', 'B', 'B', 'C'], 'v': [1, 2, 3, 4, 5, 6]})
f.groupby('g').filter(lambda x: len(x) >= 3)['g'].unique()       # 输出: ['A'](按组大小过滤)
f.groupby('g').filter(lambda x: x['v'].mean() > 3)['g'].unique() # 输出: ['B', 'C']
gg = f.groupby('g')
gg.get_group('A')                       # 输出: A 组子表
gg.head(2)                       # 输出: 组内前 2 条
gg.tail(1)                       # 输出: 组内后 1 条
gg.nth([0, -1])                  # 输出: 每组首条与末条
gg.indices                              # 输出: dict of 组名 → 位置数组(比 groups 快)

⚠️ filter 的条件函数收到的是 DataFrame(即使只选中了单列),必须写 x['v'].mean() 而非 x.mean();它对每组调用一次 Python 函数,组数多时是热点——能先用 groupby.agg() 算出组级指标再筛选就别逐组跑。

结果整形:排序、拍平、重置索引

Python7 行
res = d.groupby(['city', 'cat'])['amount'].sum()
res.reset_index(name='total')              # 输出: 把值列起个名字
d.groupby(['city', 'cat'], as_index=False)['amount'].sum()   # 等价于上面一行
res.sort_values(ascending=False).head(2)   # 输出: 按聚合值降序 TOP2
res.sort_index(level='city')               # 输出: 按索引某一层排序
res.unstack(fill_value=0)                  # 输出: 把内层 cat 摊到列上(详见第 8 章)
res.to_frame('amount').reset_index().sort_values('amount', ascending=False)  # 报表收尾套路

⚠️ as_index=False 只对 by 是列时有效;用 Series 或函数当键时得手动 reset_index()。多层索引出来后第一时间决定要不要拍平,否则后续 merge 会因索引层级对不上而失败。

分箱后分组:pd.cut / pd.qcut

把连续变量离散成分箱标签,再作为 groupby 的键,是“连续量的分段统计”标准做法。cut 按值区间等宽,qcut 按样本数等频。

Python11 行
age = pd.Series([18, 22, 25, 27, 30, 35, 40, 45, 50, 55, 60, 65])
pd.cut(age, 4).value_counts().sort_index()      # 输出: 4 个等宽区间的人数
pd.qcut(age, 3, labels=['low', 'mid', 'high']).value_counts()   # 输出: 每段各 4 人

bill = pd.DataFrame({'age': age, 'amount': np.arange(12) * 10 + 30})
grp = pd.cut(bill['age'], [0, 25, 40, 60, 100], labels=['<25', '25-40', '40-60', '60+'])
(bill.assign(seg=grp)
     .groupby('seg', observed=False)          # 显式 observed:空箱也要出现在报表里
     .agg(n=('amount', 'size'), avg=('amount', 'mean')))
# 输出: 4 行;60+ 段 n=2, avg=180.0
bill['age'].pipe(lambda s: pd.qcut(s, q=4, duplicates='drop')).value_counts()  # 输出: 四分位人数

⚠️ 分组键是 Categorical 时务必显式 observed=,空区间要么保留(报表口径)要么丢掉(分析口径),别交给默认值。此外 cut 得到的区间是左开右闭(默认 right=True),边界值归属要核对;include_lowest=True 可把最小值纳入第一段。

时间维度分组:pd.Grouper (时间序列基础见 §9)

索引或某列是 datetime 时,pd.Grouper(key=, freq=) 把时间切成规整桶再聚合。freq 用现代别名:D 日、W 周、ME 月末、MS 月初、QE 季末、YE 年末、h 小时、min 分钟。

Python10 行
idx = pd.date_range('2024-01-01', periods=90, freq='D')
ts = pd.DataFrame({'date': idx,
                   'city': np.tile(['BJ', 'SH'], 45),
                   'amount': np.arange(90) * 3 + 10})

ts.groupby(pd.Grouper(key='date', freq='ME'))['amount'].sum()          # 输出: 3 个月各一列
ts.groupby(pd.Grouper(key='date', freq='W'))['amount'].agg(['sum', 'mean']).shape  # 输出: (14, 2)
(ts.groupby([pd.Grouper(key='date', freq='QE'), 'city'])['amount']
   .sum().unstack(fill_value=0))          # 输出: 季度 × 城市的宽表
ts.set_index('date').groupby(pd.Grouper(freq='MS'))['amount'].size()   # 输出: 每月记录数

分组报表与“每组最优行”

一份多城市多品类的成交明细,需要产出:城市维度汇总(命名聚合)、每行的组内占比(transform)、以及每个城市销售额最高的那条记录(排序 + head)。

Python19 行
sales = pd.DataFrame({
    'city':   ['BJ', 'BJ', 'BJ', 'SH', 'SH', 'GZ'],
    'cat':    ['A', 'B', 'A', 'B', 'B', 'A'],
    'amount': [100, 200, 150, 80, 120, 300],
    'qty':    [1, 2, 3, 1, 2, 4],
})

report = (sales.groupby('city')
               .agg(orders=('amount', 'size'), gmv=('amount', 'sum'),
                    aov=('amount', 'mean'), cats=('cat', 'nunique'))
               .round(1).sort_values('gmv', ascending=False))
report.index.tolist()              # 输出: ['BJ', 'GZ', 'SH'](按 GMV 降序)
report.loc['BJ', 'gmv']            # 输出: 450.0
sales['city_share'] = (sales['amount'] / sales.groupby('city')['amount'].transform('sum')).round(3)
sales.loc[sales['city'] == 'BJ', 'city_share'].tolist()   # 输出: [0.222, 0.444, 0.333]
sales.groupby('city')['amount'].transform('rank', ascending=False)  # 输出: 组内排名等长列
top = sales.sort_values('amount', ascending=False).groupby('city').head(1)
top[['city', 'cat', 'amount']].values.tolist()  # 输出: [['GZ','A',300], ['BJ','B',200], ['SH','B',120]]
sales.loc[sales.groupby('city')['amount'].idxmax(), 'amount'].tolist()  # 输出: [200, 300, 120]

要点:汇总用命名聚合一步拿到扁平列;“每行占组内比例”是 transform 的招牌场景;取“每组最优行”优先 sort_values + groupby.head(1),比 apply(nlargest) 更快也更易读。

分箱后做多维交叉统计

把金额分段后与城市交叉统计,是 RFM/客单价分层分析的常见形态。要点是 cut 得到 Categorical 后用 observed=False 保留空档,unstack(fill_value=0) 直接出宽表。

Python16 行
rng = np.random.default_rng(7)
orders = pd.DataFrame({
    'city':   rng.choice(['BJ', 'SH', 'GZ'], 20),
    'amount': rng.integers(20, 500, 20),
    'qty':    rng.integers(1, 5, 20),
})
orders['level'] = pd.cut(orders['amount'], bins=[0, 100, 300, np.inf],
                         labels=['low', 'mid', 'high'])
cross = (orders.groupby(['city', 'level'], observed=False)
               .agg(n=('amount', 'size'), gmv=('amount', 'sum')).reset_index())
cross.head(3)                              # 输出: BJ × low/mid/high 三档(含空档 n=0)
pivot = orders.pivot_table(index='city', columns='level', values='amount',
                           aggfunc='sum', observed=False, fill_value=0)
pivot                                      # 输出: 城市 × 金额档位的销售矩阵
orders.groupby('level', observed=True)['qty'].agg(['count', 'mean']).round(2)  # 输出: 每档件数/均值
orders.groupby(pd.qcut(orders['amount'], 2))['qty'].mean()  # 输出: 按金额中位数二分后的平均件数

要点:pd.cut + groupby(observed=False) + unstack(fill_value=0) 是分层交叉表的固定三件套;等频用 qcut,但注意重复值多的列要加 duplicates='drop'。

组过滤与异常组剔除

分析前常要剔除“样本太少”或“量级异常”的组:filter 保留满足条件的整组,再用 transform 给每行打上组标签以便回溯。

Python15 行
tk = pd.DataFrame({
    'group': ['g1'] * 5 + ['g2'] * 2 + ['g3'] * 4,
    'value': [10, 12, 11, 13, 9, 100, 110, 20, 21, 19, 22],
})

keep = tk.groupby('group').filter(lambda x: len(x) >= 3)
keep['group'].unique().tolist()                # 输出: ['g1', 'g3'](g2 样本不足被剔除)

g = keep.groupby('group')['value']
stats = keep.assign(mean=g.transform('mean'), std=g.transform('std'))
stats.loc[stats['group'] == 'g1', 'mean'].round(2).tolist()   # 输出: [11.0] × 5(广播值)
z = ((stats['value'] - stats['mean']) / stats['std']).abs()
keep.assign(is_outlier=z > 1.2).query('is_outlier')           # 输出: g1 中偏离 >1.2σ 的行(13 与 9)
keep[z <= 2].groupby('group')['value'].agg(['size', 'mean']).round(2)  # 输出: 剔除后各组行数与均值
tk.groupby('group').size().loc[lambda s: s < 3].index.tolist()  # 输出: ['g2'](先列出会被剔除的组)

要点:filter 里只能访问“整组”,行数会因整组剔除而减少;若要逐行剔除,改用 transform 把组统计量广播回每一行再做布尔过滤——两种粒度的清洗要分清。

重塑与透视

宽格式 vs 长格式

同一份数据有两种摆放方式,选哪种取决于下游要做什么:宽表每行一个主体、每种度量占一列(适合给人看、适合做机器学习特征);长表每行一个观测(主体 × 维度组合),适合 groupby、seaborn 绘图、以及数据库存储。

宽格式(wide) 长格式(long / tidy)
行含义 一个主体一行 一个“主体×维度”观测一行
列名 含数据(如 Math/English) 固定的字段名(如 Subject/Score)
扩展新类别 要加列 加行即可
适合 报表、特征矩阵 分组聚合、画图、存库
Python14 行
import pandas as pd
import numpy as np

wide = pd.DataFrame({
    'Name': ['Alice', 'Bob', 'Charlie'],
    'Math': [90, 85, 88], 'English': [88, 92, 85], 'Science': [92, 87, 90],
})
long = pd.DataFrame({
    'Name': ['Alice'] * 3 + ['Bob'] * 3 + ['Charlie'] * 3,
    'Subject': ['Math', 'English', 'Science'] * 3,
    'Score': [90, 88, 92, 85, 92, 87, 88, 85, 90],
})
wide.shape      # 输出: (3, 4)
long.shape      # 输出: (9, 3)

melt:宽转长

id_vars 是保持不变的标识列,其余列被“融化”成两列:原来的列名进 var_name,值进 value_name。

参数 作用
id_vars 保留为标识的列(不被融化)
value_vars 指定要融化的列(默认 = 除 id_vars 外全部)
var_name 新“变量列”的名字(默认 variable)
value_name 新“值列”的名字(默认 value)
ignore_index=True 结果重排索引(默认 True)
Python10 行
m = wide.melt(id_vars='Name', var_name='Subject', value_name='Score')
m.shape                          # 输出: (9, 3)
m.head(3)['Subject'].tolist()    # 输出: ['Math', 'Math', 'Math'](melt 按列优先展开)
# 只融化部分列,其余保持宽列
g = pd.DataFrame({'Class': ['A', 'B'], 'Name': ['Alice', 'Bob'],
                  'Mid': [85, 90], 'Final': [88, 92], 'Age': [20, 21]})
g.melt(id_vars=['Class', 'Name'], value_vars=['Mid', 'Final'],
       var_name='Exam', value_name='Score').shape   # 输出: (4, 4)(Age 被丢掉)
g.melt(id_vars=['Class', 'Name', 'Age'], value_vars=['Mid', 'Final'],
       var_name='Exam', value_name='Score').shape   # 输出: (4, 5)(Age 作为标识保留)

⚠️ 被融化的列必须是同一量纲(同一单位、同一类型)。把“年龄”和“分数”融成一列会得到无法解释的值;混合 dtype 时结果列会被强制提升成 object。

pivot:长转宽,及与 melt 的互逆关系

long.pivot(index=标识, columns=维度, values=值):把某一列的取值摊成若干新列。

Python7 行
back = long.pivot(index='Name', columns='Subject', values='Score')
back.shape                    # 输出: (3, 3)
back.columns.tolist()         # 输出: ['English', 'Math', 'Science'](按字典序排)
back.loc['Alice', 'Math']     # 输出: 90
back.index.name, back.columns.name   # 输出: ('Name', 'Subject')——列索引还挂着名字

long.pivot(index='Name', columns='Subject', values='Score').rename_axis(columns=None)  # 去掉 columns.name

melt 与 pivot 互为逆操作:melt(id_vars=X, var_name=V, value_name=C) 之后再 pivot(index=X, columns=V, values=C),结果与原表只差列顺序和 index 是否还原。前提是:

  1. (X, V) 的组合唯一——有重复就必须在中间用 pivot_table 加聚合(§8.4);
  2. 融化的列是同一量纲;
  3. melt 时放进 id_vars 的列,正是 pivot 里要放回 index 的列。
Python5 行
dupkey = pd.DataFrame({'Name': ['Alice', 'Alice', 'Bob'],
                       'Subject': ['Math', 'Math', 'Math'],
                       'Score': [90, 92, 85]})
# dupkey.pivot(index='Name', columns='Subject', values='Score')   # ValueError: 索引组合重复
dupkey.pivot_table(index='Name', columns='Subject', values='Score', aggfunc='mean')  # 输出: Alice 91.0

⚠️ pivot 遇到重复的 (index, columns) 组合会直接报错而不是取第一个——这条“报错”其实是好事,它在提醒你数据里有重复测量,需要显式决定聚合方式。

pivot_table:带聚合的透视

参数 作用
values 要聚合的列(不写则对所有数值列聚合)
index / columns 行维度 / 列维度,均可为列表(多级)
aggfunc 默认 'mean';可 'sum'、['sum','mean']、{'col': 'sum'}
fill_value 缺失组合的填充值(注意:先聚合后填充)
margins=True 加“总计”行列,margins_name 改名字
dropna=True 丢弃结果全为 NaN 的列
observed 维度是 categorical 时是否只统计出现过的类别
Python18 行
sales = pd.DataFrame({
    'Region': ['N', 'N', 'N', 'N', 'S', 'S', 'S', 'S'],
    'Product': ['A', 'B', 'A', 'B', 'A', 'B', 'A', 'B'],
    'Sales': [100, 150, 120, 130, 90, 110, 100, 120],
    'Qty': [10, 15, 12, 13, 9, 11, 10, 12],
})

pv = sales.pivot_table(index='Region', columns='Product', values='Sales', aggfunc='sum')
pv.loc['N', 'A']                 # 输出: 220(两条 N×A 记录求和)
pv.shape                         # 输出: (2, 2)
sales.pivot_table(index='Region', columns='Product', values=['Sales', 'Qty'], aggfunc='sum').shape  # 输出: (2, 4)
sales.pivot_table(index='Region', columns='Product', values='Sales',
                  aggfunc=['sum', 'mean']).shape                       # 输出: (2, 4)(二级列索引)
sales.pivot_table(index='Region', columns='Product', values='Sales',
                  aggfunc='sum', margins=True, margins_name='Total')   # 输出: 多一行一列总计
sales.pivot_table(index='Region', columns='Product', values='Sales',
                  aggfunc='sum', fill_value=0)                         # 输出: 空组合补 0
sales.pivot_table(index='Region', columns='Product', values='Sales', aggfunc='size')  # 输出: 每个格子计数

⚠️ 默认 aggfunc='mean'——当你以为在做求和透视时,Pandas 会静默给你均值,务必显式写 aggfunc=。另外 fill_value 是聚合之后填的,它不会改变均值本身,别指望它来“补全样本”。

stack / unstack:旋转索引层级

stack 把列压成行的最内层索引(宽→长,得到 Series 或 DataFrame);unstack 把某层行索引抬成列(长→宽)。两者互逆,是多级索引版本的 melt/pivot。

Python14 行
mi = pd.DataFrame({'v1': [1, 2, 3, 4], 'v2': [5, 6, 7, 8]},
                  index=pd.MultiIndex.from_arrays([['A', 'A', 'B', 'B'], ['X', 'Y', 'X', 'Y']],
                                                 names=['g', 'sg']))
st = mi.stack()
type(st).__name__                 # 输出: Series
st.index.names                    # 输出: ['g', 'sg', None](原列名压成了第三层)
st.unstack().equals(mi)           # 输出: True(unstack 默认还原最内层)
st.unstack(level=0).columns.tolist()  # 输出: ['A', 'B'](把 g 层抬成列)
st.unstack(level='sg').columns.tolist()  # 输出: ['X', 'Y']

d2 = pd.DataFrame({'A': [1, 2, np.nan], 'B': [4, np.nan, 6]}, index=['x', 'y', 'z'])
d2.stack().tolist()               # 输出: [1.0, 4.0, 2.0, NaN, NaN, 6.0](Pandas 3 保留 NaN)
d2.stack().reset_index().shape    # 输出: (6, 3)(stack 后转长表,空缺仍是 NaN)
mi.unstack(fill_value=0)          # 输出: 抬列产生的空缺补 0

⚠️ unstack 只能操作索引层级;想把普通列变成行请用 melt 或先 set_index 再 stack。Pandas 3 起 stack 走新实现,默认保留 NaN 且不再接受 dropna= 参数,想要丢空缺请自己 .dropna()。

wide_to_long:带后缀的列名转长

处理 score_t1/score_t2/score_t3 这类“同名前缀 + 编号后缀”的列:指定 stubnames 和分隔符,一次性抽出后缀当新列。

Python13 行
w = pd.DataFrame({
    'id': [1, 2, 3],
    'name': ['Alice', 'Bob', 'Charlie'],
    'score_t1': [85, 90, 88],
    'score_t2': [88, 92, 90],
    'score_t3': [90, 93, 92],
})
tl = pd.wide_to_long(w, stubnames='score', i=['id', 'name'], j='time', sep='_t')
tl.shape                       # 输出: (9, 1)
tl.reset_index()['time'].tolist()          # 输出: [1, 2, 3, 1, 2, 3, 1, 2, 3](按主体优先排列)
tl.loc[(1, 'Alice')]           # 输出: 该主体在三个时间点的分数值
pd.wide_to_long(w, stubnames='score', i='id', j='time',
                sep='_t', suffix=r'\d+').head(2)   # 输出: 显式指定后缀正则(默认就是 \d+)

⚠️ i 指定的列必须能唯一标识一行主体,否则结果会报 “the id variables need to uniquely identify each row”。多个 stub(如同时有 score_t1 和 rank_t1)写成 stubnames=['score', 'rank'],一次抽出多值列。

explode:把列表列炸成多行

一列里装 list/array 的“嵌套”数据,explode 把每个元素拆成独立行,其余列自动复制。

Python9 行
e = pd.DataFrame({'id': [1, 2, 3],
                  'tags': [['a', 'b'], ['c'], []],
                  'score': [10, 20, 30]})
ex = e.explode('tags')
ex.shape                       # 输出: (4, 3)——空列表给出一行 NaN,不会被跳过
ex['id'].tolist()              # 输出: [1, 1, 2, 3](其余列广播复制)
e.explode('tags', ignore_index=True).index.tolist()   # 输出: [0, 1, 2, 3]

e.explode('tags').dropna(subset=['tags'])   # 输出: 丢掉空列表产生的 NaN 行

⚠️ explode 多列时要求这些列的列表长度两两相等,长度不一致会直接报错;单列炸完后行数变了,别忘了 ignore_index=True 或事后 reset_index(drop=True),否则索引里会有重复标签。

get_dummies:类别转哑变量

Python9 行
cd = pd.DataFrame({'color': ['red', 'blue', 'red'], 'size': ['S', 'M', 'S']})
dm = pd.get_dummies(cd)
dm.dtypes.unique()             # 输出: [dtype('bool')](Pandas 2.x 起默认是 bool 而非 int)
dm.columns.tolist()            # 输出: ['color_blue', 'color_red', 'size_M', 'size_S']

pd.get_dummies(cd, columns=['color'], prefix='c', dtype='int8')  # 输出: 只转指定列 + 0/1
pd.get_dummies(cd, drop_first=True).columns.tolist()   # 输出: ['color_red', 'size_S'](避免共线性)
pd.get_dummies(cd, dummy_na=True).shape                # 输出: (3, 6)(NaN 也独占一列)
pd.from_dummies(dm, sep='_')   # 输出: 逆变换回原始类别列(要求列名带前缀)

⚠️ 训练集/测试集必须对齐列:分别 get_dummies 会得到不同列集合。做法是先在全体数据上确定类别(pd.CategoricalDtype),或 test.reindex(columns=train_cols, fill_value=0)。此外默认 dtype 从 int 变成了 bool,喂给 sklearn 前确认是否需要 .astype(int)。

crosstab 与 T(转置) (进阶见 §16.3)

crosstab 本质是“两个(或多个)类别变量的频数透视”,等价于 pivot_table(aggfunc='size'),但支持 normalize 求占比。

Python12 行
ct = pd.DataFrame({'Gender': ['M', 'F', 'M', 'F', 'M', 'F'],
                   'AgeGrp': ['Y', 'Y', 'O', 'O', 'Y', 'O'],
                   'Product': ['A', 'A', 'B', 'B', 'A', 'B'],
                   'Sales': [100, 150, 200, 130, 110, 90]})
pd.crosstab(ct['Gender'], ct['Product'])                     # 输出: 2×2 频数表
pd.crosstab([ct['Gender'], ct['AgeGrp']], ct['Product'])     # 输出: 多级行维度的交叉表
pd.crosstab(ct['Gender'], ct['Product'], values=ct['Sales'], aggfunc='sum')  # 输出: 金额而非计数
pd.crosstab(ct['Gender'], ct['Product'], margins=True, margins_name='合计')  # 输出: 带行列小计
pd.crosstab(ct['Gender'], ct['Product'], normalize='index').round(2)   # 输出: 每行和为 1

wide.set_index('Name').T.shape  # 输出: (3, 3)(先 set_index 再转置 → 科目为行、姓名为列)
wide.T.shape                    # 输出: (4, 3)(连 Name 列也被转置,通常不是想要的)

⚠️ df.T 会把所有列(含字符串列)一起转置,混合 dtype 的 DataFrame 转置后整体退化为 object——转置前先 set_index 把标识列移出数据区。crosstab 的 values + aggfunc 必须与 normalize 分开理解:前者换度量,后者只做归一化。

多层列索引拍平

pivot_table(columns=[...]) 或 aggfunc=[...] 会产生二级列索引,取列要写元组。一次性拍平的三种写法:

Python8 行
pt = sales.pivot_table(index='Region', columns='Product', values=['Sales', 'Qty'], aggfunc=['sum', 'mean'])
pt.columns[:2].tolist()         # 输出: [('sum','Sales','A'), ('sum','Sales','B')](三层)
flat = pt.copy()
flat.columns = flat.columns.map(lambda c: '_'.join(map(str, c)))
flat.columns.tolist()           # 输出: ['sum_Sales_A', 'sum_Sales_B', ...]
flat.reset_index()              # 输出: Region 变回普通列
pt.droplevel(0, axis=1).columns.tolist()    # 输出: [('Sales','A'), ...](只保留部分层级)
pv.rename_axis(columns=None).reset_index()  # 输出: 单级列时去掉 columns.name('Product')

⚠️ 拍平前先确认元组每层的顺序——aggfunc 列表在前、values 次之、columns 取值最后(写法不同顺序会变),写错会得到 avg_amount_A 这种张冠李戴的列名。含非字符串层级(如年份 int)时用 map(str, c) 再 join。

成绩宽表 → 长表 → 多维度透视

一份“科目×考试”双维度的宽表:Subject_Exam 这种复合列名要先拆开,再按不同维度透视为报表,最后把多级列索引拍平交付。

Python19 行
grades = pd.DataFrame({
    'SID': [1, 2, 3],
    'Name': ['Alice', 'Bob', 'Charlie'],
    'Math_Mid': [85, 90, 88], 'Math_Final': [88, 92, 90],
    'Eng_Mid': [90, 85, 92], 'Eng_Final': [92, 87, 93],
})
long = grades.melt(id_vars=['SID', 'Name'], var_name='Sub_Exam', value_name='Score')
long[['Subject', 'Exam']] = long['Sub_Exam'].str.split('_', expand=True)
long = long.drop(columns='Sub_Exam')
long.shape                       # 输出: (12, 5)(3 人 × 2 科目 × 2 考试)

avg = long.pivot_table(index=['SID', 'Name'], columns='Subject', values='Score', aggfunc='mean')
avg.round(1).loc[(1, 'Alice')]   # 输出: 该生两科平均分
mx = long.pivot_table(index='Subject', columns='Exam', values='Score',
                      aggfunc='mean', margins=True, margins_name='总体')
mx.round(2)                      # 输出: 2×2 加总计行列的成绩矩阵
flat = long.pivot_table(index='Name', columns=['Subject', 'Exam'], values='Score')
flat.set_axis(flat.columns.map('_'.join), axis=1).reset_index()
# 输出: 一人一行的完全展开宽表(列名 Math_Mid / Math_Final / ...)

要点:melt 之后用 str.split(expand=True) 把复合列名拆成两个维度,是处理“列名带编码”的标准套路;多级 columns=['Subject','Exam'] 产出的二级列索引,用 set_axis(...map('_'.join)) 一步拍平即可交付。

销售长表转多层透视并交付

原始流水是长表(城市 × 品类 × 月份),交付要求是“每个城市一行,品类在上层、月份在下层”的宽表,附带占比小计。

Python18 行
flow = pd.DataFrame({
    'city': ['BJ', 'BJ', 'BJ', 'BJ', 'SH', 'SH', 'SH', 'SH'],
    'cat':  ['A', 'A', 'B', 'B', 'A', 'A', 'B', 'B'],
    'month': ['01', '02', '01', '02', '01', '02', '01', '02'],
    'amt':  [100, 120, 80, 90, 200, 210, 60, 70],
})

pv = flow.pivot_table(index='city', columns=['cat', 'month'], values='amt',
                      aggfunc='sum', fill_value=0, margins=True, margins_name='合计')
pv.columns.names                # 输出: ['cat', 'month']
pv.loc['BJ', ('A', '01')]       # 输出: 100(用元组取格子)

out = pv.set_axis(pv.columns.map(lambda c: '_'.join(map(str, c))), axis=1).reset_index()
out.columns.tolist()            # 输出: ['city', 'A_01', 'A_02', 'B_01', 'B_02', '合计']
out['A_share'] = (pv[[('A', '01'), ('A', '02')]].sum(axis=1) / pv[('合计', '')]).round(3)
flow.pivot_table(index='cat', columns='month', values='amt', aggfunc='sum').stack()  # 输出: 再转回长表
pd.crosstab(flow['city'], flow['cat'], values=flow['amt'], aggfunc='sum', normalize='index').round(2)
# 输出: 各城市内品类金额占比

要点:margins=True 时总计列是 ('合计','') 这种特殊标签(末项取自 margins_name),拍平前先看一眼 pv.columns;取子集用 pv.loc[:, [('A','01'),('A','02')]] 这种元组列表才安全。

标签展开与哑变量建模前处理

推荐/标签系统的原始数据常是“一行多标签”:先 explode 拆成规范的长表统计标签热度,再用 get_dummies 产出一个主体一行的 0/1 特征矩阵。

Python18 行
items = pd.DataFrame({
    'item': ['i1', 'i2', 'i3'],
    'tags': [['hot', 'new'], ['hot'], ['new', 'sale']],
    'price': [99, 199, 49],
})
tags = items.explode('tags', ignore_index=True)
tags.shape                       # 输出: (5, 3)(标签行数 = 各列表长度之和)
tags['tags'].value_counts().to_dict()   # 输出: {'hot': 2, 'new': 2, 'sale': 1}

X = pd.get_dummies(tags[['item']].assign(tag=tags['tags']), columns=['tag'], dtype='int8')
feat = X.groupby('item', as_index=True).max()      # 输出: 每item一行、标签列取 max
feat.loc['i1'].tolist()          # 输出: [1, 1, 0](hot + new)
feat.columns.tolist()            # 输出: ['tag_hot', 'tag_new', 'tag_sale']

items.join(feat, on='item')      # 输出: 特征矩阵拼回原表(一行一主体)
wideTag = tags.assign(v=1).pivot_table(index='item', columns='tags',
                                       values='v', aggfunc='max', fill_value=0)
wideTag.loc['i3'].tolist()       # 输出: 与 feat 等价的另一种写法(pivot_table 出 0/1)

要点:explode → get_dummies → groupby.max() 是“多标签转特征矩阵”的定式;用 groupby.max 而非 sum,否则重复标签会被计成 2。

时间序列

pd.to_datetime:解析入口

参数 作用
format 显式格式串(%Y-%m-%d)→ 更快且能暴露脏数据
errors='raise'/'coerce' 无法解析时报错 / 置 NaT('ignore' 已弃用)
dayfirst=True 优先按“日-月-年”解释,如 01/02/2024 → 2 月 1 日
utc=True 解析后统一钉到 UTC
unit='s'/'ms' 输入是 Unix 时间戳(秒/毫秒)时的口径
Python10 行
import pandas as pd
import numpy as np

pd.to_datetime(['2024-01-01', '2024/02/01', '20240301'], format='mixed').tolist()  # 输出: 三个 Timestamp
pd.to_datetime('01/02/2024')            # 输出: 2024-01-02(默认月在前)
pd.to_datetime('01/02/2024', dayfirst=True)   # 输出: 2024-02-01
pd.to_datetime(['2024-01-01', 'bad'], errors='coerce').isna().tolist()  # 输出: [False, True]
pd.to_datetime([1700000000, 1700000060], unit='s')     # 输出: UTC 时间戳对应的两个时刻
pd.to_datetime(pd.Series(['2024-01-01']), format='%Y-%m-%d')   # 输出: datetime64[ns] Series
pd.to_datetime(['2024-1-1', '2024/2/1'], format='mixed')  # 输出: 允许逐元素猜格式(慢)

⚠️ Pandas 2.x 起同一列混用多种格式不再自动猜测,会要求显式 format 或 format='mixed'(慢)。稳妥做法是先 str 归一到一种写法再解析。另外 'ignore' 已弃用,脏值统一用 errors='coerce' + isna().sum() 统计。

Timestamp / Period / Timedelta

三类对象各管一段:Timestamp 是一个瞬间(有时刻),Period 是一段时间跨度(有频率),Timedelta 是一段长度。

Python18 行
ts = pd.Timestamp('2024-03-15 12:30:45')
ts.year, ts.month, ts.day, ts.hour            # 输出: (2024, 3, 15, 12)
ts.dayofweek, ts.day_name(), ts.month_name()  # 输出: 4(周五), 'Friday', 'March'
ts.quarter, ts.days_in_month                  # 输出: 1, 31
ts.is_month_end, ts.normalize()               # 输出: False, 2024-03-15 00:00:00
ts.floor('h'), ts.ceil('D'), ts.round('D')    # 输出: 向小时/天边界取整
pd.Timestamp('now', tz='Asia/Shanghai')       # 输出: 带时区的当前时刻

p = pd.Period('2024-03', freq='M')      # 月度 Period(Period 仍用 'M'/'Q'/'Y')
p + 1, p - 1                            # 输出: 2024-04 / 2024-02(按频率平移)
p.start_time, p.end_time                # 输出: 2024-03-01 00:00 / 2024-03-31 23:59:59.999
p.asfreq('D', how='end')                # 输出: 该月最后一天的日周期
pd.period_range('2024-01', periods=6, freq='M')    # 输出: 6 个月度 PeriodIndex

pd.Timestamp('2024-01-01') + pd.Timedelta(days=5, hours=3)   # 输出: 2024-01-06 03:00:00
pd.Timedelta('1 day 2 hours') / pd.Timedelta('1h')            # 输出: 26.0
pd.to_timedelta([1, 2, 3], unit='D')    # 输出: 三个 1/2/3 天的 Timedelta
pd.to_timedelta(pd.Series(['1 days', '2 days'])).dt.total_seconds()  # 输出: [86400.0, 172800.0]

DatetimeIndex 与 date_range

freq 必须用现代别名:M/Q/Y 已分别被 ME/QE/YE(末)和 MS/QS/YS(初)取代,H 写成 h,T 建议写成 min。

freq 含义 freq 含义
D 日历日 h 小时
B 工作日(跳过周六日) min(旧 T) 分钟
W / W-MON 周(可锚定星期几) S 秒
ME / MS 月末 / 月初 ms 毫秒
QE / QS 季末 / 季初 2D、3h 步长倍数
YE / YS 年末 / 年初 2D、3h 步长倍数
Python11 行
pd.date_range('2024-01-01', periods=5, freq='D')        # 输出: 5 个连续日
pd.date_range('2024-01-01', '2024-01-05')               # 输出: 同上(含端点,默认 D)
pd.date_range('2024-01-31', periods=4, freq='ME')       # 输出: 1/31、2/29、3/31、4/30
pd.date_range('2024-01-01', periods=4, freq='QE')       # 输出: 3/31、6/30、9/30、12/31
pd.date_range('2024-01-01', periods=5, freq='B')        # 输出: 5 个工作日(跳过周末)
pd.bdate_range('2024-01-01', periods=5)                 # 输出: 同上,`date_range(freq='B')` 的简写
pd.date_range('2024-01-01', periods=3, freq='2D')       # 输出: 每 2 天一个点
idx = pd.date_range('2024-01-01', periods=10, freq='h')
idx.freqstr, idx.is_monotonic_increasing                # 输出: ('h', True)
pd.date_range('2024-01-01', periods=3, freq=pd.offsets.CustomBusinessDay(holidays=['2024-01-02']))
# 输出: 自定义工作日历(排除节假日)

⚠️ freq='M' 这类旧别名在 Pandas 3 中已被移除,务必写 ME/QE/YE;date_range 的端点是闭区间(包含 end)。用 periods 与 end 二选一时,另一个会被推断,写死 freq 更可控。

时间切片、truncate 与 .dt 访问器

索引排好序后,可以直接用字符串做切片和部分匹配(年 / 年-月 / 完整时间戳均可)。

.dt 属性 含义
year/quarter/month/day 日历字段
hour/minute/second 时刻字段
dayofweek/day_name()/weekday 星期(周一起 0)
month_name()/days_in_month 月名 / 当月天数
is_month_start/end、is_year_start/end 边界布尔
date/time 取出 Python date/time 对象
normalize()/floor/ceil/round 取整到天/指定频率
to_period('ME')/to_timestamp() Period ↔ Timestamp 互换
total_seconds()/days 仅 Timedelta 可用
Python18 行
rng = np.random.default_rng(0)
s = pd.Series(rng.normal(size=90).cumsum(), index=pd.date_range('2024-01-01', periods=90, freq='D'))

s['2024-01-01']                     # 输出: 精确某一天的标量
s['2024-01']                        # 输出: 1 月全部 31 个点
s['2024-01':'2024-03']              # 输出: 一季度全部(含两端)
s.loc['2024-01-15':].shape          # 输出: 从 1/15 起的切片
s.truncate(before='2024-02-01', after='2024-02-29').shape   # 输出: 29(按边界截断)

s.index.month.tolist()[:3]          # 输出: [1, 1, 1]
s.index.dayofweek[:5].tolist()      # 输出: 周一=0 起的星期序号
s.index.to_period('M')[:2]          # 输出: PeriodIndex(['2024-01', '2024-01'], freq='M')
df = pd.DataFrame({'t': pd.to_datetime(['2024-01-01 09:00', '2024-01-01 22:00']), 'v': [1, 2]})
df['t'].dt.hour.tolist()            # 输出: [9, 22]
df['t'].dt.normalize().tolist()     # 输出: 同一天 00:00
day = pd.DataFrame({'v': range(48)}, index=pd.date_range('2024-01-01', periods=48, freq='h'))
day.between_time('09:00', '12:00').shape   # 输出: (8, 1)(每天该时段的行)
day.at_time('10:00')                       # 输出: 每天 10 点的行

⚠️ 时间切片要求索引已排序(sort_index()),否则行为未定义或报错。.dt 只对 datetime64 类型的 Series 可用;列还是 object 时先 pd.to_datetime 转换——这是最常见的踩坑点,报错往往出现在链式调用的中间步。

shift / diff / pct_change

Python9 行
p = pd.Series([100, 102, 105, 103, 107], index=pd.date_range('2024-01-01', periods=5, freq='D'))
p.shift(1).tolist()              # 输出: [NaN, 100.0, 102.0, 105.0, 103.0](数据下移,索引不动)
p.shift(-1).tolist()             # 输出: [102.0, 105.0, 103.0, 107.0, NaN]
p.shift(2, freq='D').index[0]    # 输出: 2024-01-03(索引整体平移,数据不动)
p.diff().tolist()                # 输出: [NaN, 2.0, 3.0, -2.0, 4.0]
p.diff(2).tolist()               # 输出: 间隔 2 期的差分
p.pct_change().round(4).tolist() # 输出: [NaN, 0.02, 0.0294, -0.019, 0.0388]
p.pct_change(periods=2)          # 输出: 环比跨度改为 2 期
p / p.iloc[0] - 1                # 输出: 相对首值的累计涨跌幅

⚠️ shift(n) 动的是数据(产生 NaN),shift(n, freq=...) 动的是索引(时刻平移,数据不变)——算“上一年同期”用前者(按位置),算“昨日同刻”用后者。用 shift 造监督学习的标签列时,注意别把未来信息滑进去。

resample:降采样与升采样

resample = 按时间分组 + 聚合。降采样(高频→低频)需要聚合函数;升采样(低频→高频)需要补值方式。

Python17 行
hi = pd.Series(range(48), index=pd.date_range('2024-01-01', periods=48, freq='h'))
hi.resample('D').mean()                       # 输出: 2 天的小时均值
hi.resample('D').agg(['mean', 'sum', 'max']).shape   # 输出: (2, 3)
hi.resample('D').ohlc()                       # 输出: open/high/low/close 四列
hi.resample('D').size()                       # 输出: 每个桶里的样本数

lo = pd.Series([1, 2], index=pd.date_range('2024-01-01', periods=2, freq='D'))
lo.resample('12h').asfreq()                   # 输出: 4 个点,新插入点为 NaN
lo.resample('12h').ffill()                    # 输出: 前值填充(左闭右开地继承)
lo.resample('12h').interpolate()              # 输出: 线性插值补齐
lo.resample('12h').asfreq(fill_value=0)       # 输出: 空缺补 0

amount = pd.DataFrame({'city': ['BJ'] * 48, 'amt': range(48)},
                      index=pd.date_range('2024-01-01', periods=48, freq='h'))
amount.resample('D').agg({'city': 'first', 'amt': ['sum', 'max']}).shape   # 输出: (2, 3)
amount.resample('D')['amt'].sum().pct_change().round(3)   # 输出: 日环比
hi.resample('W').mean().index.day_name().tolist()   # 输出: 周桶的锚定日(默认周日)

⚠️ resample 不是 groupby:裸写 resample('D') 只得到一个惰性 Resampler 对象,必须接聚合方法才会有结果。它默认 closed='left'、label='left'(左端打标签),想让月度数据落在月末,用 resample('ME') 或显式 label='right'。

asfreq:只换频率不聚合

asfreq 等价于“把索引重排到新频率网格”,每格只采样一处,其余补 NaN(或 fill_value/method)。关系一句话:resample = 重采样 + 聚合,asfreq = 纯重索引。

Python8 行
d = pd.Series([1, 3, 5], index=pd.date_range('2024-01-01', periods=3, freq='D'))
d.asfreq('12h')                    # 输出: 6 个点,新增点为 NaN(换了网格)
d.asfreq('D')                      # 输出: 原样(频率未变)
d.asfreq('D', method='ffill')      # 输出: 用前值补缺
d.asfreq('D', fill_value=-1)       # 输出: 缺值统一填 -1
d.resample('12h').first()          # 输出: 同样网格,但走聚合语义
pd.Series(range(3), index=pd.period_range('2024-01', periods=3, freq='M')).asfreq('Q')
# 输出: PeriodIndex 上的降频(`PeriodIndex` 换频必须用 asfreq)

⚠️ asfreq 要求索引是 DatetimeIndex/PeriodIndex,普通 RangeIndex 上无效;它也不会去重——同一时刻多条记录会原样保留而非合并,这种情况要用 resample 或 groupby。

滚动窗口:rolling / expanding / ewm

窗口 含义 常用参数
rolling(n) 固定 n 条(或时间长度如 '7D')的滑动窗 min_periods、center、closed
expanding() 从头累计到当前行 min_periods
ewm() 指数加权,越近权重越大 span、halflife、alpha、adjust
Python12 行
v = pd.Series([10, 15, 13, 18, 20, 17, 22, 25], index=pd.date_range('2024-01-01', periods=8, freq='D'))
v.rolling(3).mean().round(2).tolist()   # 输出: [NaN, NaN, 12.67, 15.33, 17.0, 18.33, 19.67, 21.33]
v.rolling(3, min_periods=1).mean().round(2).tolist()   # 输出: 前两点也出值
v.rolling(3).agg(['mean', 'std', 'max']).shape         # 输出: (8, 3)
v.rolling(3).apply(lambda x: x.max() - x.min())        # 输出: 窗口极差(自定义标量函数)
v.rolling('3D').mean().round(2).tolist()   # 输出: 按时间长度开窗(需 DatetimeIndex)
v.rolling(3, center=True).mean()           # 输出: 居中窗口的前后平均值,常用于平滑曲线
v.expanding().mean().round(1).tolist()     # 输出: 累计均值,逐点吸收全部历史
v.expanding().agg(['sum', 'max']).shape    # 输出: (8, 2)
v.ewm(span=3, adjust=False).mean().round(2).tolist()   # 输出: EMA,权重按 span 衰减
v.ewm(halflife=2).mean().round(2)          # 输出: 按半衰期指定衰减速度
v.ewm(alpha=0.3).mean().round(2)           # 输出: 直接给平滑系数(0 < α ≤ 1)

⚠️ rolling('7D') 这类时间窗依赖 DatetimeIndex 且必须已排序;窗口字符串里同样要用 D/h/min。rolling().apply(f) 逐窗调用 Python(默认传入 Series,raw=True 可换成 ndarray 提速),数据上万条时优先用内置的 mean/sum/std/max。

时区:tz_localize / tz_convert

方向固定:localize 给“天真”时间钉上时区(墙上时刻不变,只加解释);convert 把已带时区的时间换到另一时区(同一瞬间,墙上时刻变动)。

Python13 行
tz = pd.Series(range(3), index=pd.date_range('2024-01-01', periods=3, freq='D'))
tz.index.tz                          # 输出: None(naive,无时区)
l = tz.tz_localize('Asia/Shanghai')
l.index.tz                           # 输出: Asia/Shanghai(墙上时间未变)
l.tz_convert('UTC').index[0]         # 输出: 2023-12-31 16:00:00+00:00(同一瞬间)
l.tz_convert('America/New_York').index[0]   # 输出: 2023-12-31 11:00:00-05:00
pd.date_range('2024-01-01', periods=2, tz='UTC')   # 输出: 直接生成带时区索引
l.tz_localize(None).index.tz         # 输出: None(去时区,保留墙上时间)

amb = pd.DatetimeIndex(['2024-11-03 01:30', '2024-11-03 01:30'])   # 夏令时回拨 → 出现两次
amb.tz_localize('America/New_York', ambiguous='NaT')             # 输出: 歧义时刻置 NaT
nonex = pd.DatetimeIndex(['2024-03-10 02:30'])                   # 夏令时跳过 → 该时刻不存在
nonex.tz_localize('America/New_York', nonexistent='shift_forward')  # 输出: 前移到 03:00

⚠️ 顺序写反会报错:对已有时区的索引调 tz_localize 抛 TypeError(应用 tz_convert),反之亦然。跨时区比对前统一转 UTC,不要拿字符串比时间。

Period 索引与 to_period / to_timestamp

Python7 行
ts2 = pd.Series([1, 2, 3, 4], index=pd.date_range('2024-01-15', periods=4, freq='QE'))
per = ts2.to_period()                # 输出: Index 变为 PeriodIndex,频率取自原 freq
per.index.tolist()                   # 输出: [2024Q1, 2024Q2, 2024Q3, 2024Q4]
per.to_timestamp().index[0]          # 输出: 2024-01-01(回到时间戳,默认取期间起点)
ts2.to_period('M')                   # 输出: 转成月度 Period(各点归属所在月)
pd.period_range('2024-01', periods=6, freq='M').to_timestamp(how='end')
# 输出: 每月最后一日 23:59:59.999(做区间报表时很有用)

日流水的重采样与月度报表

一份跨三个月的逐日交易流水,要产出日汇总、以周一为锚的周汇总、月度 OHLC 与月度环比。关键是先把日期列设成 DatetimeIndex 并排序。

Python17 行
rng = np.random.default_rng(3)
raw = pd.DataFrame({
    'date': pd.date_range('2024-01-01', periods=62, freq='D'),
    'city': np.tile(['BJ', 'SH'], 31),
    'amt': rng.integers(100, 900, 62),
})
raw['date'].max()                     # 输出: 2024-03-02(跨 3 个月的日数据)
m = raw.set_index('date').sort_index()

m.resample('D')['amt'].sum().head(2)         # 输出: 每日合计
m.resample('W-MON')['amt'].sum().shape       # 输出: (10,)(以周一为锚的周桶)
m.resample('ME')['amt'].agg(['sum', 'mean', 'size']).round(1)   # 输出: 月维度三指标
m.resample('ME')['amt'].ohlc()               # 输出: 每月首/高/低/末值
m.resample('ME')['amt'].sum().pct_change().round(3).dropna()    # 输出: 2、3 月环比
(m.groupby([pd.Grouper(freq='ME'), 'city'])['amt'].sum()
   .unstack(fill_value=0))                   # 输出: 月 × 城市的宽表
m['amt'].resample('12h').asfreq().isna().sum()   # 输出: 升采样后网格中的空缺格数

要点:set_index('date').sort_index() 是重采样前的必要准备;resample 后可接任何 groupby 风格的聚合(列表、字典、ohlc()),而 pd.Grouper 是在不设索引时做同样事情的另一种写法。

移动平均、波动率与累计净值

典型的“价格/指标”流水线:先算日收益率,再叠加不同窗口的移动平均与滚动标准差,最后用累计乘积还原净值曲线。

Python17 行
px = pd.DataFrame({'close': [100, 102, 101, 105, 104, 108, 110, 107, 112, 115]},
                  index=pd.date_range('2024-01-01', periods=10, freq='B'))

px['ret'] = px['close'].pct_change()
px['ma3'] = px['close'].rolling(3).mean().round(2)
px['ma5'] = px['close'].rolling(5, min_periods=1).mean().round(2)
px['vol'] = px['ret'].rolling(4).std().round(4)
px['ema'] = px['close'].ewm(span=4, adjust=False).mean().round(2)
px['nav'] = (1 + px['ret'].fillna(0)).cumprod()

px['ma3'].iloc[-1]                   # 输出: 111.33(最后三日均线)
px['ret'].max().round(4)             # 输出: 0.0467(最大单日涨幅)
px['vol'].max()                      # 输出: 最大滚动波动率
px['nav'].iloc[-1].round(4)          # 输出: 约 1.15(相对首日的累计净值)
px.loc[px['ret'].abs() > 0.03, 'close'].tolist()   # 输出: 单日波动超 3% 的收盘价
px['ret'].shift(-1).dropna().round(4).tolist()     # 输出: 次日收益率(做监督学习标签)
px['close'].rolling('5D').mean().round(2)          # 输出: 按 5 个自然日开窗的移动平均

要点:用 assign 链式写更整洁(px.assign(ma3=lambda x: x['close'].rolling(3).mean()));min_periods=1 让均线从第一根就有值,代价是前几根由极少样本算出、噪音大——画图可以,别拿去触发交易信号。

跨时区对齐与工作日补齐

两地机房的日志分别用本地时区记录,需要先统一钉时区再转 UTC 比对;同时把日历日补齐成完整工作日网格,缺失日画 NaN 而不是被跳过。

Python17 行
bj = pd.DataFrame({'v': [1, 2, 3]}, index=pd.to_datetime(
    ['2024-01-02 09:00', '2024-01-03 09:00', '2024-01-04 09:00']))
bj_idx = bj.tz_localize('Asia/Shanghai').tz_convert('UTC')
bj_idx.index[0]                      # 输出: 2024-01-02 01:00:00+00:00(东八区早上 9 点)

grid = pd.bdate_range('2024-01-01', '2024-01-10')      # 输出: 该区间全部工作日(跳过周末)
bjn = bj.copy()
bjn.index = bjn.index.normalize()    # 输出: 去掉时刻,对齐到日粒度(否则与午夜网格对不上)
r = bjn.reindex(grid)
r.shape                              # 输出: (8, 1)(补齐到 8 个工作日)
r['v'].isna().sum()                  # 输出: 5(其中 5 天没有日志)
r.ffill()['v'].tolist()              # 输出: [nan, 1.0, 2.0, 3.0, 3.0, 3.0, 3.0, 3.0]

pm = pd.period_range('2024-01', periods=4, freq='M')
pd.Series(range(4), index=pm).to_timestamp(how='end')  # 输出: 每月最后一天对齐的时间戳
bj.tz_localize('Asia/Shanghai').tz_convert('UTC').tz_convert('America/New_York').index[0]
# 输出: 同一瞬间在纽约的墙上时间

要点:多源时间数据合并前统一到 UTC 是唯一稳妥做法;bdate_range + reindex 能把稀疏事件补齐到完整工作日网格,比直接画图更能暴露“哪几天没数据”。

数据可视化

pandas 的绘图是 matplotlib 的一层薄封装:Series.plot() / DataFrame.plot() 返回 Axes 对象,剩下的样式仍用 plt.* 调。默认后端 matplotlib。

df.plot() 全家桶速查

kind 图形 必填 / 常用参数
line 折线(默认) x y logy secondary_y
bar 垂直柱状 stacked rot color
barh 水平柱状 同上,x/y 语义互换
hist 直方图 bins alpha stacked
box 箱线 vert by
kde 核密度(别名 density) bw_method ind
area 面积图 stacked alpha
scatter 散点 必填 x y;s c colormap
hexbin 六边形分箱 必填 x y;gridsize C
pie 饼图 只对 Series;autopct startangle
Python22 行
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(0)                       # NumPy 2.x 用新随机 API
df = pd.DataFrame(rng.standard_normal((30, 3)).cumsum(axis=0),
                  columns=list('ABC'),
                  index=pd.date_range('2026-01-01', periods=30, freq='D'))

# 堆叠类图表要求同号数据,另建一份全正数据
pos = pd.DataFrame(rng.uniform(1, 10, (8, 3)), columns=list('ABC'))

df.plot()                                            # 输出: <Axes: >  ← 默认 kind='line'
df.plot(kind='barh')
df.plot(kind='hist', bins=20, alpha=0.5)
df.plot(kind='box', vert=False)
df.plot(kind='kde')
df.plot(kind='scatter', x='A', y='B', s=20)
df.plot(kind='hexbin', x='A', y='B', gridsize=12, cmap='YlOrRd')
pos.plot(kind='bar', stacked=True, figsize=(8, 4))   # 堆叠要求同号数据
pos.plot(kind='area', stacked=True, alpha=0.4)
df['A'].gt(0).value_counts().plot(kind='pie', autopct='%1.1f%%')

⚠️ scatter 与 hexbin 必须显式给 x/y 列名。DataFrame 上直接 plot(kind='pie') 会报错,要么只取一列 Series,要么给 y='列名'。

⚠️ 无显示器环境(服务器 / CI)先 matplotlib.use('Agg'),否则 plt.show() 会挂住进程。

常用参数速查

参数 作用
x / y 指定列;y 可以是列名列表,只画子集
figsize=(w, h) 画布尺寸,单位英寸
title 标题(等价 ax.set_title())
ax 画到指定 Axes,多子图混排的核心
subplots=True 每列一个子图,配 layout=(行, 列) sharex sharey
secondary_y 双 Y 轴:df.plot(secondary_y=['C']) 或 True
logy / logx / loglog 对数坐标
grid / rot 网格线 / X 轴刻度旋转角度
stacked bar / area / hist 堆叠
cmap / colormap 配色;pandas 侧散点用 colormap
alpha 透明度
color / style 颜色列表;style 接受 'r--' 这类格式串
legend / xlabel / ylabel / ylim 图例、轴标签、Y 轴范围
Python7 行
ax = df.plot(y=['A', 'B'], figsize=(10, 4), title='AB 走势',
             grid=True, rot=45, alpha=0.8,
             style=['-', '--'], color=['#FF6B6B', '#4ECDC4'])
ax.set_ylabel('累计值')                              # 输出: Text(0, 0.5, '累计值')
df.plot(secondary_y=['C'], figsize=(10, 4))         # 输出: C 走右轴
df.plot(subplots=True, layout=(3, 1), figsize=(8, 6), sharex=True)
df.plot(logy=True, ylim=(1e-3, 1e3))                # 输出: 数量级跨度大时用

kind 参数与 df.plot.xxx() 两种写法

两者完全等价:df.plot(kind='area') ≡ df.plot.area()。后者少一层括号、IDE 补全友好;需要把图型当变量循环时用 kind。

Python7 行
pos.plot(kind='area', stacked=True)     # 输出: 堆叠面积
pos.plot.area(stacked=True)             # 输出: 同上,完全等价
df.plot.line(); df.plot.bar(); df.plot.barh(); df.plot.hist()
df.plot.box(); df.plot.kde(); df.plot.density()      # kde / density 是同一方法的两个名字
df.plot.scatter(x='A', y='B')          # scatter / hexbin 通常只写方法形式
df.plot.hexbin(x='A', y='B', gridsize=10)
df.plot.pie(y='A')                     # DataFrame 上用 pie 必须给 y

直方图与分箱

bins 决定粒度:整数 = 等宽箱数,列表 = 自定义边界(可不等宽)。默认 10 箱,样本上千就该调到 20–50。

Python9 行
s = pd.Series(rng.standard_normal(1000))
s.plot(kind='hist', bins=30, alpha=0.7)             # 输出: 等宽 30 箱
s.plot(kind='hist', bins=[-3, -1, 0, 1, 3])         # 输出: 自定义边界(不等宽)
s.plot(kind='hist', density=True)                   # 输出: 纵轴变密度,面积和为 1
s.plot(kind='kde', bw_method=0.3)                   # 输出: 同一份数据的密度曲线

df.hist(bins=15, figsize=(10, 3))                   # 输出: array([<Axes>, ...]) ← 每列一个子图
df.hist(column=['A', 'B'], bins=15, layout=(1, 2))  # 输出: 只画指定列
df.plot(kind='hist', bins=15, subplots=True, layout=(1, 3), figsize=(12, 3))

⚠️ df.hist() ≠ df.plot(kind='hist'):前者每列单独一个子图并返回 Axes 数组,后者默认把多列叠在一张图上靠 alpha 区分。

散点图与散点矩阵

Python13 行
sc = pd.DataFrame({'x': rng.standard_normal(300),
                   'y': rng.standard_normal(300),
                   'size': rng.uniform(10, 200, 300),
                   'grp': rng.choice(['甲', '乙', '丙'], 300)})

sc.plot(kind='scatter', x='x', y='y', s=sc['size'] / 5, alpha=0.6)
sc.plot(kind='scatter', x='x', y='y', c='size', colormap='viridis', s=15)
sc.plot(kind='hexbin', x='x', y='y', gridsize=15, cmap='Blues')

pd.plotting.scatter_matrix(sc[['x', 'y', 'size']], figsize=(7, 7),
                           diagonal='hist', alpha=0.5)   # 输出: 3×3 散点矩阵
pd.plotting.lag_plot(pd.Series(rng.standard_normal(200)), lag=1)
pd.plotting.autocorrelation_plot(pd.Series(rng.standard_normal(200).cumsum()))

⚠️ 散点超过万级会糊成一团,改用 hexbin;scatter 的 c 只接受列名字符串(或颜色序列),不能传 Series 对象。

多子图布局

两种套路:subplots=True 一把梭(同图型、每列一图);或先 plt.subplots() 拿 axes 数组,再逐个指定 ax=(不同图型混排)。

Python6 行
fig, axes = plt.subplots(2, 2, figsize=(11, 7))
df['A'].plot(ax=axes[0, 0], title='折线')
df['A'].plot(kind='hist', bins=15, ax=axes[0, 1], title='直方图')
df[['A', 'B', 'C']].plot(kind='box', ax=axes[1, 0], title='箱线')
df['A'].plot(kind='kde', ax=axes[1, 1], title='密度')
fig.tight_layout()                                  # 输出: 无返回值,收紧留白

⚠️ tight_layout() 要在所有子图画完后调一次。每个子图后各调一次会互相挤压。

保存图片与中文字体

中文乱码与负号方块是 matplotlib 侧两个独立开关,import 后一次性设好。savefig 必须在 plt.show() 之前调用,否则 show 会清空画布、存出空白图。

Python6 行
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False          # 输出: 负号正常显示,不再是方框

ax = df['A'].plot(figsize=(8, 4), title='中文标题正常')
ax.figure.savefig('out.png', dpi=150, bbox_inches='tight')   # 输出: 落盘 out.png
ax.figure.savefig('out.svg', bbox_inches='tight')            # 输出: 矢量 SVG

⚠️ Linux 服务器上通常没有 SimHei,先 fc-list :lang=zh 查已装中文字体再填 rcParams;省事的办法是全图用英文标签。

日销售额趋势 + 移动平均

一份一年的日销售流水,要看出趋势而不是被日噪声淹没:先按日聚合,再叠一条 30 日移动平均线。rolling 出的是 Series,直接 .plot() 就画在同一张图上。

Python18 行
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
daily = pd.DataFrame({
    'date': pd.date_range('2026-01-01', periods=365, freq='D'),
    'sales': rng.integers(100, 1000, 365) + np.arange(365) * 1.5,
}).set_index('date')

ax = daily['sales'].plot(figsize=(11, 4), alpha=0.4, label='日销售')
daily['sales'].rolling(30).mean().plot(ax=ax, linewidth=2, label='30 日均线')
ax.set(title='日销售额与 30 日移动平均', ylabel='销售额')
ax.legend()

weekly = daily['sales'].resample('W').sum()          # 输出: 53 行的周聚合
weekly.plot(kind='bar', figsize=(11, 3), title='周销售额')
plt.tight_layout()

要点:rolling(n).mean() 与 resample('W').sum() 返回的都是 pandas 对象,可以直接接 .plot(),无需手工转 list。

品类对比仪表板

同一份数据要从“总量、占比、分布”三个角度看:柱状图看总量、饼图看占比、箱线图看分布离散度。用 plt.subplots 起 1×3 的画布,三种图型各占一格。

Python21 行
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(7)
sales = pd.DataFrame({
    'product': rng.choice(['产品 A', '产品 B', '产品 C'], 300),
    'region': rng.choice(['华东', '华南', '华北'], 300),
    'amount': rng.integers(100, 1000, 300),
})

fig, axes = plt.subplots(1, 3, figsize=(14, 4))
sales.groupby('product')['amount'].sum().plot(kind='bar', ax=axes[0],
                                              title='品类总销售额', rot=0)
sales.groupby('region')['amount'].sum().plot(kind='pie', ax=axes[1],
                                             title='地区占比', autopct='%1.1f%%')
axes[1].set_ylabel('')
sales.boxplot(column='amount', by='product', ax=axes[2])   # 输出: 按品类分组的箱线
axes[2].set_title('品类销售分布')
fig.suptitle('')                                     # 输出: 清掉 boxplot 自动加的总标题
plt.tight_layout()

要点:df.boxplot(column=..., by=...) 是 DataFrame 的专用方法(plot(kind='box') 做不到按列分组),但它会自动塞一个 suptitle,记得用 fig.suptitle('') 清掉。

双 Y 轴与对数坐标

成交额与订单数量级差三个数量级,画在一根轴上小的那条被压成直线。用 secondary_y 开右轴,或直接 logy=True 上对数坐标。

Python17 行
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

idx = pd.date_range('2026-01-01', periods=60, freq='D')
rng = np.random.default_rng(3)
df = pd.DataFrame({'gmv': rng.integers(1_000_000, 9_000_000, 60),
                   'orders': rng.integers(100, 900, 60)}, index=idx)

df.plot(figsize=(10, 4), secondary_y=['orders'],
        title='成交额(左)vs 订单量(右)', grid=True)

df['gmv'].plot(logy=True, figsize=(10, 3), title='成交额对数坐标')

ax = df['gmv'].pct_change().mul(100).plot(kind='bar', figsize=(10, 3),
                                          title='日环比 %')
plt.tight_layout()

要点:secondary_y 接受列名列表,列表里的列走右轴、其余走左轴;写 secondary_y=True 则是最后一列走右轴。

文件读写

read_csv 参数速查

参数 作用 典型值
sep 分隔符 ','(默认)'\t' ';';sep=None 自动嗅探
header 表头行号 0 默认;None 表示无表头
names 自定义列名 配 header=None 用,或 header=0 做重命名
index_col 索引列 列名或序号,给列表则生成 MultiIndex
usecols 只读部分列 列名列表,或 callable 过滤
dtype 指定类型 {'id': 'int32', 'city': 'category'}
parse_dates 日期解析 ['date'];多列合并 {'dt': ['y', 'm', 'd']}
na_values 额外缺失标记 ['NA', 'N/A', '-999', '']
encoding 编码 'utf-8' 默认;国产旧系统常用 'gbk'
skiprows / nrows 跳过 / 只读 N 行 skiprows=2;也接受 callable
chunksize 分块 返回可迭代的 TextFileReader
converters 逐列自定义函数 {'amt': lambda x: float(x.strip('¥'))}
thousands 千分位分隔符 ','
comment 注释符 '#'
Python19 行
import pandas as pd
from io import StringIO

csv = """# 导出时间 2026-01-01
name,age,city,salary,hire_date
Alice,25,Beijing,"1,234.5",2026-01-01
Bob,,Shanghai,"2,000.0",2026-02-01
Charlie,35,,N/A,2026-03-01"""

df = pd.read_csv(StringIO(csv),
                 sep=',',
                 skiprows=1,
                 usecols=['name', 'age', 'city', 'salary', 'hire_date'],
                 dtype={'city': 'category'},
                 parse_dates=['hire_date'],
                 na_values=['N/A', ''],
                 thousands=',')
df.dtypes        # 输出: name str / age float64 / city category / salary float64 / hire_date datetime64[us]
df.isna().sum()  # 输出: age 1  city 1  ← 'N/A' 与空串都被识别为缺失

⚠️ 有缺口的整数列会被抬成 float64(age 就是)。要保住整数语义,读完补 df['age'] = df['age'].astype('Int64')。

⚠️ 编码错了不要盲试,UnicodeDecodeError 时优先怀疑 GBK:pd.read_csv(f, encoding='gbk');反过来写出去给 Windows 用户看,用 encoding='utf-8-sig' 才能被 Excel 正确识别。

CSV 写出侧

Python8 行
out = pd.DataFrame({'name': ['Alice', 'Bob'], 'age': [25, 30]})

print(out.to_csv(index=False))                 # 输出: name,age\nAlice,25\nBob,30\n
out.to_csv('o.csv', index=False)               # 落盘,不带索引列
out.to_csv('o.tsv', sep='\t', index=False)     # TSV
out.to_csv('o.csv', columns=['name'], index=False)        # 只写部分列
out.to_csv('o.csv', mode='a', header=False, index=False)  # 追加(不加表头)
out.to_csv('o.csv.gz', index=False, compression='gzip')   # gzip / bz2 / zip / xz 自动按扩展名

⚠️ to_csv 默认写索引,落盘前几乎总要 index=False;追加模式下必须同时 header=False,否则每追一次多一行表头。

Excel

Python12 行
pd.read_excel('f.xlsx', sheet_name='明细')           # 输出: 单个 DataFrame
pd.read_excel('f.xlsx', sheet_name=[0, '明细'])      # 输出: {0: df, '明细': df}
pd.read_excel('f.xlsx', sheet_name=None)             # 输出: 全部 sheet 的 dict
pd.read_excel('f.xlsx', usecols='A:C', nrows=100)    # 输出: 只取前三列前 100 行

with pd.ExcelWriter('out.xlsx', engine='openpyxl') as w:
    df1.to_excel(w, sheet_name='汇总', index=False)
    df2.to_excel(w, sheet_name='明细', index=False)  # 输出: 一个文件两个 sheet

with pd.ExcelWriter('out.xlsx', engine='openpyxl', mode='a',
                    if_sheet_exists='replace') as w:
    df3.to_excel(w, sheet_name='汇总', index=False)  # 输出: 覆盖已有 sheet 而不重建文件

⚠️ 读写 .xlsx 都要 pip install openpyxl(.xls 老格式才用 xlrd)。ExcelWriter 默认覆盖整个文件,要往已有文件加 sheet 必须显式 mode='a' + if_sheet_exists。

JSON 的 orient

orient 结构 适用
records [{col: val}, ...] 默认;前端 / API 最常用
index {idx: {col: val}} 保留索引语义
columns {col: {idx: val}} pandas 默认输出形态
values [[...], ...] 只要数值,丢掉所有标签
split {columns: [], index: [], data: []} 体积最小、往返最稳
Python4 行
df.to_json(orient='records', force_ascii=False, indent=2)   # 输出: 中文正常不转义
pd.read_json(js, orient='records')
pd.read_json(js, orient='split', convert_dates=['date'])    # 输出: 日期列恢复成 datetime64
pd.read_json(js, orient='records', dtype={'id': 'Int64'}, lines=True)  # NDJSON 逐行

⚠️ 写 JSON 默认 force_ascii=True,中文全变 \uXXXX;给人看或给前端用必须关掉。时间戳默认被写成毫秒整数,读回来要 convert_dates 才能变回 datetime。

SQL

Python12 行
import sqlite3

conn = sqlite3.connect(':memory:')
df.to_sql('emp', conn, if_exists='replace', index=False)   # 输出: 3(写入行数)
pd.read_sql('SELECT * FROM emp WHERE age > 25', conn)
pd.read_sql_query('SELECT city, COUNT(*) n FROM emp GROUP BY city', conn)
pd.read_sql_table('emp', conn)                             # 输出: 整表,不支持过滤

# 生产库走 SQLAlchemy
# from sqlalchemy import create_engine
# engine = create_engine('mysql+pymysql://user:pwd@host:3306/db')
# df = pd.read_sql('SELECT * FROM t WHERE dt >= %s', engine, params=['2026-01-01'])

if_exists:fail(默认,表已存在就报错)/ replace(删表重建)/ append(追加)。

⚠️ read_sql 一次把结果全拉进内存。大表务必在 SQL 里先 WHERE / LIMIT 筛过,别指望 pandas 侧省内存。参数一律走 params=,不要 f-string 拼 SQL。

Parquet / Feather / Pickle

Python4 行
df.to_parquet('d.parquet', engine='pyarrow', compression='snappy')  # pip install pyarrow
pd.read_parquet('d.parquet', columns=['a', 'b'])   # 输出: 列式存储,只读需要的列
df.to_feather('d.feather'); pd.read_feather('d.feather')
df.to_pickle('d.pkl');      pd.read_pickle('d.pkl')
场景 选 理由
与人交换、人工查看 CSV 通用、可 diff、无依赖
数据管道中间产物 Parquet 列式 + 压缩 + 自带 schema,体积小读得快
同机临时缓存 Feather / Pickle 最快,但不可跨语言、不可长期存
给业务方看 Excel 会开 Excel 的人最多
Web / API 传输 JSON (records) 天然结构化
需要先过滤再进内存 SQL 让数据库先筛
网页上的表格 read_html 返回 list[DataFrame]

⚠️ Pickle 反序列化会执行任意代码,只读自己生成的文件;跨 pandas 大版本也可能读不出来,长期存储一律用 Parquet。

剪贴板与 HTML

Python5 行
pd.read_clipboard()                          # 输出: 从剪贴板解析(Windows / macOS 开箱可用)
df.to_clipboard(index=False)                 # 输出: 直接粘回 Excel
tables = pd.read_html(html_str)              # 输出: [df1, df2, ...] ← 页面上所有 <table>
pd.read_html('https://example.com/tb', match='关键词', flavor='lxml')
print(df.to_html(index=False))               # 输出: <table>...</table>

⚠️ read_html 在 Linux 上需要额外装 lxml 或 html5lib;它返回的是列表,即使页面只有一张表也要取 [0]。

大文件策略

三板斧:usecols 砍列 → dtype 定死类型 → chunksize 流式聚合。探查阶段先用 nrows 看结构。

Python11 行
dtypes = {'id': 'int32', 'city': 'category', 'amount': 'float32'}
head = pd.read_csv('big.csv', nrows=1000)            # 输出: 先看列名与类型再定 dtype

total = pd.Series(dtype='int64')
for chunk in pd.read_csv('big.csv', usecols=['city', 'amount'],
                         dtype=dtypes, chunksize=200_000):
    total = total.add(chunk.groupby('city')['amount'].sum(), fill_value=0)
    # 输出: 峰值内存 ≈ 单个 chunk,而不是整表

reader = pd.read_csv('big.csv', iterator=True)
reader.get_chunk(50)                                  # 输出: 手动取 50 行

⚠️ 分块累加类别计数别用 dict 手工加,直接让两个 Series .add(..., fill_value=0),索引会自动对齐补零。

脏 CSV 一次读干净

导出的报表常带注释头、千分位、货币符号、混合编码和 -999 哨兵值。全部在 read_csv 一层处理掉,不要读进来再慢慢洗。

Python26 行
import pandas as pd
from io import StringIO

raw = """## 导出批次 20260101 ##
ID|Name|Amount|Signup|Note
1|张三|"1,200.50"|2026/01/05|新客
2|李四|-999|2026/01/06|
3|王五|980.00|bad_date|VIP
"""

df = pd.read_csv(StringIO(raw),
                 sep='|',
                 skiprows=1,
                 usecols=['ID', 'Name', 'Amount', 'Signup'],
                 dtype={'ID': 'int32', 'Name': 'string'},
                 na_values=[''],
                 thousands=',',
                 converters={'Amount': lambda x: float(str(x).replace(',', '')
                                                            .strip('¥').strip())},
                 encoding='utf-8')

df['Signup'] = pd.to_datetime(df['Signup'], format='%Y/%m/%d', errors='coerce')
df['Amount'] = df['Amount'].replace(-999, pd.NA).astype('Float64')
df.dtypes        # 输出: ID int32 / Name string / Amount Float64 / Signup datetime64[us]
df.isna().sum()  # 输出: Amount 1  Signup 1  ← -999 与坏日期都被变成缺失
df.shape         # 输出: (3, 4)  ← 注释行没进来,Note 列被 usecols 砍掉

要点:converters 拿到的是原始文本,thousands 对它不生效,去千分位和货币符号得自己写;skiprows 干掉注释头、na_values 干掉空串,比读进来再 replace 更省内存。

多 sheet 汇总与格式互转

各分公司各交一张 sheet,要合成一张总表并同时产出 CSV(给下游)、Parquet(给管道)、Excel 汇总(给老板)。

Python25 行
import pandas as pd
import numpy as np

# 先造输入:三个分公司各一张 sheet
rng = np.random.default_rng(0)
with pd.ExcelWriter('branches.xlsx', engine='openpyxl') as w:
    for b in ['华东', '华南', '华北']:
        pd.DataFrame({'date': pd.date_range('2026-01-01', periods=4),
                      'amount': rng.integers(100, 500, 4)}).to_excel(w, sheet_name=b, index=False)

sheets = pd.read_excel('branches.xlsx', sheet_name=None)    # 输出: {'华东': df, '华北': df, '华南': df}
combined = (pd.concat(sheets.values(), keys=sheets.keys(), names=['branch'])
              .reset_index('branch')
              .reset_index(drop=True))
combined.shape                                              # 输出: (12, 3)

combined.to_csv('all.csv', index=False, encoding='utf-8-sig')   # Excel 可直开的 UTF-8
combined.to_parquet('all.parquet', index=False)                 # 管道中间产物
combined.to_json('all.json', orient='records', force_ascii=False, indent=2)

summary = combined.groupby('branch')['amount'].agg(['sum', 'mean', 'count'])
with pd.ExcelWriter('report.xlsx', engine='openpyxl') as w:
    summary.to_excel(w, sheet_name='汇总')
    combined.to_excel(w, sheet_name='明细', index=False)
summary['sum'].idxmax()                                     # 输出: '华北'(销售额最高的分公司,随种子而定)

要点:pd.concat(dict.values(), keys=dict.keys()) 能把“文件名/sheet 名”直接变成一列标识列,是批量合并的固定套路。

数据类型与转换

dtype 全景

dtype 说明 备注
int64 / int32 整数 Windows 上 numpy 默认 int32
float64 / float32 浮点 含 np.nan 的整数列会被抬成 float
bool numpy 布尔 不允许缺失
object 任意 Python 对象 慢且占内存,应尽量避免
str 字符串 Pandas 3.x 默认,取代 object,可存 pd.NA
category 有限枚举 省内存 + 可保顺序
datetime64[us] 时刻 配 .dt 访问器(3.x 默认微秒;2.x 是 [ns])
timedelta64[s] 时长 配 .dt 访问器(3.x 默认秒)
Int64(大写 I) 可空整型 有缺失也不用变浮点
Python17 行
import numpy as np
import pandas as pd

df = pd.DataFrame({'i': [1, 2, 3],
                   'f': [1.1, 2.2, 3.3],
                   'b': [True, False, True],
                   's': ['a', 'b', 'c'],
                   'c': pd.Categorical(['甲', '乙', '甲']),
                   'd': pd.date_range('2026-01-01', periods=3),
                   't': pd.to_timedelta([1, 2, 3], unit='D')})

df.dtypes            # 输出: i int64 / f float64 / b bool / s str / c category / d datetime64[us] / t timedelta64[s]
df['s'].dtype        # 输出: dtype('str')  ← 3.x 默认已是 str,不再是 object
pd.Series([1, 2, None]).dtype                 # 输出: float64(有缺失的普通整数被抬成浮点)
pd.Series([1, 2, None], dtype='Int64').dtype  # 输出: Int64(可空整型保住整数语义)
pd.Series([True, False, pd.NA], dtype='boolean').dtype  # 输出: boolean(可空布尔,值必须本身就是真/假)
df.info(memory_usage='deep')                  # 输出: 逐列 dtype + 非空计数 + 真实内存

⚠️ Pandas 3.x 起字符串列默认 str dtype。老代码里 select_dtypes(include='object') 抓不到字符串列了,要改成 include='str' 或先 convert_dtypes()。

astype 与陷阱

Python5 行
df['i'].astype('int32')                       # 输出: dtype int32
df.astype({'i': 'int32', 'f': 'float32'})     # 输出: 一次转多列
df['f'].astype('int64')                       # 输出: 直接截断小数(不是四舍五入)
df['i'].astype('str')                         # 输出: '1' '2' '3'
df['i'].astype('category')                    # 输出: category

⚠️ astype(int) 遇到 'abc' 或缺失值会直接抛异常。含脏数据的列先 pd.to_numeric(..., errors='coerce') 再转,才不会崩。

⚠️ astype 返回新对象。Pandas 3.x 不推荐 inplace=True,统一写 df['c'] = df['c'].astype(...),整行赋值用 df.loc[:, 'c'] = ... 避免链式赋值。

to_numeric

Python7 行
s = pd.Series(['1', '2', 'abc', '4'])
pd.to_numeric(s)                              # 输出: 抛 ValueError(默认是 errors='raise')
pd.to_numeric(s, errors='coerce')             # 输出: [1.0, 2.0, NaN, 4.0]
pd.to_numeric(s, errors='coerce').astype('Int64')  # 输出: [1, 2, <NA>, 4]
big = pd.Series([1, 2, 3], dtype='int64')
pd.to_numeric(big, downcast='integer')        # 输出: int8(能装下的最小整型)
pd.to_numeric(pd.Series([1.0, 2.0]), downcast='float')   # 输出: float32

to_datetime / to_timedelta

Python11 行
pd.to_datetime(pd.Series(['2026-01-01', 'bad']))                  # 输出: 抛错
pd.to_datetime(pd.Series(['2026-01-01', 'bad']), errors='coerce') # 输出: 第二个变 NaT
pd.to_datetime(pd.Series(['01/02/2026']), format='%d/%m/%Y')      # 输出: 2026-02-01(日/月/年)
pd.to_datetime(pd.Series(['2026年01月01日']), format='%Y年%m月%d日')
pd.to_datetime(pd.DataFrame({'year': [2026], 'month': [1], 'day': [15]}))  # 多列合成日期
pd.to_datetime([1_800_000_000], unit='s')                         # 输出: 秒级时间戳转时刻

pd.to_timedelta([1, 2], unit='D')             # 输出: 1 days / 2 days
pd.to_timedelta(['1 days', '02:00:00'])       # 输出: 1 days 00:00:00 / 0 days 02:00:00
s.dt.year, s.dt.month, s.dt.day_name(), s.dt.to_period('M')       # 输出: .dt 访问器
td.dt.days, td.dt.total_seconds()             # 输出: timedelta 侧的 .dt

⚠️ 混合格式的日期列务必写死 format:否则 pandas 逐元素嗅探,既慢又可能把 01/02 解析反。Pandas 2.2+ 起 errors='ignore' 已废弃,只能 raise / coerce。

convert_dtypes 与 infer_objects

Python6 行
raw = pd.DataFrame({'a': [1, 2, 3], 'b': ['x', 'y', None]}, dtype=object)
raw.dtypes                                # 输出: a object, b object
raw.infer_objects().dtypes                # 输出: a int64, b str(只推断数值,不做可空化)
raw.convert_dtypes().dtypes               # 输出: a Int64, b string(换 pandas 原生可空类型)
raw.convert_dtypes(dtype_backend='pyarrow').dtypes   # 输出: int64[pyarrow], string[pyarrow]
raw.convert_dtypes(convert_string=False).dtypes      # 输出: a Int64, b object(保留字符串原样)

⚠️ infer_objects() 只回收 object 里“明显是数值”的部分,不会产生可空类型;要全面升级用 convert_dtypes()。后者会把整数列变 Int64,与某些 numpy 下游库交互时需要再 .astype('int64') 转回去。

category 的用途与坑

Python16 行
c = pd.Categorical(['乙', '甲', '丙', '甲'], categories=['甲', '乙', '丙'], ordered=True)
c.categories    # 输出: Index(['甲', '乙', '丙'])
c.codes         # 输出: [1 0 2 0]
c.ordered       # 输出: True

rng = np.random.default_rng(1)
s = pd.Series(rng.choice(['甲', '乙', '丙'], 100_000))
s.memory_usage(deep=True)                          # 输出: 约 6800000 字节
s.astype('category').memory_usage(deep=True)       # 输出: 约 100000 字节(省 98%)

sc = s.astype('category')
sc.cat.rename_categories({'甲': 'A'})               # 输出: 类别改名,codes 不变
sc.cat.add_categories(['丁'])                      # 输出: 末尾追加一个类别
sc.cat.remove_unused_categories()                  # 输出: 删掉 codes 里没出现的类别
sc.cat.reorder_categories(['丙', '乙', '甲'], ordered=True)   # 输出: 重排并设序
sc.cat.as_ordered() / sc.cat.as_unordered()        # 输出: 切有序/无序
方法 作用
cat.categories 类别清单(Index)
cat.codes 每个元素的类别下标(-1 = 缺失)
cat.ordered 是否有序
cat.rename_categories 改类别名
cat.add/remove_categories 增删类别
cat.remove_unused_categories 清掉没用到的类别
cat.reorder/set_categories 重排 / 全量重设
cat.as_ordered / as_unordered 切有序性

⚠️ 给 category 列赋一个不在 categories 里的新值会静默变成 NaN。必须先 add_categories。

⚠️ groupby 一个 category 列时,早期 pandas 会列出所有类别 × 所有分组的笛卡尔积(未出现的组合补 0)。Pandas 3.x 已默认 observed=True;老代码若依赖补零,要显式写 observed=False。

select_dtypes 与类型检查

Python12 行
df.select_dtypes(include='number').columns.tolist()        # 输出: ['i', 'f']
df.select_dtypes(include=['datetime', 'timedelta']).columns.tolist()  # 输出: ['d', 't']
df.select_dtypes(include='str').columns.tolist()           # 输出: ['s'](3.x 用 str 而非 object)
df.select_dtypes(include='category').columns.tolist()      # 输出: ['c']
df.select_dtypes(exclude='number')                         # 输出: 非数值列

pd.api.types.is_integer_dtype(df['i'])          # 输出: True
pd.api.types.is_float_dtype(df['f'])            # 输出: True
pd.api.types.is_bool_dtype(df['b'])             # 输出: True
pd.api.types.is_string_dtype(df['s'])           # 输出: True
pd.api.types.is_datetime64_any_dtype(df['d'])   # 输出: True
pd.api.types.is_categorical_dtype(df['c'])      # 输出: True

内存与 downcast

Python6 行
df.memory_usage(deep=True)                      # 输出: 每列真实字节(含字符串对象开销)
df.memory_usage(deep=True).sum() / 1024**2      # 输出: 总 MB
df.info(memory_usage='deep')                    # 输出: 逐列 + 总计

num = df.select_dtypes('number')
num.apply(pd.to_numeric, downcast='unsigned')   # 输出: 数值列统一降到最小可用位宽

百万行内存瘦身

百万行 × 5 列的表直接读进来常占几百 MB。按“整数降位宽 + 低基数列转 category + 浮点转 float32 + 字符串用 str dtype”四步压一遍,通常能砍掉 70% 以上。

Python27 行
import numpy as np
import pandas as pd

n = 500_000
rng = np.random.default_rng(0)
big = pd.DataFrame({
    'id': np.arange(n),
    'city': rng.choice(['北京', '上海', '广州', '深圳', '杭州'], n),
    'level': rng.choice(['A', 'B', 'C'], n),
    'amount': rng.random(n) * 1000,
    'flag': rng.choice([True, False], n),
    'note': rng.choice(['ok', 'pending', 'fail'], n),
})

before = big.memory_usage(deep=True).sum() / 1024**2       # 输出: 约 90.8 MB

opt = big.copy()
opt['id'] = opt['id'].astype('int32')
opt['city'] = opt['city'].astype('category')
opt['level'] = opt['level'].astype('category')
opt['note'] = opt['note'].astype('category')
opt['amount'] = opt['amount'].astype('float32')

after = opt.memory_usage(deep=True).sum() / 1024**2        # 输出: 约 5.7 MB
round(1 - after / before, 3)                               # 输出: 0.937(省下 94%)
opt.dtypes    # 输出: id int32 / city category / level category / amount float32 / flag bool / note category
(opt['amount'].sum(), big['amount'].sum())                 # 输出: 两个和量级一致(float32 有微小精度损失)

要点:memory_usage(deep=True) 才统计字符串的真实开销,不加 deep 只算指针;低基数列(取值种类远少于行数)是 category 的主要收益来源。

脏数据类型批量规范化

从 CSV/Excel 读进来的表,数值常被存成带符号的字符串、日期格式不统一、还混着 None 和空串。按列分类型批量规范,再统一验一遍 dtype。

Python22 行
import numpy as np
import pandas as pd

raw = pd.DataFrame({
    'order_id': ['1001', '1002', '1003', '1004'],
    'amount': ['¥1,200.5', '980', 'N/A', ' 2,000 '],
    'qty': ['2', '3', '', '5'],
    'signup': ['2026/01/05', '2026-01-06', 'bad', '20260107'],
    'vip': ['true', 'false', '', 'true'],
})

raw['order_id'] = raw['order_id'].astype('int32')
raw['amount'] = pd.to_numeric(raw['amount'].str.replace(r'[¥,\s]', '', regex=True),
                              errors='coerce').astype('Float64')
raw['qty'] = pd.to_numeric(raw['qty'], errors='coerce').astype('Int64')
raw['signup'] = pd.to_datetime(raw['signup'], format='mixed', errors='coerce')
raw['vip'] = raw['vip'].map({'true': True, 'false': False}).astype('boolean')
raw['amount'] = raw['amount'].ffill()                      # 输出: 第三行 NaN 用前一个值补

raw.dtypes     # 输出: order_id int32 / amount Float64 / qty Int64 / signup datetime64[us] / vip boolean
raw.isna().sum()   # 输出: amount 0  qty 1  signup 1  vip 1  ← 脏值全部转为缺失,无一处报错中断
raw.select_dtypes(include='number').columns.tolist()       # 输出: ['order_id', 'amount', 'qty']

要点:清洗顺序固定为「去符号 → to_numeric(errors='coerce') → 转可空 dtype」;Float64/Int64 让缺失值与正确类型共存,不必为了存 NaN 而退回 float。

缺失值处理

三种缺失标记

标记 出现位置 语义
np.nan 浮点列 数值缺失,nan != nan
pd.NA Int64 / boolean / string pandas 可空类型统一缺失
pd.NaT datetime64 / timedelta64 时间缺失
None object 列 Python 原生,进 DataFrame 多被转成上面三者
Python8 行
import numpy as np
import pandas as pd

np.nan == np.nan                                     # 输出: False ← 永远别用 == 判缺失
pd.isna(pd.NA), pd.isna(None), pd.isna(np.nan), pd.isna(pd.NaT)   # 输出: (True, True, True, True)
pd.Series([1, pd.NA], dtype='Int64').isna()          # 输出: [False, True]
pd.Series([1, np.nan]).dtype                         # 输出: float64(普通整型被迫抬精度)
pd.Series([1, pd.NA], dtype='Int64').dtype           # 输出: Int64(可空整型保持整数语义)

先统计,再决定

Python7 行
rpt = pd.DataFrame({'缺失数': df.isna().sum(),
                    '缺失率': df.isna().mean(),
                    'dtype': df.dtypes.astype(str)}).sort_values('缺失率', ascending=False)
df.isna().sum().sum()                                # 输出: 全表缺失单元格总数
df.isna().any(axis=1).sum()                          # 输出: 至少缺一列的行数
df['col'].isna().groupby(df['grp']).mean()           # 输出: 各组缺失率(看缺失是否集中在某群体)
df.isna().value_counts()                             # 输出: 缺失组合模式的分布

处理前至少回答三个问题:缺失比例多大?缺失是否集中在某个时间段 / 地区 / 数据源?缺失本身是否携带信息?

dropna

Python5 行
df.dropna()                          # 输出: how='any',含任一缺失的行都丢
df.dropna(how='all')                 # 输出: 整行全缺才丢
df.dropna(subset=['金额', '城市'])    # 输出: 只在关键列上判定
df.dropna(thresh=3)                  # 输出: 至少 3 个非空才保留
df.dropna(axis=1, how='all')         # 输出: 整列全缺才丢列

⚠️ dropna 返回新对象。Pandas 3.x 不推荐 inplace=True,统一写 df = df.dropna(...)。删除前留一份原始数据,别做不可逆操作。

fillna

Python12 行
df['金额'].fillna(0)                                 # 输出: 标量填充
df.fillna({'金额': 0, '城市': '未知'})                # 输出: 每列不同值
df['金额'].fillna(df['金额'].median())                # 输出: 中位数比均值抗异常值
df['城市'].fillna(df['城市'].mode()[0])               # 输出: 众数填充

s = pd.Series([10, np.nan, np.nan, 20, np.nan, 30])
s.ffill()                 # 输出: [10, 10, 10, 20, 20, 30]
s.bfill()                 # 输出: [10, 20, 20, 20, 30, 30]
s.ffill(limit=1)          # 输出: [10, 10, NaN, 20, 20, 30]
df.ffill(axis=1)          # 输出: 按列向右填充
df.groupby('站点')['值'].ffill()   # 输出: 只在组内前向填充,不跨组
df.groupby('班级')['分'].transform(lambda x: x.fillna(x.median()))  # 组内中位数填充

⚠️ 3.x 直接调 s.ffill() / s.bfill(),别再用 fillna(method='ffill')(已废弃)。前向填充适合“状态在下一次更新前不变”的业务数据,不适合价格、浓度这类本身持续变化的量。

interpolate

Python6 行
ts = pd.Series([10, 12, np.nan, np.nan, 20, 23, np.nan, 28],
               index=pd.date_range('2026-01-01', periods=8, freq='D'))
ts.interpolate()                     # 输出: 线性插值 → 14.67, 17.33, 25.5(等距索引下与 method='time' 相同)
ts.interpolate(method='time')        # 输出: 按真实时间刻度加权(索引不等距时与上不同)
ts.interpolate(limit_area='inside')  # 输出: 只补内部缺失,两端保留 NaN(默认就是 'inside')
ts.interpolate(limit_direction='both')         # 输出: 两端的缺失也向后/向前补上

⚠️ 只对随索引连续变化的量插值。价格、计数这类跳跃量做线性插值等于凭空造数据;样条/多项式插值(method='spline')需装 SciPy,且可能插出超出合理范围的值。

replace 把哨兵值变 NaN

Python7 行
raw = pd.DataFrame({'值': [1, -999, 3, ''], '备注': ['ok', 'None', None, 'N/A']})
clean = raw.replace([-999, '', 'None', 'N/A', 'NULL', 'null'], np.nan)
clean['值'] = pd.to_numeric(clean['值'], errors='coerce').astype('Int64')

raw['值'].replace(-999, np.nan)                      # 输出: 单值替换
raw.replace({'值': {-999: np.nan, 0: np.nan}})       # 输出: 按列给映射
raw.replace(r'^\s*$', np.nan, regex=True)            # 输出: 纯空白串变 NaN

⚠️ 顺序必须是「先 replace 再 astype」,反了会把哨兵值算进统计里。0 往往是真实观测值,别默认把 0 当缺失。

聚合中的默认行为

Python7 行
df['x'].sum()                                # 输出: 跳过 NaN(skipna=True 是默认)
df['x'].count()                              # 输出: 只数非缺失
df.groupby('g')['x'].mean()                  # 输出: 全缺的组结果是 NaN
df.groupby('g')['x'].sum()                   # 输出: 全缺的组结果是 0 ← 与 mean 不一致
df.groupby('g')['x'].sum(min_count=1)        # 输出: 全缺的组变回 NaN
df.dropna(subset=['g']).groupby('g')['x'].mean()   # groupby 默认丢弃分组键为 NaN 的行
customers['income_missing'] = customers['income'].isna().astype('int8')  # 缺失指示变量

⚠️ sum() 把“全缺失组”算成 0(0 个非缺失相加),mean() 却算成 NaN。要区分二者就用 min_count=1。

缺失报告 + 分组填充

拿到一张新表先出一份缺失报告,按缺失率降序排,再对数值列走组内中位数、类别列填“未知”。

Python24 行
import numpy as np
import pandas as pd

df = pd.DataFrame({
    'dept': ['研发', '研发', '研发', '销售', '销售', '销售'],
    'salary': [20000, np.nan, 24000, 12000, np.nan, 9000],
    'level': ['P7', None, 'P6', 'P5', 'P5', None],
    'city': ['北京', '北京', None, '上海', '上海', '上海'],
})

def missing_report(frame):
    return pd.DataFrame({'dtype': frame.dtypes.astype(str),
                         'missing': frame.isna().sum(),
                         'ratio': frame.isna().mean().round(3)}
                        ).sort_values('ratio', ascending=False)

missing_report(df)                                   # 输出: salary 与 level 各缺 2 行、city 缺 1 行,按缺失率降序

df['salary'] = df.groupby('dept')['salary'].transform(lambda s: s.fillna(s.median()))
df['level'] = df['level'].fillna('未知')
df['city'] = df.groupby('dept')['city'].transform(lambda s: s.ffill().bfill())

df.isna().sum().sum()                                # 输出: 0
df.groupby('dept')['salary'].mean()                  # 输出: 研发 22000.0  销售 10500.0

要点:groupby(...).transform(lambda s: s.fillna(...)) 返回与原表等长的 Series,可直接赋回原列——这是“按组填充”的标准写法。

时间序列插值与哨兵清洗

监控数据常见两种脏:传感器离线留下的 -999 哨兵值,和采样缺失造成的断点。前者替换成 NaN,后者按时间插值补齐。

Python15 行
import numpy as np
import pandas as pd

idx = pd.date_range('2026-01-01', periods=10, freq='h')
ts = pd.DataFrame({'temp': [21.0, 21.5, -999, np.nan, 23.1,
                            np.nan, 24.0, 24.2, -999, 25.0]}, index=idx)

ts['temp'] = ts['temp'].replace(-999, np.nan)          # 输出: 哨兵值先变 NaN
ts['temp_linear'] = ts['temp'].interpolate(method='time')   # 输出: 按真实时间刻度插值
ts['temp_pad'] = ts['temp'].ffill(limit=1)             # 输出: 只补一格,长断点保留 NaN
ts['gap'] = ts['temp'].isna().astype('int8')           # 输出: 缺失指示列,供下游标记

ts['temp_linear'].round(2).tolist()[:5]                # 输出: [21.0, 21.5, 22.03, 22.57, 23.1]
ts['temp'].isna().sum()                                # 输出: 4(两个 -999 + 两个原生缺失)
ts['temp_linear'].isna().sum()                         # 输出: 0

要点:method='time' 才按索引的真实时间间隔加权;等距索引用默认 linear 即可。补完务必留一列 gap 标记哪些是补出来的。

排序与排名

sort_values

Python16 行
import numpy as np
import pandas as pd

df = pd.DataFrame({'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
                   'age': [25, 30, 35, 25, 28],
                   'score': [85, 92, 78, 85, 95],
                   'city': ['北京', '上海', '北京', '广州', '上海']})

df.sort_values('age')                                  # 输出: 升序
df.sort_values('score', ascending=False)               # 输出: 降序
df.sort_values(['city', 'score'], ascending=[True, False])   # 输出: 城市升序,组内分数降序
df.sort_values('score', na_position='first')           # 输出: NaN 排最前(默认 'last')
df.sort_values('score', kind='stable')                 # 输出: 稳定排序,保住原相对次序
df.sort_values('score', ignore_index=True)             # 输出: 索引重排为 0..n-1
df.sort_values('name', key=lambda s: s.str.lower())    # 输出: 忽略大小写排序
df.sort_values('name', key=lambda s: s.str.len())      # 输出: 按字符串长度排序

⚠️ ascending 传列表时必须与 by 等长;只给一个布尔值则应用于所有列。key 接收的是整个 Series(不是逐元素),必须返回等长的 Series/数组。

⚠️ sort_values 返回新对象,写 df = df.sort_values(...)。要原地重排索引加 ignore_index=True,否则索引会跟着乱序带过去。

sort_index

Python6 行
df.set_index('name').sort_index()            # 输出: 按索引标签升序
df.sort_index(ascending=False)               # 输出: 降序
df.sort_index(axis=1)                        # 输出: 按列名排序
df.sort_index(level=0)                       # 输出: MultiIndex 指定层
df.sort_index(level=['city', 'name'])        # 输出: 多层依次排序
df.sort_index(na_position='first')           # 输出: 索引为 NaN 的排最前

rank

method 并列取值 对 [85, 92, 85, 78]
average 平均名次(默认) [2.5, 4, 2.5, 1]
min 取最小名次 [2, 4, 2, 1]
max 取最大名次 [3, 4, 3, 1]
first 按出现顺序 [2, 4, 3, 1]
dense 密集排名,不留空位 [2, 3, 2, 1]
Python9 行
sc = pd.Series([85, 92, 85, 78])
sc.rank()                        # 输出: [2.5, 4.0, 2.5, 1.0]
sc.rank(method='min')            # 输出: [2.0, 4.0, 2.0, 1.0]
sc.rank(method='max')            # 输出: [3.0, 4.0, 3.0, 1.0]
sc.rank(method='first')          # 输出: [2.0, 4.0, 3.0, 1.0]
sc.rank(method='dense')          # 输出: [2.0, 3.0, 2.0, 1.0]
sc.rank(ascending=False)         # 输出: [2.5, 1.0, 2.5, 4.0] ← 高分是第 1 名
sc.rank(pct=True)                # 输出: [0.625, 1.0, 0.625, 0.25](百分位)
sc.rank(na_option='top')         # 输出: 缺失排最前(默认 'keep' 保留 NaN)

nlargest / nsmallest

Python4 行
df.nlargest(3, 'score')                      # 输出: 分数最高的 3 行
df.nsmallest(3, 'score')                     # 输出: 最低的 3 行
df.nlargest(3, ['score', 'age'])             # 输出: 多列字典序取 Top3
df.nlargest(3, 'score', keep='all')          # 输出: 并列全部保留(默认 keep='first')

⚠️ 只要 TopN 就用 nlargest,别 sort_values(...).head(n)——前者是部分排序,大数据上快得多。

与 groupby 配合取组内 TopN

Python13 行
g = pd.DataFrame({'cls': ['A', 'A', 'A', 'B', 'B', 'B'],
                  'stu': ['甲', '乙', '丙', '丁', '戊', '己'],
                  'score': [85, 92, 78, 88, 95, 82]})

g['rank_in_cls'] = g.groupby('cls')['score'].rank(ascending=False, method='dense')
# 输出: A 组 [2,1,3]、B 组 [2,1,3]

g.sort_values(['cls', 'score'], ascending=[True, False]).groupby('cls').head(2)
# 输出: 每组前 2 名(整行)

g.loc[g.groupby('cls')['score'].rank(ascending=False, method='first') <= 2]
# 输出: 同上,但用 .loc[...] 做安全筛选赋值
g.groupby('cls')['score'].nlargest(2)        # 输出: MultiIndex(cls, 原索引) 的 Series

⚠️ groupby.head(n) 要求先 sort_values 排好序,否则取的是“原表顺序的前 n 条”而不是 TopN。给新列赋值一律 df.loc[mask, 'col'] = ...,避免链式赋值。

班级成绩排行榜

一份学生成绩表,要出总分排名、单科百分位、以及“同名次并列”两种口径(dense 与 min)的差异。

Python20 行
import pandas as pd

df = pd.DataFrame({
    'name': ['甲', '乙', '丙', '丁', '戊', '己'],
    'cls': ['A', 'A', 'A', 'B', 'B', 'B'],
    'chinese': [88, 92, 85, 78, 95, 82],
    'math': [90, 90, 76, 88, 88, 92],
})
df['total'] = df['chinese'] + df['math']

board = df.sort_values('total', ascending=False, ignore_index=True)
board['rank_avg'] = board['total'].rank(ascending=False)
board['rank_dense'] = board['total'].rank(ascending=False, method='dense')
board['rank_first'] = board['total'].rank(ascending=False, method='first')
board['pct'] = board['total'].rank(pct=True)

board[['name', 'total', 'rank_avg', 'rank_dense', 'rank_first']]
# 输出: 戊183→1/1/1;(乙,己) 同为 182 → avg 2.5、dense 2、first 2 与 3;甲178→4/3/4
board.nlargest(3, 'total')[['name', 'total']]     # 输出: 戊 183、乙 182、己 182
df.nsmallest(1, 'math')[['name', 'math']]         # 输出: 丙 76

要点:rank(ascending=False) 让高分成为第 1 名;并列场景下 dense 与 first 的名次会分叉,出排行榜前先定好口径。

组内 TopN 与多因子综合排名

每个班取前 2 名,同时按「收益 50% + 流动性 30% + 估值 20%」做一次加权综合排名——这是排序 + 排名最典型的组合场景。

Python21 行
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
st = pd.DataFrame({'code': [f'S{i:02d}' for i in range(1, 11)],
                   'ret': rng.normal(0, 0.1, 10).round(4),
                   'vol': rng.integers(1000, 10000, 10),
                   'pe': rng.uniform(5, 30, 10).round(2)})

top2 = (st.sort_values('ret', ascending=False)
          .groupby(st['vol'] > 5000).head(2))     # 输出: 高/低流动性两组各前 2

st['r_ret'] = st['ret'].rank(ascending=False)     # 收益越高越好
st['r_vol'] = st['vol'].rank(ascending=False)     # 成交量越大越好
st['r_pe'] = st['pe'].rank()                      # 估值越低越好
st['composite'] = st['r_ret'] * 0.5 + st['r_vol'] * 0.3 + st['r_pe'] * 0.2
st['final'] = st['composite'].rank()

st.nsmallest(3, 'final')[['code', 'ret', 'vol', 'pe', 'final']]
# 输出: 综合分最低(最优)的 3 只
st.sort_values('final', ignore_index=True).head(3)   # 输出: 等价写法,nlargest 更快

要点:多因子打分的标准做法是「各自 rank() 消除量纲 → 加权求和 → 再 rank() 一次」;方向相反的因子(如 PE)用 rank() 不翻转即可纳入。

字符串操作

.str 访问器速查

方法 作用
len 长度
lower / upper / title / capitalize / swapcase 大小写
strip / lstrip / rstrip 去空白(可给字符集)
split / rsplit 切分,expand=True 展开成 DataFrame
get(i) / str[a:b] 取第 i 段 / 按字符切片
contains / match / fullmatch 包含 / 开头匹配 / 全量匹配
startswith / endswith 前后缀(非正则,支持元组)
replace 替换,regex=True 开正则
extract / extractall 抽捕获组 → DataFrame / 展开成多行
findall 全部匹配 → 列表
count 子串出现次数
pad / zfill 补位 / 左侧补 0
repeat 重复 n 次
cat 按分隔符拼接整个 Series
slice 按位置切片
join 把元素(列表)用分隔符连起来
get_dummies 按分隔符拆分后 one-hot
removeprefix / removesuffix 去前缀 / 后缀
Python11 行
import pandas as pd

s = pd.Series(['  Hello  ', 'WORLD', 'Pandas', 'data analysis'])
s.str.len()                                  # 输出: [9, 5, 6, 13]
s.str.strip().str.lower()                    # 输出: ['hello', 'world', 'pandas', 'data analysis']
s.str.upper().str.replace('A', '@')          # 输出: ['  HELLO  ', 'WORLD', 'P@ND@S', 'D@T@ @N@LYSIS']
s.str.slice(0, 3)                            # 输出: ['  H', 'WOR', 'Pan', 'dat']
s.str.count('a')                             # 输出: [0, 0, 2, 4]
s.str.pad(10, side='right', fillchar='.')    # 输出: 右补 '.' 到 10 位
s.str.zfill(6)                               # 输出: 左补 0 到 6 位
s.str.startswith(('W', 'P'))                 # 输出: [False, True, True, False]

⚠️ .str 只对字符串 dtype 生效。数值列要先 .astype('str');遗留的 object 列建议先 convert_dtypes() 转成 str。

链式清洗

.str 每次返回一个新的字符串 Series,可以无限接龙。清洗脏文本的固定顺序:剥空白 → 统一大小写 → 替换/去噪。

Python5 行
messy = pd.Series(['  PRODUCT-A  ', 'product-b   ', '  Product-C', 'PRODUCT-D'])
clean = messy.str.strip().str.lower().str.replace('-', '_')
# 输出: ['product_a', 'product_b', 'product_c', 'product_d']

messy.str.strip().str.removeprefix('PRODUCT-')     # 输出: 只剥大写前缀,大小写敏感

⚠️ replace 在 Pandas 3.x 默认 regex=False(字面替换)。要写正则必须显式 regex=True,否则 +、*、\d 会当普通字符处理。

正则要点

Python10 行
t = pd.Series(['Phone: 138-1234-5678', 'Email: a@b.com', 'No contact'])

t.str.extract(r'(\d{3})-(\d{4})-(\d{4})')            # 输出: 3 列 DataFrame,不匹配填 NaN
t.str.extract(r'(?P<区号>\d{3})-(?P<号码>\d{4}-\d{4})')  # 输出: 列名直接取命名分组
t.str.extractall(r'(\d+)')                            # 输出: MultiIndex(原行, match) 的多行结果
t.str.findall(r'\d+')                                 # 输出: [['138','1234','5678'], [], []]
t.str.contains(r'\d{3}-', regex=True)                 # 输出: [True, False, False]
t.str.match(r'Phone:')                                # 输出: [True, False, False](只匹配开头)
t.str.replace(r'\d{3}-\d{4}-\d{4}', '***-****-****', regex=True)   # 输出: 脱敏
t.str.replace(r'(\w+)@(\w+)', r'\2@\1', regex=True)  # 输出: 用 \1 \2 反向引用

⚠️ contains 遇到缺失值返回 NaN 而不是 False,直接做布尔索引会报错。显式给 na=False:s.str.contains(pat, na=False)。

⚠️ extract 只抽第一个匹配,一行里有多个目标要用 extractall。extractall 结果是 MultiIndex,需要 .droplevel('match') 或 reset_index() 才能配回原表。

split 拆列与 get_dummies

Python14 行
emails = pd.Series(['u1@example.com', 'u2@test.org', 'u3@example.com'])
emails.str.split('@', expand=True)                   # 输出: 2 列 DataFrame
emails.str.split('@', expand=True).set_axis(['user', 'domain'], axis=1)
emails.str.split('@').str.get(1)                     # 输出: ['example.com', 'test.org', ...]
emails.str.get_dummies(sep='@')                      # 输出: one-hot 展开

tags = pd.Series(['a,b', 'b,c', 'a'])
tags.str.get_dummies(sep=',')                        # 输出: a/b/c 三列 0-1
tags.str.split(',', expand=True)                     # 输出: 两列,缺的填 None

names = pd.Series([['John', 'Doe'], ['Jane', 'Smith']])
names.str.join(' ')                                  # 输出: ['John Doe', 'Jane Smith']
pd.Series(['a', 'b', 'c']).str.cat(sep='-')          # 输出: 'a-b-c'(整个 Series 拼成一个串)
pd.Series(['a', 'b']).str.cat(pd.Series(['1', '2']), sep='')  # 输出: ['a1', 'b2']

⚠️ split 不加 expand=True 得到的是 list 列,后续还得 .str.get(i);要拆成多列就一步到位加 expand=True。

性能实测:.str、apply 与去重映射

.str 的价值是表达力(链式、可读、能处理缺失),不是无条件更快。默认 str dtype 是 Python 对象存储,.str 的每个方法都要完整遍历一遍;链式越长,遍历次数越多。真正快的做法是「先 unique() 去重 → 处理少量唯一值 → map() 回原表」。

Python23 行
import numpy as np
import pandas as pd
import time

big = pd.Series(np.random.default_rng(0).choice(['  A-1', 'b-22', 'C-333'], 200_000))

def bench(fn, n=5):
    ts = []
    for _ in range(n):
        t0 = time.perf_counter(); out = fn(); ts.append(time.perf_counter() - t0)
    return round(min(ts), 4), out

t_one, r_one = bench(lambda: big.str.strip())                      # 输出: ≈0.029s(单层)
t_two, r_two = bench(lambda: big.str.strip().str.upper())          # 输出: ≈0.060s(两层,翻倍)
t_map, r_map = bench(lambda: big.apply(lambda x: x.strip().upper()))  # 输出: ≈0.029s(单趟)
t_reg, _     = bench(lambda: big.str.replace(r'\s+', '', regex=True)) # 输出: ≈0.095s(正则最贵)

uniq = pd.Series(big.unique())
lut = pd.Series(uniq.str.strip().str.upper().to_numpy(), index=big.unique())
t_lut, r_lut = bench(lambda: big.map(lut))                         # 输出: ≈0.008s(最快)

r_lut.equals(r_two)      # 输出: True(去重映射与链式结果一致)
big.nunique()            # 输出: 3  ← 唯一值远少于行数,是去重映射的前提
写法 相对耗时 何时用
big.map(查找表) 最快 低基数列(唯一值远少于行数)首选
单层 .str.xxx() 中 常规清洗,够用
链式 .str.a().str.b() 中×层数 可读性优先,层数别太多
apply(lambda) 单趟 与单层 .str 相当 逻辑无法拆成内置方法时
.str.replace(regex=True) 最慢 非正则不可才用,能字面替换就别开

⚠️ 别把「.str 一定比 apply 快」当常识:在 Python 对象存储的 str 列上,两层 .str 明显慢于单趟 apply。数据量上百万后再考虑 dtype_backend='pyarrow' 的字符串列。

电商商品名清洗

商品名混着多余空白、大小写不一、价格带货币符号和千分位、描述里塞满感叹号。一次链式清洗加正则抽数字,全部用 .str 完成。

Python25 行
import pandas as pd

products = pd.DataFrame({
    'name': ['  iPhone 14 Pro  ', 'SAMSUNG Galaxy S23', 'xiaomi 13 pro'],
    'price': ['¥8,999', '¥7,999', '¥4,999'],
    'desc': ['Latest model!!!', 'Best seller   ', 'HOT SALE!!!'],
    'sku': ['7', '42', '305'],
})

products['name_clean'] = products['name'].str.strip().str.title()
products['price_num'] = (products['price']
                         .str.replace(r'[^\d.]', '', regex=True)
                         .astype('float64'))
products['desc_clean'] = (products['desc']
                          .str.strip()
                          .str.replace(r'!+', '', regex=True)
                          .str.capitalize())
products['sku_pad'] = products['sku'].str.zfill(6)
products['brand'] = products['name_clean'].str.split(' ').str.get(0)
products['is_hot'] = products['desc'].str.contains('HOT', case=False, na=False)

products['name_clean'].tolist()    # 输出: ['Iphone 14 Pro', 'Samsung Galaxy S23', 'Xiaomi 13 Pro']
products['price_num'].tolist()     # 输出: [8999.0, 7999.0, 4999.0]
products['sku_pad'].tolist()       # 输出: ['000007', '000042', '000305']
products['is_hot'].tolist()        # 输出: [False, False, True]

要点:「去符号 → astype」是价格列的标准流程;contains 一定要带 na=False,否则描述列有缺失时布尔索引会炸。

日志字段正则抽取

一份非结构化日志,要把手机号、日期级别、错误码分别抽成独立列,并按手机号前缀做脱敏。extract 用命名分组,列名一步到位。

Python22 行
import pandas as pd

logs = pd.DataFrame({'raw': [
    '2026-01-05 10:23:01 ERROR code=5001 user=13812345678',
    '2026-01-05 10:25:44 WARN  code=3002 user=13900001111',
    '2026-01-06 09:01:10 INFO  code=0 user=None',
]})

parts = logs['raw'].str.extract(
    r'(?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}) '
    r'(?P<level>\w+)\s+code=(?P<code>\d+)\s+user=(?P<phone>\d+|None)')
logs = logs.join(parts)

logs['level'].tolist()             # 输出: ['ERROR', 'WARN', 'INFO']
logs['phone'].tolist()             # 输出: ['13812345678', '13900001111', 'None']
logs['masked'] = logs['phone'].str.replace(r'(\d{3})\d{4}(\d{4})', r'\1****\2', regex=True)
logs['is_error'] = logs['level'].str.fullmatch('ERROR', na=False)
logs['dt'] = pd.to_datetime(logs['date'] + ' ' + logs['time'], format='%Y-%m-%d %H:%M:%S')

logs['masked'].tolist()            # 输出: ['138****5678', '139****1111', 'None']
logs['is_error'].tolist()          # 输出: [True, False, False]
logs['dt'].dtype                   # 输出: datetime64[us]

要点:extract 用 (?P<名字>...) 命名分组,返回的 DataFrame 列名直接就是字段名,join 回原表零额外处理;抽不中的行整行填 NaN,比 findall 更适合结构化落列。

高级技巧

MultiIndex 的创建与选择

Python20 行
import pandas as pd
import numpy as np

# from_tuples:显式给出层级组合
mi = pd.MultiIndex.from_tuples([('华东', '上海'), ('华东', '杭州'),
                                ('华南', '广州'), ('华南', '深圳')],
                               names=['region', 'city'])
pd.Series([120, 90, 80, 75], index=mi).index.nlevels        # 输出: 2

# from_product:笛卡尔积,层级全组合时最省事
len(pd.MultiIndex.from_product([['2024', '2025'], ['Q1', 'Q2'], ['A', 'B']],
                               names=['year', 'quarter', 'sku']))   # 输出: 8

# set_index:由列升格为索引层级
df = pd.DataFrame({'region': ['华东', '华东', '华南', '华南'],
                   'city': ['上海', '杭州', '广州', '深圳'],
                   'sales': [120, 90, 80, 75],
                   'orders': [30, 22, 18, 17]})
mdf = df.set_index(['region', 'city'])
mdf.loc['华东']                                            # 输出: 上海 / 杭州 两行
写法 用途
.loc[(k1, k2)] 元组精确定位,层数不变
.loc[k1] 只给第一层,返回子集
xs(key, level=) 取某一层,结果少一层
IndexSlice 每层独立切片
Python5 行
mdf.loc[('华东', '上海'), 'sales']                          # 输出: 120(标量)
mdf.xs('华东', level='region')                             # 输出: region 层消失的 2 行
mdf.xs(('华东', '上海'), level=['region', 'city'])          # 输出: 单行 Series
idx = pd.IndexSlice
mdf.loc[idx['华东', ['上海', '杭州']], idx['sales':'orders']]   # 输出: 2 × 2 子集

⚠️ 对未排序的 MultiIndex 切片会抛 UnsortedIndexError,先 sort_index();xs 不受此限制。

⚠️ set_index 默认返回副本;Pandas 3.x 的 Copy-on-Write 下 inplace=True 已不推荐,一律写 df = df.set_index(...)。

层级重排与拍平

Python13 行
mdf.swaplevel('region', 'city')                   # 输出: 索引变为 (city, region)
mdf.reorder_levels(['city', 'region'])            # 输出: 同上,可一次重排 3 层以上
mdf.sort_index(level=['region', 'city'], ascending=[True, False])
mdf.index.get_level_values('region')              # 输出: Index(['华东','华东','华南','华南'])

# 拍平:多级列 → 单级字符串列
agg = mdf.groupby(level='region').agg(['sum', 'mean'])
agg.columns.map('|'.join)                         # 输出: Index(['sales|sum', 'sales|mean', ...])
agg.droplevel(1, axis=1)                          # 输出: 列只剩 sales / orders(会重名)

flat = agg.copy()
flat.columns = flat.columns.map('{0[0]}_{0[1]}'.format)   # 改名 → sales_sum / sales_mean
mdf.droplevel('city')                             # 输出: 只剩 region 索引(会重复)

⚠️ droplevel 后索引或列名可能重复,groupby(level=) 与 .loc 会变含糊;通常 reset_index() 把层级变回列更稳。

crosstab 与 pivot_table 进阶

Python14 行
raw = pd.DataFrame({
    'region': ['华东', '华南', '华东', '华南', '华东'],
    'channel': ['线上', '线上', '线下', '线下', '线上'],
    'level': ['高', '中', '高', '低', '中'],
    'amount': [100, 80, 60, 40, 120]})

pd.crosstab(raw['region'], raw['channel'])                     # 输出: 2 × 2 频数表
pd.crosstab(raw['region'], raw['channel'], normalize='index')   # 输出: 行内占比(每行和 = 1)
pd.crosstab([raw['region'], raw['level']], raw['channel'], margins=True)  # 输出: 带 All 行列

raw.pivot_table(index='region', columns='channel', values='amount',
                aggfunc='sum', fill_value=0)                   # 输出: 2 × 2 求和,空洞填 0
raw.pivot_table(index='region', columns='channel', values='amount',
                aggfunc=['sum', 'mean'], margins=True, margins_name='合计')
参数 作用
normalize= 'index' / 'columns' / 'all' 归一化
margins=True 加 All 边际行列
aggfunc= 字符串、函数、列表,或 dict(逐列不同)
fill_value= 填补透视后的空洞
dropna= 是否丢弃全空列(默认 True)

⚠️ pivot(无 aggfunc)遇到重复键会直接报错——它只是重排不是聚合;需要聚合就用 pivot_table 或 groupby + unstack。

apply 高阶用法

Python11 行
d2 = pd.DataFrame({'a': [1., 2., 3.], 'b': [4., 5., 6.], 'c': [7., 8., 9.]})

d2.apply(np.sum)                                  # 输出: a 6.0 / b 15.0 / c 24.0(默认 axis=0)
d2.apply(np.sum, raw=True)                        # 输出: 同上;直接传 ndarray,省去 Series 构造

d2.apply(lambda r: [r['a'] + r['b'], r['a'] * r['c']], axis=1,
         result_type='expand')                    # 输出: DataFrame,2 列
d2.apply(lambda r: [r['a'] * 2, r['b'] * 2, r['c'] * 2], axis=1,
         result_type='broadcast')                 # 输出: 广播回原 shape(3 × 3)
d2.apply(lambda r: {'s': r.sum(), 'm': r.mean()}, axis=1,
         result_type='expand')                    # 输出: DataFrame,列名 s / m
result_type 效果
None(默认) list → Series 元素;dict → 展开成列
'expand' list / dict 一律展开成列
'reduce' 强制返回 Series
'broadcast' 广播回原 shape,列名沿用

⚠️ axis=1 的逐行 apply 要为每行构造一个 Series,是 Pandas 最慢的常见写法之一;能写成 d2['a'] + d2['b'] 就别用 apply。

pipe 链式管道

Python11 行
def drop_outliers(d, col, n_std=2):
    m, sd = d[col].mean(), d[col].std()
    return d.loc[(d[col] - m).abs() <= n_std * sd]

def add_share(d, col):
    return d.assign(**{f'{col}_share': d[col] / d[col].sum()})

(pd.DataFrame({'cat': list('AABBCC'), 'val': [10, 100, 20, 30, 15, 25]})
   .pipe(drop_outliers, 'val')          # 100 被当作异常值剔除
   .pipe(add_share, 'val')
   .nlargest(2, 'val'))                 # 输出: 2 行,带 val_share 列

⚠️ pipe 传入的是整个 DataFrame(不是 Series),回调第一个参数必须是 df;额外参数用关键字传,位置参数容易与 pipe(f, *args) 混淆。

transform 广播

Python5 行
g = pd.DataFrame({'grp': list('AAABBB'), 'v': [1, 2, 3, 10, 20, 30]})
g['grp_mean'] = g.groupby('grp')['v'].transform('mean')   # 输出: [2, 2, 2, 20, 20, 20]
g['pct']      = g['v'] / g.groupby('grp')['v'].transform('sum')
g['zscore']   = g.groupby('grp')['v'].transform(lambda s: (s - s.mean()) / s.std())
g['rank']     = g.groupby('grp')['v'].transform('rank', ascending=False)
agg transform
返回长度 每组长 1 与原表等长
赋回原列 需 map / merge 回连 可直接赋值

⚠️ transform 的函数必须返回与分组等长的结果;返回标量虽被允许广播,但语义易混,建议明确写 'mean' 这类字符串。

np.select 分档与 factorize 编码

Python12 行
score = pd.Series([55, 72, 88, 91, 40, 66])
conds = [score < 60, score < 80, score < 90, score >= 90]
labels = ['不及格', '及格', '良好', '优秀']
np.select(conds, labels, default='未知')          # 输出: ['不及格' '及格' '良好' '优秀' ...]
pd.cut(score, bins=[0, 60, 80, 90, 100], labels=labels, right=False)  # 输出: 同上(连续分箱)

codes, uniques = pd.factorize(pd.Series(['b', 'a', 'b', 'c', 'a']))
codes                                             # 输出: [0 1 0 2 1](按出现顺序)
uniques                                           # 输出: ['b' 'a' 'c']
pd.factorize(pd.Series(['b', 'a', np.nan, 'c']))[0]  # 输出: [0 1 -1 2](NaN 记为 -1)
uniques[codes]                                    # 输出: ['b' 'a' 'b' 'c' 'a'](反查还原)
pd.Series(['b', 'a', 'b']).astype('category').cat.codes  # 输出: 0 2 0(按字典序编码)

⚠️ np.select 的条件列表顺序优先,宽松条件写在前面会把后面的档位全吃掉;default= 建议必填,否则无匹配处落到 NaN / 空串。

merge_asof 近似连接

Python10 行
trades = pd.DataFrame({'ts': pd.to_datetime(['2024-01-01 09:31', '2024-01-01 09:35']),
                       'qty': [100, 200]})
quotes = pd.DataFrame({'ts': pd.to_datetime(['2024-01-01 09:30', '2024-01-01 09:33',
                                             '2024-01-01 09:40']),
                       'px': [10.1, 10.3, 10.8]})

pd.merge_asof(trades, quotes, on='ts', direction='backward')   # 输出: 匹配不晚于成交的最近报价
pd.merge_asof(trades, quotes, on='ts', direction='forward',
              tolerance=pd.Timedelta('3min'))                  # 输出: 超时未匹配则 px = NaN
pd.merge_asof(trades, quotes, on='ts', direction='nearest')    # 输出: 取时间上最近的报价

⚠️ merge_asof 要求两侧按键升序,否则结果错乱;direction 只有 'backward' / 'forward' / 'nearest',分组内匹配用 by=。

query 与 eval

Python5 行
big = pd.DataFrame({'a': np.arange(1_000_000.0), 'b': np.arange(1_000_000.0) * 2})
thr = 500_000
big.query('a > @thr and b < 1_500_000')           # 输出: 筛选结果;@ 引用外部变量
res = big.eval('c = a + b')                       # 输出: 带新列 c 的新 DataFrame
big = big.assign(c=lambda d: d.eval('a + b'))     # Pandas 3.x 推荐:不用 inplace
场景 建议
条件筛选、链式管道可读性 query(支持 @var 与 and / or)
多列一次性算术、避免中间对象 eval
列少 / 行数 < 10 万 普通 df['a'] + df['b'] 更快(省掉表达式解析)

⚠️ eval 在运行时解析字符串,列名带空格或中文会解析失败;列名不干净时改用 assign(**{'净 额': ...}) 或直接列运算。

accessor 与自定义注册

内置 accessor:.dt(datetime)、.str(字符串)、.cat(category)。

Python16 行
@pd.api.extensions.register_series_accessor('km')
class KmAccessor:
    def __init__(self, obj):
        if not pd.api.types.is_float_dtype(obj):
            raise AttributeError('KmAccessor 仅支持 float Series')
        self._obj = obj
    def to_miles(self):
        return self._obj * 0.621371

pd.Series([1.0, 10.0, 42.195]).km.to_miles()      # 输出: [0.621 6.214 26.219]

d = pd.Series(pd.date_range('2024-01-01', periods=3))
d.dt.dayofweek                                    # 输出: [0 1 2]
d.dt.to_period('M').dt.start_time                 # 输出: 各月月初时间戳
pd.Series(['a_b', 'c_d']).str.split('_').str[1]   # 输出: ['b' 'd']
pd.Series(['x', 'y']).astype('category').cat.codes  # 输出: [0 1]

⚠️ 自定义 accessor 名不能与既有属性冲突(str / dt / cat / plot 等),注册后全局生效;dtype 校验写在 __init__ 里并抛 AttributeError。

window 进阶

Python15 行
ts = pd.DataFrame({'v': [10, 15, 13, 18, 20, 17, 22, 25, 23, 28]})
ts['v'].rolling(3).mean()                              # 输出: NaN NaN 12.67 15.33 17.0 18.33 ...
ts['v'].rolling(3, win_type='gaussian').sum(std=1.0)   # 输出: 高斯加权和(需 SciPy)
ts['v'].rolling(3, win_type='triang').mean()           # 输出: 三角加权均值
ts['v'].rolling(3, center=True).mean()                 # 输出: 窗口居中,前后各取 1
ts['v'].expanding().mean()                             # 输出: 累计均值
ts['v'].ewm(alpha=0.5, adjust=False).mean()            # 输出: 指数加权均值

td = pd.Series(np.arange(5.0), index=pd.date_range('2024-01-01', periods=5, freq='D'))
td.rolling('3D').mean()                                # 输出: 3 天时间窗口(支持不等间隔)

gdf = pd.DataFrame({'g': ['a'] * 5 + ['b'] * 5, 'v': range(10)})
gdf.groupby('g')['v'].rolling(2).mean()                # 输出: MultiIndex(g, 原索引)
gdf.assign(ma2=gdf.groupby('g')['v'].rolling(2).mean()
                   .reset_index(level=0, drop=True))   # 拍回原索引后再赋列

⚠️ win_type= 依赖 SciPy;rolling().apply(func, raw=True) 传 ndarray,默认的 raw=False 传 Series,前者明显更快。

大区-城市-月份的多层看板

一份四城、三个月的销售明细,既要按大区下钻到城市,又要拍平成单级列交给 BI 工具。固定种子 rng 现造数据,可直接复制运行。

Python21 行
# 沿用本章开头的 import:pd / np
rng = np.random.default_rng(11)
rows = [(r, c, m, round(float(rng.gamma(4, 300)), 1))
        for r, c in [('华东', '上海'), ('华东', '杭州'), ('华南', '广州'), ('华南', '深圳')]
        for m in ['2024-01', '2024-02', '2024-03']]
sales = pd.DataFrame(rows, columns=['region', 'city', 'month', 'amount'])

mi = sales.groupby(['region', 'city', 'month'])['amount'].sum().to_frame('amount')
mi.loc[('华东', '上海')]                                 # 输出: 该城 3 个月的 Series
mi.xs('华东', level='region')                            # 输出: 2 城 × 3 月,region 层消失

board = (mi.unstack('month')                             # month 升为列层级
           .swaplevel(0, 1, axis=1)                      # 列层级 → (month, amount)
           .sort_index(axis=1, level=0))
board.loc['华东']                                        # 输出: 上海 / 杭州 两行

flat = board.copy()
flat.columns = flat.columns.map('{0[0]}_{0[1]}'.format)   # 输出: 2024-01_amount ...
flat['total'] = flat.sum(axis=1).round(1)
flat.index = flat.index.map('|'.join)                     # 输出: '华东|上海' 复合键
flat.round(1)                                             # 输出: 4 × 4 单级列看板

要点:先用 groupby 建三层索引做下钻,再 unstack + swaplevel + columns.map 一次性拍平;to_frame() 保证 unstack 后列层级是 (month, amount) 而不是裸月份。

成交与报价的近似匹配 + 自动分档

三笔成交要匹配时间上最近的一次报价,算出成本后按数量分档,并对代码做整数编码——日志对齐里最常见的组合拳。

Python17 行
# 沿用本章开头的 import:pd / np
q = pd.DataFrame({'ts': pd.to_datetime(['2024-03-01 09:30', '2024-03-01 09:32',
                                        '2024-03-01 09:35', '2024-03-01 09:40',
                                        '2024-03-01 09:44']),
                  'bid': [10.02, 10.05, 10.11, 10.08, 10.20]})
t = pd.DataFrame({'ts': pd.to_datetime(['2024-03-01 09:31', '2024-03-01 09:36',
                                        '2024-03-01 09:43']),
                  'symbol': ['A', 'A', 'B'], 'qty': [100, 300, 200]})

m = pd.merge_asof(t.sort_values('ts'), q.sort_values('ts'), on='ts', direction='backward')
m['cost'] = (m['qty'] * m['bid']).round(2)
m['tier'] = np.select([m['qty'] < 150, m['qty'] < 250], ['小单', '中单'], default='大单')
m['sym_code'] = pd.factorize(m['symbol'])[0]

m[['ts', 'qty', 'bid', 'cost', 'tier', 'sym_code']]   # 输出: 3 行,bid 取不晚于成交的最近报价
m['cost'].sum()                                       # 输出: 总成本
m.groupby('tier')['cost'].sum()                       # 输出: 大单 / 小单 的成本分布

要点:merge_asof 负责「时间上最近」的对齐,np.select 负责一次性多档标记,factorize 负责把字符串代码转整数——三者串起来把对齐 + 打标 + 编码一步做完。

性能优化

先测量,再优化

Python14 行
import time
import io
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
df = pd.DataFrame({'a': rng.normal(size=500_000), 'b': rng.normal(size=500_000)})

t0 = time.perf_counter(); _ = df['a'] + df['b']; t_vec = time.perf_counter() - t0
round(t_vec * 1000, 2)                           # 输出: 约 1–3 ms(本机实测,随机器浮动)

# 下面是 Jupyter / IPython 魔力命令,不是脚本语法,直接复制到 .py 里会 SyntaxError
# %timeit df['a'] + df['b']                        # 自动多次取最优
# %timeit -n 10 -r 3 df['a'] + df['b']             # 指定循环次数与重复轮数
工具 适用
%timeit / %%timeit Jupyter,单行 / 整个 cell
time.perf_counter() 普通脚本,手动打点
memory_usage(deep=True) 内存占用
df.info() 行数 / dtype / 粗略内存

⚠️ 单次计时会被 CPU 缓存、GC 与系统负载污染;比较两种写法时交替跑几轮取中位数,别只信最快那一次。

四种写法的性能排序

Python14 行
n = 200_000
t = pd.DataFrame({'a': rng.normal(size=n), 'b': rng.normal(size=n)})

def loop_rows(d):                       # 最慢:每行构造 Series + 索引对齐
    return pd.Series([r[1]['a'] + r[1]['b'] for r in d.iterrows()])

def loop_tuples(d):                     # 次慢:命名元组,比 iterrows 快
    return pd.Series([r.a + r.b for r in d.itertuples(index=False)])

def use_apply(d):                       # 中:每行构造一个 Series
    return d.apply(lambda r: r['a'] + r['b'], axis=1)

def vectorized(d):                      # 最快:C 层整块算
    return d['a'] + d['b']

实测结论(20 万行,取本机数量级):

写法 相对耗时 说明
向量化 d['a'] + d['b'] 1× 基准(实测 ~1.4 ms)
d.eval('a + b') ~2–5× 列多时省下中间 Series
itertuples ~100–200× 命名元组访问
apply(axis=1) ~300–800× 每行构造 Series,逐行回调
iterrows ~3000–8000× 每行构造 Series + 索引对齐,最慢

⚠️ 该排序只在「同样能表达为列运算」时成立;逻辑确实无法向量化(依赖上一行状态)时,用 itertuples 或先 to_numpy() 再循环,切勿用 iterrows 做数值计算。

dtype 优化

Python15 行
raw = pd.DataFrame({
    'id': np.arange(100_000),
    'city': rng.choice(['南京', '苏州', '无锡'], size=100_000),
    'amount': rng.gamma(2, 50, size=100_000),
    'flag': rng.choice([0, 1], size=100_000)})
raw.memory_usage(deep=True).sum() / 1024**2       # 输出: 约 9.6 MB

opt = raw.astype({'id': 'int32', 'city': 'category',
                  'amount': 'float32', 'flag': 'int8'})
opt.memory_usage(deep=True).sum() / 1024**2       # 输出: 约 0.95 MB(降约 10 倍)

pd.to_numeric(pd.Series(['1', '2', 'x']), errors='coerce')   # 输出: [1.0 2.0 NaN]
pd.to_numeric(pd.Series([1., 2., 3.]), downcast='float')     # 输出: float32
pd.to_numeric(pd.Series([1, 2, 3]), downcast='integer')      # 输出: int8
pd.to_numeric(pd.Series([1, 2, 3]), downcast='unsigned')     # 输出: uint8(需非负)
dtype 适用 每元素
category 低基数重复字符串(基数 / 行数 < 0.5) 2–4 B + 类别表
int8 / int16 / int32 整数且范围可控 1 / 2 / 4 B
float32 精度要求不高的度量 4 B
bool 二值 1 B

⚠️ int32 上限约 21 亿、int8 只有 −128~127;降位宽前先 .max() / .min() 确认范围,溢出在 Pandas 里不报错,会静默回绕。

逐列内存表

Python10 行
def mem_table(d):
    m = d.memory_usage(deep=True)
    return (pd.DataFrame({'dtype': d.dtypes.astype(str),
                          'MB': (m / 1024**2).round(3)})
              .drop(index='Index', errors='ignore')
              .sort_values('MB', ascending=False))

mem_table(raw)                                   # 输出: 按列降序的内存占用表
raw.memory_usage(deep=True, index=True)          # 输出: 含索引(默认 index=True)
pd.Series(['abc']).memory_usage(deep=False)      # 输出: 只算指针,不含字符串本体

⚠️ 不写 deep=True 时 object 列只算指针(8 B / 行),会严重低估;含字符串的 DataFrame 必须用 deep=True。

读写优化

Python15 行
# 读:只取需要的列 + 指定 dtype + 直接解析日期
d = pd.read_csv('large.csv', usecols=['id', 'time', 'value'],
                dtype={'id': 'int32', 'value': 'float32'}, parse_dates=['time'])

# 分块:无法一次装入时
parts = []
for chunk in pd.read_csv('large.csv', chunksize=100_000):
    parts.append(chunk.loc[chunk['value'] > 0]
                      .groupby('category', as_index=False)['value'].sum())
result = (pd.concat(parts, ignore_index=True)
            .groupby('category', as_index=False)['value'].sum())

# 写:列式格式
d.to_parquet('large.parquet', index=False)       # 需 pyarrow 或 fastparquet
pd.read_parquet('large.parquet', columns=['id', 'value'])
格式 读速 体积 保留 dtype
CSV 慢 大 否(每次重推断)
Parquet 快 小(压缩 + 列式) 是
Feather 极快 大 是(临时交换)
Pickle 快 中 是(不安全,别收外部文件)

⚠️ 只有「可分块再合并」的统计量(sum / count / 均值分子分母)适合 chunksize;精确分位数、全局排序、drop_duplicates 需要落盘或交给数据库做。

避免逐行增长

Python10 行
# 反例:每轮 concat 都复制整张表,O(n²)
bad = pd.DataFrame(columns=['a'])
for i in range(1000):
    bad = pd.concat([bad, pd.DataFrame({'a': [i]})], ignore_index=True)

# 正例:先攒 list,最后一次 concat
good = pd.concat([pd.DataFrame({'a': [i]}) for i in range(1000)], ignore_index=True)

# 更好:攒原始 Python list,最后一次性构造
best = pd.DataFrame({'a': [i ** 2 for i in range(1000)]})   # 输出: 1000 行

⚠️ 同理不要在循环里 df.loc[len(df)] = ...;必须逐格赋值就先预分配 pd.DataFrame(np.nan, index=..., columns=...) 再填充。

groupby 用内置聚合

Python11 行
sales = pd.DataFrame({
    'region': rng.choice(['east', 'west', 'north'], size=200_000),
    'amount': rng.gamma(2, 50, size=200_000),
    'unused': rng.normal(size=200_000)})

(sales[['region', 'amount']]                     # 先切掉无关列
   .groupby('region', as_index=False)
   .agg(total=('amount', 'sum'), mean=('amount', 'mean'), n=('amount', 'size')))

sales.groupby('region')['amount'].agg(lambda s: s.sum())   # 慢:Python 函数逐组回调
sales.groupby('region')['amount'].agg('sum')               # 快:Cython 快路径
快(内置) 慢(Python lambda)
'sum' 'mean' 'max' 'min' 'count' 'size' 'std' 'var' 'first' 'last' 'nunique' lambda s: ...
'median'(部分 Cython 优化) 自定义加权、自定义分位数

⚠️ 高基数列(如 user_id)做 groupby 会产生上百万组,比聚合本身慢得多;先 nunique() 估算分组数再决定。

eval / query 的适用场景

Python6 行
wide = pd.DataFrame(rng.normal(size=(500_000, 6)), columns=list('abcdef'))
%timeit wide.eval('a + b + c + d + e + f')       # 省掉 5 个中间 Series
%timeit wide['a'] + wide['b'] + wide['c'] + wide['d'] + wide['e'] + wide['f']

wide.query('a > 0 and b < 0')                    # 输出: 筛选后的行
wide.query('a > @cut', local_dict={'cut': 0.5})  # @ + local_dict 注入变量

适用:行数十万以上、表达式涉及 4 列以上、中间对象已成为内存瓶颈。不适用:小表(解析开销抵消收益)、列名不干净、需要单步调试的复杂逻辑。

numba

Python1 行
wide['a'].rolling(10).apply(lambda x: x.sum(), engine='numba', raw=True)  # 需 pip install numba

一行带过:apply / rolling().apply 加 engine='numba' 后函数被 JIT 编译成本地码,滚动窗口类计算常有数倍到数十倍提升;代价是首次编译耗时,且函数内只能用 NumPy 支持的运算。

inplace、Copy-on-Write 与安全赋值

Python10 行
pd.options.mode.copy_on_write = True             # 2.x 显式开启;Pandas 3.x 默认已为 True

src = df.copy()
view = src.head(10)
view.loc[:, 'a'] = 0                             # CoW 下不会污染 src(旧版本会静默改到 src)

# 安全赋值:一律用 .loc[行, 列]
src.loc[src['a'] > 0, 'tag'] = 'pos'             # 明确的行列定位
src['tag'] = src['tag'].ffill()                  # 前向填充;后向用 bfill()
src = src.assign(ratio=lambda x: x['a'] / x['b']) # 推荐:链式、返回新对象

⚠️ Pandas 3.x 下 inplace=True 已不推荐:CoW 让它不再省内存,还会打断方法链;链式赋值 df[df.a > 0]['b'] = 1 永远无效(作用在副本上),必须写 df.loc[df.a > 0, 'b'] = 1。

优化原则与检查清单

优先级:减少数据量 → 合理 dtype → 向量化 → 减少中间对象 → 分块 → 换执行引擎(DuckDB / Polars / Dask)→ 微观优化。

  1. 结果是否正确?(先写对,再写快)
  2. 真正的瓶颈在 I/O、CPU 还是内存?
  3. 能否少读行、少读列(usecols / chunksize)?
  4. dtype 是否最优(category / downcast)?
  5. 能否把 apply(axis=1) / iterrows 改写成列运算?
  6. 是否在重复计算同一个 mask 或转换?
  7. 中间对象是否过多(改用 assign / eval 链)?
  8. 是否适合分块?
  9. 是否需要换格式(Parquet)或执行引擎?
  10. 优化前后是否用同一份数据、同一方法测量?

dtype 瘦身前后对比

一份 30 万行的行为日志,城市与等级都是低基数重复字符串、金额不需要双精度。先量一遍内存,再逐列降位宽与转 category,最后验证分组聚合照常工作。

Python24 行
# 沿用本章开头的 import:pd / np / time / io
rng = np.random.default_rng(5)
n = 300_000
log = pd.DataFrame({
    'user_id': rng.integers(1, 50_000, n),
    'city': rng.choice(['北京', '上海', '广州', '深圳', '成都', '杭州'], n),
    'level': rng.choice(['gold', 'silver', 'bronze'], n),
    'amount': rng.gamma(2, 80, n),
    'success': rng.choice([True, False], n)})
before = log.memory_usage(deep=True).sum() / 1024**2    # 输出: 约 40 MB

def shrink(d):
    out = d.copy()
    ints = out.select_dtypes(include='integer').columns
    out = out.astype({c: pd.to_numeric(out[c], downcast='unsigned').dtype for c in ints})
    return out.astype({'amount': 'float32', 'city': 'category', 'level': 'category'})

slim = shrink(log)
after = slim.memory_usage(deep=True).sum() / 1024**2    # 输出: 约 2.6 MB
round(before / after, 1)                                # 输出: 约 16(降低一个数量级)

mem_table(slim)                                          # 输出: 逐列内存表,city/level 骤降
slim.groupby('city', observed=True)['amount'].agg(['sum', 'mean']).round(1)  # 输出: 分组照常
slim['level'].cat.categories                             # 输出: ['bronze' 'gold' 'silver'](字典序)

要点:先 select_dtypes 找出可降位宽的列,用 to_numeric(downcast=) 自动取最小可用类型,再对低基数列转 category;groupby(..., observed=True) 是 category 分组的标配,避免为未出现的类别生成空组。

50 万行 CSV 的分块聚合

内存装不下整表时,把「过滤 + 分组求和」下沉到每个 chunk,最后再对局部结果做一次二次聚合。数据用 io.StringIO 现造,可直接复制运行。

Python25 行
# 沿用本章开头的 import:pd / np / time / io
rng = np.random.default_rng(3)
buf = io.StringIO()
pd.DataFrame({'category': rng.choice(list('ABCD'), 50_000),
              'value': rng.gamma(2, 30, 50_000)}).to_csv(buf, index=False)
buf.seek(0)

def chunked_agg(src, key, val, size=10_000):
    parts = []
    for chunk in pd.read_csv(src, chunksize=size, usecols=[key, val],
                             dtype={key: 'category', val: 'float32'}):
        parts.append(chunk.loc[chunk[val] > 0]
                          .groupby(key, observed=True, as_index=False)[val]
                          .agg(['sum', 'count']))
    return parts

parts = chunked_agg(buf, 'category', 'value')
total = (pd.concat(parts, ignore_index=True)
           .groupby('category', observed=True)
           .agg(sum=('sum', 'sum'), count=('count', 'sum')))
total['mean'] = (total['sum'] / total['count']).round(3)

len(parts)                               # 输出: 5(50,000 / 10,000)
total.round(1)                           # 输出: 4 行 × sum / count / mean
total['sum'].sum()                       # 输出: 与一次性读入的结果一致

要点:chunksize + usecols + dtype 三件套把峰值内存压到单块大小;局部 sum/count 可二次合并,所以「先分后合」的结果与整表聚合完全一致——换成 mean 就必须拆成分子分母才能合并。

实战套路

数据清洗流水线

一份带重复行、脏日期、负数金额和缺失渠道的订单 CSV,要按「读入 → 类型修正 → 去重 → 缺失值 → 异常值 → 派生列 → 导出」完整走一遍,最后落成可直接复用的 clean_orders() 函数。CSV 内联在字符串里,无需外部文件即可运行。

Python63 行
import pandas as pd
import numpy as np
import io

RAW = """order_id,user_id,amount,city,order_date,channel
1001,U01,120.5,上海,2024-01-03,app
1001,U01,120.5,上海,2024-01-03,app
1002,U02,,北京,2024-01-05,web
1003,U03,89.0,北京,2024-01-07,app
1004,U04,1500.0,广州,2024-01-09,web
1005,U05,-20.0,上海,2024-01-11,app
1006,U06,75.0,深圳,2024/01/13,web
1007,U07,310.0,北京,2024-01-15,app
1008,U08,60.0,广州,2024-01-17,
1009,U09,95.5,上海,2024-01-19,app
"""

def clean_orders(src: str) -> pd.DataFrame:
    # 1. 读入 + 类型修正(先定型,再谈去重和统计)
    df = (pd.read_csv(io.StringIO(src))
            .astype({'user_id': 'string', 'channel': 'string'})
            .assign(order_date=lambda d: pd.to_datetime(d['order_date'],
                                                        format='mixed', errors='coerce'),
                    amount=lambda d: pd.to_numeric(d['amount'], errors='coerce')))
    # 2. 去重:同一 order_id 只留最后一条
    df = df.drop_duplicates(subset='order_id', keep='last')
    # 3. 缺失值:关键列缺失直接丢行,类别列填默认值
    df = df.dropna(subset=['order_id', 'amount', 'order_date'])
    df['channel'] = df['channel'].fillna('unknown')
    df = df.astype({'city': 'category', 'channel': 'category'})
    # 4. 异常值:金额非正视为无效,再用 IQR 上界截尾并打标
    df = df.loc[df['amount'] > 0]
    q1, q3 = df['amount'].quantile([0.25, 0.75])
    hi = q3 + 1.5 * (q3 - q1)
    df = df.assign(is_outlier=lambda d: d['amount'] > hi,
                   amount_clipped=lambda d: d['amount'].clip(upper=hi))
    # 5. 派生列 + 收尾整理
    return (df.assign(month=lambda d: d['order_date'].dt.to_period('M').astype(str),
                      weekday=lambda d: d['order_date'].dt.day_name())
              .reset_index(drop=True))

def validate(d, key='order_id'):
    """清洗后的自检:主键唯一 + 关键列无缺失 + 金额为正"""
    return {'rows': len(d),
            'dup_keys': int(d[key].duplicated().sum()),
            'na_cells': int(d.isna().sum().sum()),
            'nonpositive': int((d['amount'] <= 0).sum()),
            'outliers': int(d['is_outlier'].sum())}

orders = clean_orders(RAW)
raw_rows = len(pd.read_csv(io.StringIO(RAW)))
orders.shape                                    # 输出: (7, 10)(去重 1、缺金额 1、金额非正 1)
orders.dtypes                                   # 输出: city/channel 为 category,order_date 为 datetime64
orders['amount'].describe().round(1)            # 输出: count 7 / mean 321.4 / max 1500.0
orders.loc[orders['is_outlier'], ['order_id', 'amount']]   # 输出: 1004  1500.0
orders['amount_clipped'].max()                  # 输出: 415.1(IQR 上界)
orders.groupby('city', observed=True)['amount'].sum().round(1)  # 输出: 各城市金额合计
validate(orders)                                # 输出: {'rows': 7, 'dup_keys': 0, 'outliers': 1, ...}
(raw_rows - len(orders)) / raw_rows * 100       # 输出: 30.0(清洗掉的行占比,超过 5% 需复盘)

out = io.StringIO()
orders.to_csv(out, index=False)                 # 导出(本例写内存)
orders.to_parquet('orders_clean.parquet', index=False)     # 生产用:保留 dtype 的列式格式

要点:每一步都写成 assign / loc / drop* 的链式表达,函数输入输出都是 DataFrame,整体即可塞进 .pipe() 复用;类型修正必须放在去重和统计之前,否则字符串比较与聚合都会出错。

关键技巧清单

  • 读入即定型:dtype= / parse_dates= 显式声明,别依赖推断。
  • 脏日期用 pd.to_datetime(..., errors='coerce', format='mixed'),坏值变 NaT 而不是抛异常。
  • drop_duplicates(subset=..., keep='last') 指定业务主键,keep 决定保留哪条。
  • 缺失分两类:关键列 dropna(subset=) 丢行;非关键列 fillna() 填默认。
  • 类别列转 category 前先 fillna,否则 NaN 会成为一个独立类别。
  • 异常值优先用 IQR 上界 q3 + 1.5 * (q3 - q1),比固定阈值抗分布漂移。
  • 截尾用 clip(upper=) 并同时打标 is_outlier,保留原始值便于回溯。
  • 派生列统一在 assign 里做,dt.to_period('M') 比手写年/月拼接更稳。
  • .reset_index(drop=True) 收尾,避免后续 iloc 踩到空洞索引。
  • 导出优先 to_parquet(index=False),CSV 会丢掉 category 与 datetime 精度。
  • 清完必自检:主键 duplicated().sum()、关键列 isna().sum()、异常计数各查一遍。
  • 记录清洗前后行数差,丢行比例超过 5% 说明上游或清洗规则有问题,应复盘而不是继续往下走。
  • 整个流程写成 def clean(df) -> df,就能用 .pipe(clean) 嵌进任何管道。

探索性分析 EDA 报告

一份 500 行的用户表(含数值列与类别列、注入 3% 缺失),要一次产出概览统计、缺失率、唯一值数、单维分组、二维透视、相关矩阵和分箱分布。报表以 dict of DataFrame 返回,便于逐块查看,也方便后面一键写多 sheet Excel。

Python47 行
# 沿用 18.1 的 import:pd / np / io
rng = np.random.default_rng(0)
n = 500
eda = pd.DataFrame({
    'user_id': [f'U{i:04d}' for i in range(n)],
    'age': rng.integers(18, 65, n).astype(float),
    'city': rng.choice(['北京', '上海', '广州', '深圳'], n),
    'channel': rng.choice(['app', 'web', 'mini'], n),
    'orders': rng.poisson(3, n),
    'amount': np.round(rng.gamma(2, 150, n), 2)})
eda.loc[rng.choice(n, 15, replace=False), 'age'] = np.nan     # 注入 3% 缺失

def eda_report(d, num_cols, cat_cols, target):
    out = {}
    out['shape'] = d.shape
    out['dtypes'] = d.dtypes.astype(str)
    out['overview'] = d[num_cols].describe().T.round(2)
    out['missing_pct'] = (d.isna().mean() * 100).round(2)
    out['nunique'] = d[cat_cols].nunique()
    out['by_' + cat_cols[0]] = (d.groupby(cat_cols[0], observed=True)
                                  .agg(users=(d.columns[0], 'size'),
                                       amount_mean=(target, 'mean'),
                                       amount_sum=(target, 'sum'))
                                  .round(2).sort_values('amount_sum', ascending=False))
    out['pivot'] = (d.pivot_table(index=cat_cols[0], columns=cat_cols[1],
                                  values=target, aggfunc='mean', observed=True).round(1))
    out['corr'] = d[num_cols].corr().round(2)
    out['bins'] = pd.cut(d[num_cols[0]], bins=[0, 25, 35, 45, 55, 100],
                         right=False).value_counts().sort_index()
    # 构成占比 + Top-N 贡献度
    out['share'] = (d[cat_cols[0]].value_counts(normalize=True) * 100).round(2)
    top = d.nlargest(20, target)
    out['top_contrib'] = (top[target].sum() / d[target].sum() * 100).round(2)
    return out

rep = eda_report(eda, ['age', 'orders', 'amount'], ['city', 'channel'], 'amount')
rep['shape']                       # 输出: (500, 6)
rep['overview']                    # 输出: 3 行 × count/mean/std/min/25%/50%/75%/max
rep['missing_pct']                 # 输出: age 3.0,其余 0.0
rep['nunique']                     # 输出: city 4 / channel 3
rep['by_city']                     # 输出: 4 行 × users / amount_mean / amount_sum
rep['pivot']                       # 输出: 城市 × 渠道 的均值矩阵
rep['corr']                        # 输出: 3 × 3 相关矩阵
rep['bins']                        # 输出: 年龄段人数分布(NaN 段自动排除)
rep['share']                       # 输出: 各城市占比(%)
rep['top_contrib']                 # 输出: 约 13.7(前 20 人贡献的金额占比)
eda['amount'].quantile([0.9, 0.99]).round(1)  # 输出: 长尾检查,判断是否需截尾

要点:把 EDA 拆成「单列概览 + 缺失 + 唯一值 + 单维分组 + 二维透视 + 相关 + 分箱」七块固定产出,封装成返回 dict 的函数,换一份数据只需改列名参数;observed=True 与 round() 让报表直接可贴进文档。

关键技巧清单

  • describe().T 转置后是「一行一列」,比默认的「一行一统计量」更好扫。
  • 缺失率统一用 isna().mean() * 100,列多时比 isna().sum() 更可比。
  • 分组一律写命名聚合 agg(users=('user_id', 'size'), ...),列名由你定,不用再改 MultiIndex。
  • groupby(..., observed=True) 配 category,避免生成未出现类别的空行。
  • 二维对比用 pivot_table(index=, columns=, values=, aggfunc=),fill_value= 补洞。
  • 相关矩阵只喂数值列 select_dtypes('number').corr(),混入字符串会报错。
  • 分箱用 pd.cut(..., right=False) 表示左闭右开;等频分箱换 pd.qcut。
  • value_counts().sort_index() 保证按区间顺序而非计数顺序排列。
  • 报表用 dict 承载,配 pd.ExcelWriter 就能一键导出多 sheet。
  • 数据大时先 .sample(50_000) 或按时间切片,EDA 不需要全量。
  • 构成占比用 value_counts(normalize=True) * 100,比 value_counts() 更直观。
  • 长尾检查看 quantile([0.9, 0.99]),比值悬殊说明后续建模或可视化需要截尾/取对数。
  • Top-N 贡献度 nlargest(n, col).sum() / total,快速判断指标是否被少数样本主导。
  • 相关只看线性关系,非线性用散点或分箱后均值折线交叉验证,别直接当因果。

时间序列分析

两年日频 GMV,中间故意挖掉 5 天模拟漏报。要补齐缺失日期、做周/月重采样、算 7 日与 30 日滚动均值,并给出环比与同比,同时把「每月实际有数据的天数」作为质量指标输出。

Python45 行
# 沿用 18.1 的 import:pd / np / io
rng = np.random.default_rng(7)
full = pd.date_range('2022-01-01', '2023-12-31', freq='D')
keep = full.delete([10, 11, 12, 100, 400])           # 挖 5 天,模拟漏报
daily = pd.DataFrame({'date': keep, 'gmv': np.round(rng.gamma(3, 400, len(keep)), 2)})

def ts_kit(d, date_col, val_col):
    # 1. 建索引 + 补齐缺失日期(先 reindex 出空洞,再填)
    s = d.set_index(date_col)[val_col].sort_index()
    s = s.reindex(pd.date_range(s.index.min(), s.index.max(), freq='D'))
    s.index.name = date_col
    filled = s.ffill().bfill()                       # 前向填充,首日再后向兜底

    # 2. 重采样:日 → 周 / 月
    weekly = filled.resample('W').sum()
    monthly = filled.resample('MS').sum()

    # 3. 滚动统计
    ma7 = filled.rolling(7, min_periods=1).mean()
    ma30 = filled.rolling(30, min_periods=1).mean()

    # 4. 同比 / 环比
    out = pd.DataFrame({'gmv': monthly.round(2),
                        'mom_pct': (monthly.pct_change() * 100).round(2),
                        'yoy_pct': (monthly.pct_change(12) * 100).round(2)})

    # 5. 趋势列与数据质量列
    out['ma7'] = ma7.resample('MS').mean().round(2)
    out['ma30'] = ma30.resample('MS').mean().round(2)
    out['days_reported'] = s.notna().resample('MS').sum().astype(int)
    # 6. 累计值
    out['cum'] = monthly.cumsum().round(0)
    return out, filled, weekly, ma7, ma30

monthly, filled, weekly, ma7, ma30 = ts_kit(daily, 'date', 'gmv')
daily.shape[0]                                  # 输出: 725(原始漏报 5 天)
len(filled), filled.isna().sum()                # 输出: (730, 0)(空洞已补齐)
monthly.shape                                   # 输出: (24, 7)
monthly.head(2)                                 # 输出: 首行 mom_pct / yoy_pct 为 NaN
monthly['yoy_pct'].dropna().round(1).head(3)    # 输出: 从第 13 个月起才有同比
monthly.loc[monthly['days_reported'] < monthly.index.days_in_month]  # 输出: 漏报月份
monthly['gmv'].idxmax()                         # 输出: gmv 最高的月份起点
monthly['cum'].iloc[-1]                         # 输出: 两年累计 GMV
filled.groupby(filled.index.dayofweek).mean().round(1)  # 输出: 周内效应(0 = 周一)
filled.diff().abs().nlargest(3)                 # 输出: 日环比跳变最大的 3 天,用于排查异常

要点:reindex 到连续日历把「隐式缺失」变成显式 NaN,之后 ffill/bfill、resample 与 pct_change 全都在完整网格上算,避免漏报日把环比窗口悄悄缩短。

关键技巧清单

  • 时间列先 set_index 再 sort_index(),resample 与 rolling('3D') 都依赖有序索引。
  • 补齐缺失日期:reindex(pd.date_range(start, end, freq='D')),洞变成 NaN 才可见。
  • 填充三选一:ffill() 前向、bfill() 后向、interpolate() 线性;业务口径优先 ffill。
  • 频次代号:'D' 日 / 'W' 周 / 'MS' 月初 / 'M' 月末 / 'QS' 季初 / 'YS' 年初。
  • resample('MS').sum() 用于流量型指标(GMV、订单数),mean() 用于水位型指标。
  • 滚动窗口 rolling(7, min_periods=1) 让开头也有值;不写 min_periods 则前 6 天为 NaN。
  • 环比 pct_change()、同比 pct_change(12)(月度);日频同比用 pct_change(365)。
  • 滚动后再 resample 取月内均值,把日级均线压缩成月级趋势列。
  • s.notna().resample('MS').sum() 统计每月实际有数据的天数——数据质量指标。
  • index.days_in_month 配合上面的天数即可筛出漏报月份,别用固定 30 天。
  • 累计值用 cumsum(),进度类指标(年度目标完成率)直接除目标值即可。
  • 周内效应 groupby(series.index.dayofweek).mean(),比额外造一列 weekday 更省事。
  • diff().abs().nlargest(n) 定位跳变最大的日期,是排查漏报与录入错误的固定手法。
  • 日历口径要对齐:月用 resample('MS'),周用 'W',别混用导致同比窗口错位。