Pandas 清洗分析
Pandas 是 Python 处理表格数据最常用的库。它适合读取 CSV、Excel、SQL 查询结果,把数据变成 DataFrame,再进行字段清洗、类型转换、缺失处理、去重、过滤、分组聚合、透视和导出。
零基础先记住:
Pandas 不是 Excel 的替代皮肤,而是把表格处理过程变成可重复执行、可审计、可测试的代码。
如果你只会 read_csv() 和 groupby(),但不知道为什么要检查类型、为什么不能无脑填 0、为什么要按业务主键去重、为什么 SettingWithCopyWarning 重要,那么报表一复杂就很容易算错。
学习目标
学完本页,你应该能回答:
DataFrame和Series是什么。- Pandas 读取 CSV/Excel 后为什么要先看
shape、head、info。 - 字段类型为什么会影响统计结果。
- 缺失值、重复值、异常值分别怎么处理。
loc、布尔索引、链式赋值和SettingWithCopyWarning怎么理解。groupby和pivot_table的原理和适用场景。- 为什么大文件不能无脑一次性读进内存。
- 能写一个完整商业清洗 Demo。
- 面试能说清 Pandas 清洗流程和生产排查方法。
为什么需要 Pandas
如果用原生 list 和 dict 处理表格,很多逻辑都要手写循环:
total = {}
for row in rows:
if row["status"] == "paid":
city = row["city"]
total[city] = total.get(city, 0) + row["amount"]这对小数据可以,但业务一复杂,缺失值、类型转换、去重、异常值、导出、分组都会让代码越来越乱。
Pandas 把常见表格操作抽象成列式处理:
summary = (
df[df["status"] == "paid"]
.groupby("city", as_index=False)["amount"]
.sum()
)它的价值不是少写几行,而是:
| 能力 | 价值 |
|---|---|
| 自动读取多种数据源 | CSV、Excel、SQL、JSON 都能接入 |
| 列式处理 | 更贴近“按字段清洗和计算”的业务思维 |
| 缺失值和类型体系 | 能系统处理脏数据 |
| 分组聚合 | 快速得到报表指标 |
| 可脚本化 | 可重复、可审计、可自动化 |
DataFrame 和 Series 是什么
DataFrame 可以理解成一张带行索引和列名的表。Series 可以理解成一列带索引的数据。
flowchart TD
A["DataFrame 一张表"] --> B["index 行索引"]
A --> C["columns 列名"]
A --> D["多列 Series"]
D --> E["Series: city"]
D --> F["Series: amount"]
D --> G["Series: status"]Demo:
import pandas as pd
df = pd.DataFrame([
{"order_id": "1001", "city": "北京", "amount": 99.5},
{"order_id": "1002", "city": "上海", "amount": 120.0},
])
print(type(df))
print(type(df["amount"]))
print(df.index)
print(df.columns)关键点:
| 对象 | 含义 | 常见操作 |
|---|---|---|
DataFrame | 一张二维表 | 读取、筛选、分组、合并、导出 |
Series | 一列或一维数据 | 类型转换、缺失处理、统计 |
Index | 行标签 | 对齐、选择、合并 |
columns | 列名 | 字段选择和业务含义 |
Pandas 清洗流程
flowchart TD
A["读取数据"] --> B["检查结构"]
B --> C["确认字段口径"]
C --> D["类型转换"]
D --> E["缺失值处理"]
E --> F["重复值处理"]
F --> G["异常值处理"]
G --> H["业务过滤"]
H --> I["分组聚合"]
I --> J["结果校验"]
J --> K["导出和日志"]这条链路不能省。省掉哪一步,就可能在那里出错。
| 步骤 | 常用 API | 目的 |
|---|---|---|
| 读取 | read_csv、read_excel、read_sql | 获取原始表格 |
| 检查 | shape、head、info、describe | 确认结构和类型 |
| 类型转换 | to_numeric、to_datetime、astype | 让字段可计算 |
| 缺失处理 | isna、fillna、dropna | 控制空值影响 |
| 去重 | duplicated、drop_duplicates | 防止重复计算 |
| 过滤 | 布尔索引、query | 按业务口径取数 |
| 聚合 | groupby、agg、pivot_table | 统计指标 |
| 导出 | to_csv、to_excel | 交付结果 |
读取数据
CSV:
import pandas as pd
df = pd.read_csv("orders.csv", encoding="utf-8")Excel:
df = pd.read_excel("orders.xlsx", sheet_name="订单")SQL:
import sqlite3
import pandas as pd
conn = sqlite3.connect("demo.db")
df = pd.read_sql("select * from orders", conn)读取时常见参数:
| 参数 | 作用 | 场景 |
|---|---|---|
encoding | 指定编码 | 中文乱码 |
sep | 指定分隔符 | 非逗号 CSV |
dtype | 指定列类型 | 订单号、手机号保留字符串 |
parse_dates | 读取时解析日期 | 日期格式稳定 |
usecols | 只读部分列 | 大文件减少内存 |
chunksize | 分块读取 | 文件很大 |
订单号建议指定字符串:
df = pd.read_csv(
"orders.csv",
encoding="utf-8",
dtype={"order_id": "string"},
)为什么?订单号不是拿来加减乘除的数字,前导 0、长度和格式都可能有业务意义。
读取后先做三板斧
print(df.shape)
print(df.head())
print(df.info())含义:
| 方法 | 看什么 | 如果异常说明什么 |
|---|---|---|
shape | 行数和列数 | 文件没读全、表头错、分隔符错 |
head | 前几行样例 | 列错位、乱码、表头行不对 |
info | 类型和非空数量 | 金额是 object、日期没解析、缺失严重 |
再看数值分布:
print(df.describe())缺失分布:
print(df.isna().sum())重复主键:
print(df.duplicated(subset=["order_id"]).sum())类型转换
真实数据最常见的问题是:看起来像数字,实际上是字符串。
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
df["created_at"] = pd.to_datetime(df["created_at"], errors="coerce")errors="coerce" 表示转换失败时变成缺失值。它适合批量清洗,但转换后必须检查:
bad_amount = df[df["amount"].isna()]
bad_date = df[df["created_at"].isna()]金额带单位怎么处理
df["amount"] = (
df["amount"]
.astype("string")
.str.replace("元", "", regex=False)
.str.replace(",", "", regex=False)
.str.strip()
)
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")状态字段怎么处理
df["status"] = df["status"].astype("string").str.strip().str.lower()然后校验枚举:
allowed_status = {"paid", "refund", "cancelled"}
invalid_status = df[~df["status"].isin(allowed_status)]缺失值处理
缺失值不是技术细节,而是业务语义。
flowchart TD
A["发现缺失值"] --> B{"是否核心字段"}
B -- "是" --> C["进入异常文件"]
B -- "否" --> D{"是否有安全默认值"}
D -- "有" --> E["fillna 默认值"]
D -- "没有" --> F["保留为空或标记 unknown"]常见处理:
| 字段 | 建议 | 原因 |
|---|---|---|
order_id 缺失 | 异常 | 无法追溯 |
amount 缺失 | 异常或人工核对 | 0 和未知不同 |
remark 缺失 | 填空字符串 | 不影响核心统计 |
age 缺失 | 中位数或 unknown | 看分析目的 |
created_at 缺失 | 时间报表中剔除 | 无法归属日期 |
示例:
df["remark"] = df["remark"].fillna("")
invalid = df[df[["order_id", "amount", "created_at"]].isna().any(axis=1)]
valid = df.dropna(subset=["order_id", "amount", "created_at"]).copy()重复值处理
完全重复:
df.duplicated().sum()
df = df.drop_duplicates()业务主键重复:
duplicates = df[df.duplicated(subset=["order_id"], keep=False)]主键重复更重要。比如同一个订单出现两次,即使其他列不完全相同,也会造成销售额重复。
去重策略要由业务决定:
| 策略 | 适合 |
|---|---|
| 保留第一条 | 后续是重复导入 |
| 保留最后一条 | 后续代表最新状态 |
按 updated_at 保留最新 | 有更新时间 |
| 不自动去重,输出异常 | 金额或状态冲突 |
示例:
df = (
df.sort_values("created_at")
.drop_duplicates(subset=["order_id"], keep="last")
.copy()
)异常值处理
异常值要分两类:
- 技术异常:无法转换、格式错误、字段缺失。
- 业务异常:金额为负、日期未来、状态非法、数量过大。
negative_paid = df[(df["status"] == "paid") & (df["amount"] < 0)]
future_orders = df[df["created_at"] > pd.Timestamp.today()]
too_large = df[df["amount"] > 100_000]处理方式:
| 异常 | 处理 |
|---|---|
| 状态非法 | 输出异常文件 |
| 已支付金额为负 | 输出异常文件 |
| 退款金额为负 | 可能合理,看口径 |
| 金额过大 | 抽样核对或单独标记 |
| 日期无法转换 | 输出异常文件 |
不要静默删除异常数据。生产处理应该保留异常数据,用来反馈数据源。
布尔索引和 loc
筛选已支付订单:
paid = df[df["status"] == "paid"]多条件:
paid_large = df[(df["status"] == "paid") & (df["amount"] >= 100)]注意:Pandas 条件要用 &、|,每个条件加括号,不要用 and、or。
推荐用 .loc 做赋值:
df.loc[df["amount"] < 0, "amount_flag"] = "invalid"SettingWithCopyWarning 是什么
错误倾向写法:
paid = df[df["status"] == "paid"]
paid["amount"] = paid["amount"].fillna(0)Pandas 可能警告 SettingWithCopyWarning。它想提醒你:paid 可能是原表的视图,也可能是拷贝,你的赋值到底会不会影响原表不够明确。
flowchart TD
A["原 DataFrame"] --> B["过滤得到 paid"]
B --> C{"paid 是视图还是拷贝不明确"}
C --> D["赋值可能没按预期生效"]正确做法:
paid = df[df["status"] == "paid"].copy()
paid.loc[:, "amount"] = paid["amount"].fillna(0)或者直接在原表上明确赋值:
df.loc[df["status"] == "paid", "amount"] = (
df.loc[df["status"] == "paid", "amount"].fillna(0)
)商业项目里不要忽略这个警告。它可能导致脚本看起来跑完了,但某一步清洗没有真正生效。
groupby 原理和用法
groupby 可以理解成三步:拆分、应用、合并。
flowchart TD
A["DataFrame"] --> B["按 city 拆分成多个组"]
B --> C["每组分别 sum、mean、count"]
C --> D["合并成汇总表"]示例:
summary = (
df.groupby("city", as_index=False)
.agg(
order_count=("order_id", "nunique"),
total_amount=("amount", "sum"),
avg_amount=("amount", "mean"),
)
.sort_values("total_amount", ascending=False)
)常见聚合:
| API | 含义 |
|---|---|
count | 非空数量 |
size | 行数,包括空值 |
nunique | 去重数量 |
sum | 求和 |
mean | 平均 |
median | 中位数 |
max / min | 最大/最小 |
count 和 size 区别很重要:
df.groupby("city").agg(
row_count=("order_id", "size"),
non_null_amount=("amount", "count"),
)如果金额有空值,count 不会算空值,size 会算行数。
pivot_table 透视表
透视表适合多维交叉统计。
pivot = pd.pivot_table(
df,
values="amount",
index="city",
columns="status",
aggfunc="sum",
fill_value=0,
)含义:
| 参数 | 作用 |
|---|---|
values | 被统计的值 |
index | 行维度 |
columns | 列维度 |
aggfunc | 聚合方法 |
fill_value | 缺失组合填充值 |
业务例子:
| 城市 | paid | refund | cancelled |
|---|---|---|---|
| 北京 | 1000 | 100 | 0 |
| 上海 | 2000 | 80 | 50 |
透视表适合报表展示,但如果后续还要继续做程序处理,普通 groupby 结果通常更简单。
merge 合并表
商业数据经常需要把订单表和城市维度表、用户表、资产表合并。
orders = pd.DataFrame([
{"order_id": "1001", "city_code": "BJ", "amount": 100},
{"order_id": "1002", "city_code": "SH", "amount": 200},
])
city_dim = pd.DataFrame([
{"city_code": "BJ", "city_name": "北京"},
{"city_code": "SH", "city_name": "上海"},
])
result = orders.merge(city_dim, on="city_code", how="left")
print(result)how 怎么选:
| how | 含义 | 场景 |
|---|---|---|
left | 保留左表全部 | 订单补维度 |
inner | 只保留两边匹配 | 只分析有维度的数据 |
outer | 两边都保留 | 对账 |
right | 保留右表全部 | 少用 |
合并后要检查是否匹配失败:
missing_city = result[result["city_name"].isna()]如果维度没匹配上,后续分组会出现空城市或统计丢失。
排序、TopN 和排名
top_city = summary.sort_values("total_amount", ascending=False).head(10)组内 TopN:
df["rank_in_city"] = (
df.groupby("city")["amount"]
.rank(method="first", ascending=False)
)
top_orders = df[df["rank_in_city"] <= 3]排名常见用途:
| 场景 | 用法 |
|---|---|
| 每城市销售 Top10 | groupby + rank |
| 每部门资产访问 TopN | groupby + rank |
| 找异常高金额订单 | 排序后抽查 |
apply 什么时候用
apply 很灵活,但不是首选。能用内置向量化就不要用 apply。
优先:
df["amount_level"] = "normal"
df.loc[df["amount"] >= 1000, "amount_level"] = "high"复杂逻辑再用:
def classify(row: pd.Series) -> str:
if row["status"] != "paid":
return "not_paid"
if row["amount"] >= 1000:
return "high"
return "normal"
df["amount_level"] = df.apply(classify, axis=1)apply(axis=1) 是逐行调用 Python 函数,数据大时会慢。商业项目里要先保证正确,再考虑性能;性能瓶颈出现时,优先改成向量化、SQL 或分布式处理。
大文件怎么处理
Pandas 默认把数据读进内存。如果文件很大,可能内存爆掉。
分块读取:
chunks = []
for chunk in pd.read_csv("big_orders.csv", chunksize=100_000):
chunk["amount"] = pd.to_numeric(chunk["amount"], errors="coerce")
paid = chunk[chunk["status"] == "paid"].copy()
part = paid.groupby("city", as_index=False)["amount"].sum()
chunks.append(part)
merged = pd.concat(chunks, ignore_index=True)
final = merged.groupby("city", as_index=False)["amount"].sum()分块难点:
| 问题 | 说明 |
|---|---|
| 跨块去重 | 同一订单可能分布在不同块 |
| 全局排序 | 单块排序不等于全局排序 |
| 全局 TopN | 需要最终合并后再算 |
| 内存 | 中间结果也可能越来越大 |
如果数据量持续增长,应考虑数据库、DuckDB、Polars、Spark,而不是所有事情都硬用 Pandas。
商业 Demo:医疗资产导入清洗
需求:业务上传一份资产 CSV,字段包括资产编码、资产名称、部门、负责人、访问次数、质量分、状态。我们要清洗数据并输出:
- 有效资产清单。
- 异常资产清单。
- 按部门统计资产数量、平均质量分、访问次数。
下面 Demo 可直接运行。
from io import StringIO
import pandas as pd
RAW_CSV = """asset_code,asset_name,department,owner,visit_count,quality_score,status
A001,门诊明细,信息科,张三,120,0.95,online
A002,检验报告,信息科,李四,80,0.88,online
A002,检验报告,信息科,李四,90,0.90,online
A003,药品库存,药剂科,王五,abc,0.76,online
,设备台账,设备科,赵六,30,0.99,offline
A005,收费明细,财务科,钱七,300,1.20,online
A006,病案首页,病案室,,60,0.82,unknown
"""
def load_assets() -> pd.DataFrame:
return pd.read_csv(StringIO(RAW_CSV), dtype={"asset_code": "string"})
def normalize_assets(df: pd.DataFrame) -> pd.DataFrame:
result = df.copy()
text_columns = ["asset_code", "asset_name", "department", "owner", "status"]
for column in text_columns:
result[column] = result[column].astype("string").str.strip()
result["visit_count"] = pd.to_numeric(result["visit_count"], errors="coerce")
result["quality_score"] = pd.to_numeric(result["quality_score"], errors="coerce")
return result
def split_invalid_assets(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
required = ["asset_code", "asset_name", "department", "owner"]
valid_status = {"online", "offline"}
invalid_mask = (
df[required].isna().any(axis=1)
| (df[required] == "").any(axis=1)
| df["visit_count"].isna()
| df["quality_score"].isna()
| (df["quality_score"] < 0)
| (df["quality_score"] > 1)
| ~df["status"].isin(valid_status)
)
invalid = df[invalid_mask].copy()
valid = df[~invalid_mask].copy()
return valid, invalid
def deduplicate_assets(df: pd.DataFrame) -> pd.DataFrame:
return df.drop_duplicates(subset=["asset_code"], keep="last").copy()
def build_department_report(df: pd.DataFrame) -> pd.DataFrame:
online = df[df["status"] == "online"].copy()
return (
online.groupby("department", as_index=False)
.agg(
asset_count=("asset_code", "nunique"),
total_visit=("visit_count", "sum"),
avg_quality=("quality_score", "mean"),
)
.sort_values("asset_count", ascending=False)
)
def main() -> None:
raw = load_assets()
normalized = normalize_assets(raw)
deduped = deduplicate_assets(normalized)
valid, invalid = split_invalid_assets(deduped)
report = build_department_report(valid)
print("有效资产:")
print(valid)
print("\n异常资产:")
print(invalid)
print("\n部门报表:")
print(report)
if __name__ == "__main__":
main()这个 Demo 体现了:
| 处理 | 为什么 |
|---|---|
asset_code 指定 string | 资产编码不是数学数字 |
文本字段 strip | 去掉导入文件中的多余空格 |
数值字段 to_numeric | 把访问次数、质量分转成可统计字段 |
quality_score 范围校验 | 分数必须在 0 到 1 |
status 枚举校验 | 防止未知状态进入统计 |
按 asset_code 去重 | 防止同一资产重复导入 |
| 异常数据单独输出 | 反馈给业务修正 |
只统计 online | 报表口径明确 |
结果校验
清洗完不要直接交付,要做校验。
assert report["asset_count"].sum() <= valid["asset_code"].nunique()
assert valid["quality_score"].between(0, 1).all()
assert valid["visit_count"].ge(0).all()也可以打印处理报告:
print("原始行数:", len(raw))
print("有效行数:", len(valid))
print("异常行数:", len(invalid))
print("重复资产数:", normalized.duplicated(subset=["asset_code"]).sum())生产任务建议把这些信息写入日志,而不是只打印到控制台。
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 不指定编码 | 中文乱码 | 明确 encoding |
| 订单号、资产编码转数字 | 前导 0 丢失,科学计数法 | 指定 string |
不检查 info() | 类型错误不知道 | 读取后三板斧 |
| 缺失值全填 0 | 业务含义被篡改 | 按字段含义处理 |
| 主键重复只做完全去重 | 重复业务数据仍存在 | 按业务主键去重 |
忽略 SettingWithCopyWarning | 修改可能没生效 | .copy() 或 .loc |
apply(axis=1) 滥用 | 大数据很慢 | 优先向量化 |
| 分块后块内去重 | 跨块重复仍存在 | 最终阶段全局去重 |
| 导出不带异常文件 | 数据问题无反馈 | 输出异常数据和原因 |
生产排查流程
Pandas 报表结果不对时,按这条链路查:
flowchart TD
A["报表结果异常"] --> B["确认原始数据范围"]
B --> C["检查读取行数和列名"]
C --> D["检查 dtype 和缺失"]
D --> E["检查主键重复"]
E --> F["检查异常值和枚举"]
F --> G["检查过滤条件"]
G --> H["检查 groupby / pivot 口径"]
H --> I["聚合前后总数对账"]
I --> J["抽样明细核对"]排查证据:
| 证据 | 用途 |
|---|---|
| 原始文件行数 | 判断源数据完整性 |
df.shape | 判断读取是否完整 |
df.dtypes | 判断类型是否正确 |
isna().sum() | 找缺失和转换失败 |
duplicated(subset=key) | 找业务重复 |
value_counts() | 检查状态、部门、城市分布 |
| 聚合前后总金额 | 判断是否漏算或重复 |
| 异常文件 | 反馈源数据质量 |
面试标准回答
Pandas 数据清洗完整流程是什么?
可以这样答:
我会先确认字段含义和统计口径,再读取数据并检查 shape、head、info、describe、缺失值和重复值。接着对金额、日期、状态等关键字段做类型转换和枚举校验,按业务主键去重,处理缺失值和异常值,再按业务口径过滤数据。最后使用 groupby 或 pivot_table 聚合,导出结果和异常数据,并做聚合前后总数、总金额和样例明细校验,保证结果可复现、可追溯。DataFrame 和 Series 是什么?
DataFrame 是一张带行索引和列名的二维表,Series 是带索引的一维列数据。实际使用中,读取 CSV/Excel/SQL 后通常得到 DataFrame,选取某一列得到 Series。清洗和统计就是围绕这些列做类型转换、缺失处理、筛选和聚合。
SettingWithCopyWarning 是什么?
它表示你可能在一个不明确的视图或拷贝上赋值,修改结果不一定按预期生效。常见原因是先过滤出子 DataFrame,再直接赋值。正确做法是过滤后显式 .copy(),或者在原 DataFrame 上用 .loc[row_condition, column] 明确赋值。
groupby 的原理是什么?
groupby 可以理解成拆分、应用、合并三步。先按一个或多个字段把 DataFrame 拆成多个组,再对每组执行 sum、mean、count、nunique 等聚合函数,最后把结果合并成汇总表。面试时要注意 count 是非空数量,size 是行数。
apply 为什么不建议滥用?
apply(axis=1) 会逐行调用 Python 函数,灵活但性能通常不如 Pandas/NumPy 向量化。小数据或复杂业务规则可以用,数据量大时应优先考虑向量化、loc 条件赋值、SQL 聚合或其他更适合大数据的工具。
关联知识点
| 知识点 | 作用 |
|---|---|
| Python 数据处理总览 | 建立完整数据处理流程 |
| NumPy 数组计算 | 理解 Pandas 底层数组和向量化 |
| 端到端实践 | 完整跑通 CSV 清洗和导出 |
| Python 异常与文件 | 理解文件编码、异常和资源关闭 |
| Python 日志与调试 | 让清洗脚本可排查 |
| Python 测试 | 给清洗函数写测试 |
| Python 面试题 | 查看标准回答和追问 |
本章小结
Pandas 清洗分析的核心是“让表格数据可信”。你要先检查结构和类型,再按业务口径处理缺失、重复和异常,最后聚合、校验、导出。真正的商业数据处理不是跑出一个 CSV,而是保证结果可解释、过程可复现、错误可追溯。
