目录211
- 概述与安装
- 安装与验证
- 定位与生态位
- Series 与 DataFrame 心智模型
- 最小可运行骨架
- 读写与保存速查 (完整用法见后续)
- 数据清洗速查 (完整用法见)
- 版本与依赖检查
- 显示设置 pd.set_option / pd.options.display.*
- 与 NumPy 的互转
- 环境自检与显示设置一次性配好
- 订单明细清洗 → 聚合 → 导出
- Series
- 创建 Series
- 索引与标签
- 属性速查
- 常用方法
- 运算与索引对齐
- 布尔筛选与 where/mask (DataFrame 侧的系统梳理见 §4.7、§4.9)
- 缺失值处理 (系统梳理见后续)
- 与 NumPy / dict / DataFrame 互转
- 易错点
- 成绩统计与排名
- 销量 × 单价的对齐核算
- 气温序列的缺失值清洗
- DataFrame
- 创建方式与三个关键字
- 结构属性
- 查看概览
- 列操作
- 行操作
- 索引对象 Index
- 与 NumPy / dict / list 互转
- 复制与视图
- 易错点速查
- 从零搭一张员工表并自我体检
- API 返回的字典列表对齐字段
- 循环采集时别一边跑一边拼表
- 数据选择与索引
- 选择器速查表:什么时候用哪个
- [] 操作符:选列与切行
- loc:基于标签
- iloc:基于位置
- loc vs iloc 切片语义对照
- at / iat:单个标量
- 布尔索引与多条件组合
- query():字符串表达式
- where / mask / np.where / np.select:保形状的条件替换
- filter / get / take / xs / sample
- 赋值:只用 .loc,别用链式
- 索引的坑:重复索引 / sort_index / reindex / get_indexer
- 组合技:idxmax / nlargest / duplicated / np.select (排序与 TopN 见 §14)
- 成绩分档与偏科筛查
- 订单的时间窗口与 Top-N 筛选
- 脏表去重、清洗与跨表回写
- 数据清洗
- 缺失值:检测、删除、阈值 (与 §13 重叠,这里只讲清洗场景下的取舍)
- 填充与插值 (另见 §13.4 / §13.5)
- 重复值:duplicated / drop_duplicates
- 异常值:IQR、z-score、clip
- 类型转换 (系统梳理见 §12)
- 字符串清洗 (系统梳理见 §15)
- 重命名列与 replace 映射
- 清洗流水线套路
- 用户表端到端清洗
- 销售额异常值识别与缩尾
- 订单流水去重与最新状态
- 数据合并与连接
- concat:沿轴堆叠
- merge:how 的四种连接
- merge 的键与列冲突参数
- join:以索引为主的简写
- 合并后行数变化与 validate 诊断
- combine_first 与 update
- merge_asof:按最近时间匹配 (进阶用法见 §16.8)
- 订单-客户-产品三表拼宽
- 合并后行数爆炸排查
- 多源指标的对齐与补全
- 分组与聚合
- split-apply-combine 心智模型
- groupby 的参数
- 常用聚合函数与 size/count/nunique
- agg:单函数、多函数、命名聚合
- agg / transform / apply 三者的区别
- groupby.apply 的性能坑
- 组内取值与组过滤
- 结果整形:排序、拍平、重置索引
- 分箱后分组:pd.cut / pd.qcut
- 时间维度分组:pd.Grouper (时间序列基础见 §9)
- 分组报表与“每组最优行”
- 分箱后做多维交叉统计
- 组过滤与异常组剔除
- 重塑与透视
- 宽格式 vs 长格式
- melt:宽转长
- pivot:长转宽,及与 melt 的互逆关系
- pivot_table:带聚合的透视
- stack / unstack:旋转索引层级
- wide_to_long:带后缀的列名转长
- explode:把列表列炸成多行
- get_dummies:类别转哑变量
- crosstab 与 T(转置) (进阶见 §16.3)
- 多层列索引拍平
- 成绩宽表 → 长表 → 多维度透视
- 销售长表转多层透视并交付
- 标签展开与哑变量建模前处理
- 时间序列
- pd.to_datetime:解析入口
- Timestamp / Period / Timedelta
- DatetimeIndex 与 date_range
- 时间切片、truncate 与 .dt 访问器
- shift / diff / pct_change
- resample:降采样与升采样
- asfreq:只换频率不聚合
- 滚动窗口:rolling / expanding / ewm
- 时区:tz_localize / tz_convert
- Period 索引与 to_period / to_timestamp
- 日流水的重采样与月度报表
- 移动平均、波动率与累计净值
- 跨时区对齐与工作日补齐
- 数据可视化
- df.plot() 全家桶速查
- 常用参数速查
- kind 参数与 df.plot.xxx() 两种写法
- 直方图与分箱
- 散点图与散点矩阵
- 多子图布局
- 保存图片与中文字体
- 日销售额趋势 + 移动平均
- 品类对比仪表板
- 双 Y 轴与对数坐标
- 文件读写
- read_csv 参数速查
- CSV 写出侧
- Excel
- JSON 的 orient
- SQL
- Parquet / Feather / Pickle
- 剪贴板与 HTML
- 大文件策略
- 脏 CSV 一次读干净
- 多 sheet 汇总与格式互转
- 数据类型与转换
- dtype 全景
- astype 与陷阱
- to_numeric
- to_datetime / to_timedelta
- convert_dtypes 与 infer_objects
- category 的用途与坑
- select_dtypes 与类型检查
- 内存与 downcast
- 百万行内存瘦身
- 脏数据类型批量规范化
- 缺失值处理
- 三种缺失标记
- 先统计,再决定
- dropna
- fillna
- interpolate
- replace 把哨兵值变 NaN
- 聚合中的默认行为
- 缺失报告 + 分组填充
- 时间序列插值与哨兵清洗
- 排序与排名
- sort_values
- sort_index
- rank
- nlargest / nsmallest
- 与 groupby 配合取组内 TopN
- 班级成绩排行榜
- 组内 TopN 与多因子综合排名
- 字符串操作
- .str 访问器速查
- 链式清洗
- 正则要点
- split 拆列与 get_dummies
- 性能实测:.str、apply 与去重映射
- 电商商品名清洗
- 日志字段正则抽取
- 高级技巧
- MultiIndex 的创建与选择
- 层级重排与拍平
- crosstab 与 pivot_table 进阶
- apply 高阶用法
- pipe 链式管道
- transform 广播
- np.select 分档与 factorize 编码
- merge_asof 近似连接
- query 与 eval
- accessor 与自定义注册
- window 进阶
- 大区-城市-月份的多层看板
- 成交与报价的近似匹配 + 自动分档
- 性能优化
- 先测量,再优化
- 四种写法的性能排序
- dtype 优化
- 逐列内存表
- 读写优化
- 避免逐行增长
- groupby 用内置聚合
- eval / query 的适用场景
- numba
- inplace、Copy-on-Write 与安全赋值
- 优化原则与检查清单
- dtype 瘦身前后对比
- 50 万行 CSV 的分块聚合
- 实战套路
- 数据清洗流水线
- 探索性分析 EDA 报告
- 时间序列分析
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>。
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[...] = ...;字符串列默认strdtype 而非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 |
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。
最小可运行骨架
读进来 → 加列 → 统计 → 排序 → 落盘,这一条链路覆盖了日常八成的需求。
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') |
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) |
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())会对整表求均值、把字符串列一起卷进去报错——显式指定要填的列。
版本与依赖检查
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 | 超过只给列摘要 |
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 → 带标签列 |
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 都被 ... 折叠。之后每次开新会话,把这段当成模板粘贴即可。
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。这是最典型的“脏数据 → 可建模数组”流程。
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。
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 后成为列名 |
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)。
索引与标签
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 |
标签存在则改,不存在则新增一行 |
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 |
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) |
整体替换索引 | 长度必须相等 |
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 的核心直觉。
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。
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:
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)
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[...] = ...。
缺失值处理 (系统梳理见后续)
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 互转
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 的向量化方法完成。
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——正确姿势是先体检索引、补齐口径,再算销售额与占比。
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() 适用于状态量。
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 语义变糊涂。
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(逐列指定)。
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(...)] |
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 1pd.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 有多少列 |
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() # 输出: 每列缺失值个数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 |
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) # 输出: 丢弃旧标签,重排 RangeIndexdf.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。
行操作
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=['姓名']) # 输出: 按列去重,保留首次出现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 章 |
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 互转
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()把索引变成列。
复制与视图
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 选列 |
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。
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。
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 列。
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()兜底。
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[...] = ...。
[] 操作符:选列与切行
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:基于标签
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:基于位置
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 取、随机位置 |
df.loc[0:3].shape # 输出: (4, 7) ← 标签闭区间
df.iloc[0:3].shape # 输出: (3, 7) ← 位置半开
# df.loc[0, 4] # ❌ 语义不明:0 是标签、4 是位置,不可混用at / iat:单个标量
df.at[0, '数学'] # 输出: 85
df.at[0, '数学'] = 100 # ✅ 单格赋值最快
# df.at[0:2, '数学'] # ❌ at/iat 不支持切片和列表⚠️
at/iat只能取改一个标量(at按标签、iat按位置),需要切片/列表/掩码时回到loc/iloc。循环里逐格改值用iat没问题,但先问一句:能不能整列向量化。
布尔索引与多条件组合
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():字符串表达式
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:保形状的条件替换
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
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,别用链式
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
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)
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 定位尖子,最后把偏科与需辅导的标记一次性回写原表——全程不写循环,也不碰链式赋值。
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 才能落回原表。
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 对齐回写。
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') |
只删整行全空的行 |
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 且间隔不均 |
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(全部重复项都删掉,只留真正唯一的)。
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
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)
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 |
全角/重音字符归一(需标准库配合) |
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 映射
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) |
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 强转、非法年龄用合法行的中位数回填。
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 定出上下界,对比“删除 / 缩尾 / 换中位数”三种处置对本均值和记录数的影响,再决定走哪条路。
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——异常值有时恰恰是业务上最值得看的那条记录。
订单流水去重与最新状态
同一订单在流水表里有多条状态变更记录,且部分记录是整行重复的脏数据:先用整行去重吃掉脏数据,再按时间戳排序取每个订单的最后一条状态。
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 若重复则报错 |
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 的四种连接
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 |
笛卡尔积 | 生成组合全集 |
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 / bothmerge 的键与列冲突参数
| 参数 | 作用 |
|---|---|
on |
两侧同名键;多键写 on=['k1','k2'] |
left_on / right_on |
两侧键名不同 |
left_index / right_index |
用某一侧的索引当键(可与 *_on 混用) |
suffixes=('_x','_y') |
非键的同名列自动加后缀 |
indicator=True / ='列名' |
标出每行来源,便于对账 |
validate='1:1' |
声明预期基数,不符立即报错 |
sort=False |
是否按键排序结果(默认保持左表顺序更快) |
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=(此时该列去匹配右表的索引)。
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)。
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' |
允许任意(默认,不校验) |
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 |
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 取“不晚于该时刻的最近一条”,是时间序列配对的专用武器。
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' 断言两张维表的键唯一。
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 固化结论。
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 去重聚合。
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 三个方法) |
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 更快,且保持首次出现顺序) |
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
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:单函数、多函数、命名聚合
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) |
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 万时耗时线性爆炸。
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 等返回的则是组内相对位置或等长列。
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()算出组级指标再筛选就别逐组跑。
结果整形:排序、拍平、重置索引
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 按样本数等频。
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 分钟。
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)。
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) 直接出宽表。
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 给每行打上组标签以便回溯。
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) |
| 扩展新类别 | 要加列 | 加行即可 |
| 适合 | 报表、特征矩阵 | 分组聚合、画图、存库 |
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) |
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=值):把某一列的取值摊成若干新列。
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.namemelt 与 pivot 互为逆操作:melt(id_vars=X, var_name=V, value_name=C) 之后再 pivot(index=X, columns=V, values=C),结果与原表只差列顺序和 index 是否还原。前提是:
(X, V)的组合唯一——有重复就必须在中间用pivot_table加聚合(§8.4);- 融化的列是同一量纲;
melt时放进id_vars的列,正是pivot里要放回index的列。
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 时是否只统计出现过的类别 |
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。
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 和分隔符,一次性抽出后缀当新列。
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 把每个元素拆成独立行,其余列自动复制。
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:类别转哑变量
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 求占比。
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=[...] 会产生二级列索引,取列要写元组。一次性拍平的三种写法:
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 这种复合列名要先拆开,再按不同维度透视为报表,最后把多级列索引拍平交付。
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)) 一步拍平即可交付。
销售长表转多层透视并交付
原始流水是长表(城市 × 品类 × 月份),交付要求是“每个城市一行,品类在上层、月份在下层”的宽表,附带占比小计。
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 特征矩阵。
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 时间戳(秒/毫秒)时的口径 |
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 是一段长度。
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 |
步长倍数 |
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 可用 |
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
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 = 按时间分组 + 聚合。降采样(高频→低频)需要聚合函数;升采样(低频→高频)需要补值方式。
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 = 纯重索引。
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 |
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 把已带时区的时间换到另一时区(同一瞬间,墙上时刻变动)。
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
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 并排序。
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 是在不设索引时做同样事情的另一种写法。
移动平均、波动率与累计净值
典型的“价格/指标”流水线:先算日收益率,再叠加不同窗口的移动平均与滚动标准差,最后用累计乘积还原净值曲线。
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 而不是被跳过。
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 |
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 轴范围 |
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。
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。
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区分。
散点图与散点矩阵
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=(不同图型混排)。
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 会清空画布、存出空白图。
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() 就画在同一张图上。
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 的画布,三种图型各占一格。
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 上对数坐标。
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 |
注释符 | '#' |
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 写出侧
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
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: []} |
体积最小、往返最稳 |
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
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
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
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 看结构。
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 一层处理掉,不要读进来再慢慢洗。
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 汇总(给老板)。
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) |
可空整型 | 有缺失也不用变浮点 |
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 起字符串列默认
strdtype。老代码里select_dtypes(include='object')抓不到字符串列了,要改成include='str'或先convert_dtypes()。
astype 与陷阱
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
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') # 输出: float32to_datetime / to_timedelta
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
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 的用途与坑
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 与类型检查
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
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% 以上。
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。
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 多被转成上面三者 |
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(可空整型保持整数语义)先统计,再决定
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
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
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
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
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 当缺失。
聚合中的默认行为
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。
缺失报告 + 分组填充
拿到一张新表先出一份缺失报告,按缺失率降序排,再对数值列走组内中位数、类别列填“未知”。
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,后者按时间插值补齐。
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
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
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] |
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
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
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)的差异。
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%」做一次加权综合排名——这是排序 + 排名最典型的组合场景。
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 |
去前缀 / 后缀 |
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,可以无限接龙。清洗脏文本的固定顺序:剥空白 → 统一大小写 → 替换/去噪。
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会当普通字符处理。
正则要点
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
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() 回原表」。
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 完成。
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 用命名分组,列名一步到位。
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 的创建与选择
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 |
每层独立切片 |
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(...)。
层级重排与拍平
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 进阶
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 高阶用法
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 / mresult_type |
效果 |
|---|---|
None(默认) |
list → Series 元素;dict → 展开成列 |
'expand' |
list / dict 一律展开成列 |
'reduce' |
强制返回 Series |
'broadcast' |
广播回原 shape,列名沿用 |
⚠️
axis=1的逐行apply要为每行构造一个 Series,是 Pandas 最慢的常见写法之一;能写成d2['a'] + d2['b']就别用apply。
pipe 链式管道
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 广播
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 编码
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 近似连接
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
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)。
@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 进阶
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 现造数据,可直接复制运行。
# 沿用本章开头的 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) 而不是裸月份。
成交与报价的近似匹配 + 自动分档
三笔成交要匹配时间上最近的一次报价,算出成本后按数量分档,并对代码做整数编码——日志对齐里最常见的组合拳。
# 沿用本章开头的 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 负责把字符串代码转整数——三者串起来把对齐 + 打标 + 编码一步做完。
性能优化
先测量,再优化
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 与系统负载污染;比较两种写法时交替跑几轮取中位数,别只信最快那一次。
四种写法的性能排序
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 优化
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 里不报错,会静默回绕。
逐列内存表
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。
读写优化
# 读:只取需要的列 + 指定 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需要落盘或交给数据库做。
避免逐行增长
# 反例:每轮 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 用内置聚合
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 的适用场景
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
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 与安全赋值
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)→ 微观优化。
- 结果是否正确?(先写对,再写快)
- 真正的瓶颈在 I/O、CPU 还是内存?
- 能否少读行、少读列(
usecols/chunksize)? - dtype 是否最优(
category/ downcast)? - 能否把
apply(axis=1)/iterrows改写成列运算? - 是否在重复计算同一个 mask 或转换?
- 中间对象是否过多(改用
assign/eval链)? - 是否适合分块?
- 是否需要换格式(Parquet)或执行引擎?
- 优化前后是否用同一份数据、同一方法测量?
dtype 瘦身前后对比
一份 30 万行的行为日志,城市与等级都是低基数重复字符串、金额不需要双精度。先量一遍内存,再逐列降位宽与转 category,最后验证分组聚合照常工作。
# 沿用本章开头的 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 现造,可直接复制运行。
# 沿用本章开头的 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 内联在字符串里,无需外部文件即可运行。
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。
# 沿用 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 日滚动均值,并给出环比与同比,同时把「每月实际有数据的天数」作为质量指标输出。
# 沿用 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',别混用导致同比窗口错位。