1 缺失值 —— 数据里的「空位」
是什么?为什么学?
缺失值就是表格里「没填」的格子,Pandas 里显示为 NaN(Not a Number)。真实数据几乎必有缺失:用户没填的选项、传感器没采到的点、合并表格时对不上的行……如果不处理,统计结果会偏(比如算平均分时 NaN 参与计算直接变 NaN),画图会断线。所以拿到数据第一步就是「查缺」。真实世界的数据永远是脏的,数据清洗是数据分析中最花时间的部分,也是决定分析结果准不准的关键。
底层原理
NaN 是 Pandas 用来表示「缺失」的特殊浮点值:任何运算碰到 NaN 都会传染成 NaN(90 + NaN = NaN),所以不能靠「算」来绕过它,必须显式处理。三个工具各管一件事:isnull() 把每个格子变成 True/False(True = 缺失),配合 sum() 直接数出每列缺了几个;dropna() 删掉含缺失的行;fillna(值) 用指定值填空。怎么填有讲究:数值列用平均值 / 中位数(不受极端值影响),文本列用固定值(如「未知」)。
生活类比
像一张没填完的报名表:isnull 是「逐格检查有没有空着」,dropna 是「这张表空得太多,作废重填」,fillna 是「就空一格?我帮你补个’无’或填个平均参考值」。
注意事项总结
1 发现缺失 —— isnull 逐格检查 + 数出缺失个数:
import pandas as pd
import numpy as np
df = pd.DataFrame({
"姓名": ["张三", "李四", "王五"],
"分数": [90, np.nan, 95],
"城市": ["北京", np.nan, "广州"],
})
print(df)
print(df.isnull()) # 每个格子是不是缺失
print("分数缺失个数:", df["分数"].isnull().sum())
运行结果:
姓名 分数 城市
0 张三 90.0 北京
1 李四 NaN NaN
2 王五 95.0 广州
---
姓名 分数 城市
0 False False False
1 False True True
2 False False False
---
分数缺失个数: 1
np.nan 就是「造一个缺失值」;注意李四的分数列变成 90.0 / NaN / 95.0——整列因为混入 NaN 自动变成浮点;df["分数"].isnull().sum() 是经典组合:先变布尔再求和,数出这一列缺了几个。
2 处理缺失 —— dropna 删除 / fillna 填充:
print("=== dropna 删除所有含缺失的行 ===")
print(df.dropna())
df2 = pd.DataFrame({"姓名": ["张三", "李四", "王五"], "分数": [90, np.nan, 95]})
df2["分数"] = df2["分数"].fillna(df2["分数"].mean()) # 用平均分填
print("\n=== fillna 用平均值填充 ===")
print(df2)
运行结果:
=== dropna 删除所有含缺失的行 ===
姓名 分数 城市
0 张三 90.0 北京
2 王五 95.0 广州
=== fillna 用平均值填充 ===
姓名 分数
0 张三 90.0
1 李四 92.5
2 王五 95.0
df.dropna() 默认删掉任何含缺失的行;df2["分数"].fillna(df2["分数"].mean()) 先算平均值(90 和 95 平均是 92.5),再填进缺失格——注意 fillna 不会改原表,必须重新赋值。想只按某一列判断删除,用 dropna(subset=["分数"])。
易错点
- 错误写法:
df.fillna(0)后以为原表变了 → 问题:fillna / dropna 默认返回新表,不改原表 → 正确写法:重新赋值:df = df.fillna(0),或加参数inplace=True。 - 错误写法:所有缺失都填 0 → 问题:文本列填 0 不伦不类,数值列填 0 会拉低平均值 → 正确写法:数值列填平均值/中位数,文本列填「未知」等固定值。
- 错误写法:
df.isnull()后直接看 → 问题:返回的是 True/False 表,不是缺失数据本身 → 正确写法:想筛出缺失行:df[df["分数"].isnull()]。 - 错误写法:缺失超过一半还 fillna → 问题:填充等于「编数据」,统计结果严重失真 → 正确写法:缺失严重(>50%)直接
dropna删除该列或该行。
记忆口诀:先 isnull 查缺,再 dropna 删、fillna 填;数值填均值,文本填未知;缺得多就删。
2 重复值 —— 数据里的「复印机」
是什么?为什么学?
重复行就是一模一样的记录出现多次:用户重复提交的订单、传感器重复上报、导出表格自带重复行。重复行会让统计「虚高」——数人数、算销量全都会多算。drop_duplicates() 一行去重,是导入数据后的标准第一步。
底层原理
drop_duplicates() 逐行比较整行内容,完全一样的行只保留第一条(默认 keep="first"),其余删除;加 subset=["列1", "列2"] 就只按这几列判断重复(比如「同一姓名只留一条」),其余列不同也算重复;keep="last" 保留最后一条。去重默认返回新表,同样要重新赋值。
生活类比
像清点快递柜里的重复包裹:drop_duplicates() 是「一模一样的两件只留一件」;subset 是「只看收件人,同一人就算重复」(哪怕包裹大小不同);keep 是「留第一件还是留最后一件」。
注意事项总结
1 整行去重 + 按列去重:
import pandas as pd
df = pd.DataFrame({
"姓名": ["张三", "李四", "张三", "王五", "李四"],
"日期": ["2026-08-01", "2026-08-01", "2026-08-02", "2026-08-02", "2026-08-01"],
})
print("=== 删除整行重复 ===")
print(df.drop_duplicates())
print("\n=== 只按姓名判断重复 ===")
print(df.drop_duplicates(subset=["姓名"]))
运行结果:
=== 删除整行重复 ===
姓名 日期
0 张三 2026-08-01
1 李四 2026-08-01
2 张三 2026-08-02
3 王五 2026-08-02
=== 只按姓名判断重复 ===
姓名 日期
0 张三 2026-08-01
1 李四 2026-08-01
3 王五 2026-08-02
第 0 行和第 4 行整行完全一样,默认去重后只剩第一条;subset=["姓名"] 把判断标准换成「只看姓名」——张三出现两次只留第一条(08-01)——注意张三的 08-02 记录也被删了,因为按姓名判断时「张三」已经出现过。想保留 08-02 那条就加 keep="last"。
易错点
- 错误写法:
df.drop_duplicates()后原表还有重复 → 问题:返回新表,不改原表 → 正确写法:df = df.drop_duplicates()。 - 错误写法:去重后想保留「最后一条」 → 问题:默认
keep="first"保留第一条 → 正确写法:df.drop_duplicates(keep="last")。 - 错误写法:想按两列判断却写
subset="姓名"→ 问题:subset 只认一列 → 正确写法:subset=["姓名", "日期"](列表)。 - 错误写法:以为去重会去「列重复」 → 问题:drop_duplicates 去的是行重复 → 正确写法:列名重复是另一回事,用
df.T.drop_duplicates().T或先看df.columns。
记忆口诀:一模一样算重复,keep 留首或留尾;只看某几列,subset 来指定。
3 类型转换 —— 给数据「换身份证」
是什么?为什么学?
从文件读进来的数据经常是文本:"12.5" 是字符串不是数字,"2026-08-01" 是字符串不是日期。文本不能做加减乘除、不能按日期排序筛选——算之前必须先转类型。astype 转数值、pd.to_datetime 转日期,是清洗流程的「中转站」。
底层原理
Pandas 里每个列都有 dtype(数据类型):文本列在 pandas 3.0 里默认是 str(旧版本叫 object,含义一样),数值列是 int64 / float64,日期列是 datetime64[us](微秒精度,pandas 3.0 默认单位,旧版是 ns 纳秒)。**为什么文本 "10" 加 "9" 是 "109"**:字符串的 + 是拼接不是相加——类型错了,运算语义全变。astype(float) 把整列转成浮点;pd.to_datetime(列) 把字符串解析成真正的日期对象,转完才能用 .dt.day 取「日」、才能比较大小。
生活类比
像给外国人办签证:护照(字符串)上写的「12.5」只是文字,只有换成「数字签证」(float)才能做算术;「2026-08-01」只有换成「日期签证」(datetime)才能比先后。类型就是数据在 Pandas 世界里的「身份」,身份不对,什么操作都别扭。
注意事项总结
1 文本转数字 —— astype:
import pandas as pd
df = pd.DataFrame({"价格": ["12.5", "9.9", "20.0"]})
print("转换前类型:", df["价格"].dtype) # str 是文本
df["价格"] = df["价格"].astype(float) # 文本转小数
print("转换后类型:", df["价格"].dtype)
print("价格总和:", df["价格"].sum()) # 转完才能计算
运行结果:
转换前类型: str
转换后类型: float64
价格总和: 42.4
astype(float) 整列转浮点,dtype 变 float64;转完后 sum() 才是真加法——不转的话 "12.5" + "9.9" 会变成 "12.59.9"(字符串拼接)。
2 文本转日期 —— pd.to_datetime:
df = pd.DataFrame({"日期": ["2026-08-01", "2026-08-02", "2026-08-03"]})
df["日期"] = pd.to_datetime(df["日期"])
print("转换后类型:", df["日期"].dtype)
print("每天的'日':", list(df["日期"].dt.day))
print("晚于 8 月 1 日的记录:")
print(df[df["日期"] > "2026-08-01"])
运行结果:
转换后类型: datetime64[us]
每天的'日': [1, 2, 3]
晚于 8 月 1 日的记录:
日期
1 2026-08-02
2 2026-08-03
pd.to_datetime 把字符串解析成日期类型;.dt.day 是日期列的专属访问器,取「日」字段;转完后日期字符串也能直接比较大小,筛选出晚于 8 月 1 日的记录。
易错点
- 错误写法:
df["价格"].astype(int)转"12.5"→ 问题:带小数的文本转 int 报ValueError→ 正确写法:先astype(float),需要整数再.astype(int)。 - 错误写法:文本列里混着
"不详"直接 astype → 问题:转不过去的值报错 → 正确写法:先fillna或清洗掉非数字,再转换;或pd.to_numeric(列, errors="coerce")把转不了的变 NaN。 - 错误写法:转日期后访问
.dt.day报错 → 问题:.dt只能用于日期类型列 → 正确写法:先确认pd.to_datetime转成功、dtype 是datetime64。 - 错误写法:转完日期直接
df["日期"]取数 → 问题:转类型返回新列/新表,需赋值 → 正确写法:df["日期"] = pd.to_datetime(df["日期"])(重新赋值)。
记忆口诀:文本不能算,日期不能比;astype 转数值,to_datetime 转日期;转完记得重新赋值。
4 apply 与 map —— 批量加工每个值
是什么?为什么学?
清洗经常要「按规则改一批值」:去空格、统一大小写、单位换算、给每个格子打标记。手写循环太啰嗦,Pandas 的 apply(按行/按列批处理) 和 map(按格子批处理) 一行搞定。这是「清洗规则」落地的地方。
底层原理
Series.apply(函数):对一列的每个元素执行函数;DataFrame.apply(函数, axis=1):对每一行执行函数(行会变成一个带列名的 Series,函数里用row["列名"]取值)df.map(函数):对每个格子执行函数(pandas 2.1 起推荐,旧版叫applymap,新版已移除)- 函数可以是
lambda匿名函数,也可以是有名字的函数——规则复杂就定义函数,规则简单就用 lambda
生活类比
像流水线上的质检员:apply 是「按列(或按行)挨个过一遍,按你的规则处理」;map 是「每个零件(格子)都贴一个标签」。你只负责写「规则」,Pandas 负责「挨个执行」。
注意事项总结
1 apply 按行算平均 + map 按格子放大:
import pandas as pd
df = pd.DataFrame({
"语文": [88, 75, 90],
"数学": [92, 80, 88],
})
df["平均分"] = df.apply(
lambda row: (row["语文"] + row["数学"]) / 2, axis=1
)
print(df)
print("---")
df2 = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
print(df2.map(lambda x: x * 10))
运行结果:
语文 数学 平均分
0 88 92 90.0
1 75 80 77.5
2 90 88 89.0
---
a b
0 10 30
1 20 40
df.apply(函数, axis=1) 里 axis=1 表示按行处理——每一行是一个「竖着的 Series」,row["语文"] 取该行的语文分;df2.map(lambda x: x * 10) 对每个格子乘 10——「按格子」和「按行/列」是两种粒度,别混。
2 文本清洗 —— apply + 字符串方法:
df = pd.DataFrame({"城市": [" 北京 ", "shanghai", "广州"]})
df["城市"] = df["城市"].apply(lambda s: s.strip().title())
print(df)
运行结果:
城市
0 北京
1 Shanghai
2 广州
.strip() 去掉首尾空格,.title() 首字母大写;apply 把这两个方法套到这一列的每个元素上——这是清洗文本列的标准姿势。中文不受 title 影响,所以北京、广州原样保留。
易错点
- 错误写法:
df.apply(函数)忘写axis=1→ 问题:默认 axis=0 按列处理,函数里row["语文"]直接报错 → 正确写法:按行处理必须axis=1。 - 错误写法:用
df.applymap(...)→ 问题:pandas 新版已移除 applymap → 正确写法:用df.map(...)(2.1+)。 - 错误写法:lambda 里写多行逻辑 → 问题:lambda 只能写一行表达式 → 正确写法:逻辑复杂就
def 函数(): ...再apply(函数)。 - 错误写法:
apply里直接return中文标点或 None → 问题:返回值会原样写进列,可能引入新脏数据 → 正确写法:清洗规则里明确空值返回什么(如"未知")。
记忆口诀:apply 管行列,map 管格子;一行 lambda,复杂就 def;axis=1 按行走。
5 异常值 —— 数据里的「离谱值」
是什么?为什么学?
异常值是明显不合理的数值:分数 150、年龄 -5、工资 99999。它们可能是录入错误、单位错误,也可能是真实但极端的数据(如老板的工资)。异常值会把平均值拉偏、让图表失真——发现它、判断它、处理它是清洗的最后一关。
底层原理
发现异常靠两招:**describe() 看统计摘要(max=150、min=-5 一眼可疑);布尔筛选揪出来((df["分数"] > 100) | (df["分数"] < 0) 直接列出异常记录)。处理也分两招:删除(确认是录入错误,用 df[~mask] 取「不是异常」的行);替换(用中位数等合理值顶替,df.loc[mask, "分数"] = 中位数)。判断「合不合理」的依据是业务规则**:分数 0100、年龄 0120——先定规则,再写条件。
生活类比
像体检报告里的「复查项」:血压 300 明显是机器故障(异常值),医生先标出来(发现),再判断是重测(删除)还是按参考值修正(替换)——绝不能直接拿异常值去算平均。
注意事项总结
1 发现异常 —— describe 摘要 + 布尔筛选:
import pandas as pd
df = pd.DataFrame({
"姓名": ["张三", "李四", "王五", "赵六"],
"分数": [88, 150, 95, -5],
})
print("=== 统计摘要(max/min 可疑)===")
print(df.describe())
mask = (df["分数"] > 100) | (df["分数"] < 0)
print("\n=== 异常记录 ===")
print(df[mask])
运行结果:
=== 统计摘要(max/min 可疑)===
分数
count 4.00000
mean 82.00000
std 64.28582
min -5.00000
25% 64.75000
50% 91.50000
75% 108.75000
max 150.00000
=== 异常记录 ===
姓名 分数
1 李四 150
3 赵六 -5
describe() 里 max=150、min=-5 一眼可疑;mask 用布尔筛选把两条异常记录单独拎出来——先看清是谁、什么值,再决定怎么处理。
2 处理异常 —— 删除 or 替换成中位数:
mask = (df["分数"] > 100) | (df["分数"] < 0)
# 方式一:删除异常行(~ 取反 = 保留"不是异常"的行)
clean = df[~mask]
print("=== 删除异常后 ===")
print(clean)
# 方式二:替换成中位数
df["分数"] = df["分数"].astype(float)
df.loc[mask, "分数"] = df["分数"].median()
print("\n=== 替换成中位数后 ===")
print(df)
运行结果:
=== 删除异常后 ===
姓名 分数
0 张三 88
2 王五 95
=== 替换成中位数后 ===
姓名 分数
0 张三 88.0
1 李四 91.5
2 王五 95.0
3 赵六 91.5
df[~mask] 里 ~ 是「取反」——mask 是 True 的是异常,取反后只留正常行;替换方案:先 astype(float),再 df.loc[mask, "分数"] = df["分数"].median()——**loc[mask, 列] 精确定位到「异常的格子」填中位数**。删除简单直接,替换保留数据量,看业务场景选。
易错点
- 错误写法:只靠眼睛看 describe 不写筛选条件 → 问题:数据多时看不出来 → 正确写法:用布尔条件把异常筛出来打印。
- 错误写法:用
and/or连异常条件 → 问题:数组/Series 不支持,报错 → 正确写法:用&/|,每个条件加括号。 - 错误写法:替换时写
df["分数"][mask] = ...→ 问题:可能触发 SettingWithCopyWarning,改不到原表 → 正确写法:用df.loc[mask, "分数"] = ...。 - 错误写法:不先定「合理范围」就处理 → 问题:删错/改错正常数据 → 正确写法:先按业务定规则(分数 0~100),再写条件。
记忆口诀:describe 看摘要,布尔筛异常;删除用取反,替换用 loc;范围先定好,再动手处理。
6 动手实践:清洗脏数据脚本
把今天学的五招组合成一个完整的清洗脚本,处理一份「集齐了各种脏数据」的 CSV:缺失值、重复行、带空格和大小写混乱的文本、文本数字、异常值。
# clean_data.py —— 清洗脏数据脚本
import pandas as pd
# 1. 准备一份脏数据(模拟从外部拿到的文件)
csv_text = """姓名,年龄,城市,收入
张三,28,北京,12000
李四,,上海,9000
李四,,上海,9000
王五,25, 广州 ,8000
赵六,150,北京,99999
孙七,-5,深圳,6500
周八,35,beijing,
"""
with open("dirty.csv", "w", encoding="utf-8") as f:
f.write(csv_text)
df = pd.read_csv("dirty.csv")
print("=== 原始数据 ===")
print(df)
print("形状:", df.shape)
# 2. 去重
df = df.drop_duplicates()
print("\n=== 去重后 ===")
print(df)
# 3. 清洗文本列:去空格 + 统一首字母大写
df["城市"] = df["城市"].apply(lambda s: s.strip().title())
# 4. 填充缺失值:年龄填中位数,收入填平均值
df["年龄"] = df["年龄"].fillna(df["年龄"].median())
df["收入"] = df["收入"].fillna(df["收入"].mean())
# 5. 类型转换:收入是文本,转成浮点数
df["收入"] = df["收入"].astype(float)
# 6. 处理异常值:年龄必须 0~120,收入不超过 50000
mask = (df["年龄"] > 120) | (df["年龄"] < 0) | (df["收入"] > 50000)
print("\n=== 发现的异常记录 ===")
print(df[mask])
df = df[~mask] # 删除异常行
print("\n=== 清洗完成 ===")
print(df)
print("形状:", df.shape)
print("\n=== 清洗后统计 ===")
print(df.describe())
运行结果:
=== 原始数据 ===
姓名 年龄 城市 收入
0 张三 28.0 北京 12000.0
1 李四 NaN 上海 9000.0
2 李四 NaN 上海 9000.0
3 王五 25.0 广州 8000.0
4 赵六 150.0 北京 99999.0
5 孙七 -5.0 深圳 6500.0
6 周八 35.0 beijing NaN
形状: (7, 4)
=== 去重后 ===
姓名 年龄 城市 收入
0 张三 28.0 北京 12000.0
1 李四 NaN 上海 9000.0
3 王五 25.0 广州 8000.0
4 赵六 150.0 北京 99999.0
5 孙七 -5.0 深圳 6500.0
6 周八 35.0 beijing NaN
=== 发现的异常记录 ===
姓名 年龄 城市 收入
4 赵六 150.0 北京 99999.0
5 孙七 -5.0 深圳 6500.0
=== 清洗完成 ===
姓名 年龄 城市 收入
0 张三 28.0 北京 12000.0
1 李四 28.0 上海 9000.0
3 王五 25.0 广州 8000.0
6 周八 35.0 Beijing 27099.8
形状: (4, 4)
=== 清洗后统计 ===
年龄 收入
count 4.000000 4.000000
mean 29.000000 14024.950000
std 4.242641 8880.733266
min 25.000000 8000.000000
25% 27.250000 8750.000000
50% 28.000000 10500.000000
75% 29.750000 15774.950000
max 35.000000 27099.800000
清洗过程拆解:李四重复行被去重;城市空格去掉、beijing 统一成 Beijing;李四缺失年龄用中位数 28 补上,周八缺失收入用平均值 27099.8 补上;收入从文本转数字;年龄 150 和 -5 两条异常被删除。7 行脏数据变 4 行干净数据。
升级挑战:把清洗好的数据保存回 CSV(df.to_csv("clean.csv", index=False, encoding="utf-8-sig"));再试试把异常值改成「替换」而不是「删除」,对比两种结果。
今日总结
能一眼看出数据「脏」在哪:缺失、重复、类型错、异常值,四种脏数据各有各的修法
isnull 发现缺失,dropna 删除、fillna 填充(数值列填平均值/中位数,文本列填固定值,缺失多就删)
drop_duplicates 去重,subset 控制按哪几列判断重复,keep 留首或留尾
astype 转数值、pd.to_datetime 转日期;文本不能算、日期不能比,转完记得重新赋值
apply 按行/列处理(axis=1 按行),map 按格子处理;一行 lambda,复杂就 def
describe 看摘要发现异常值,布尔筛选揪出来;删除用 df[~mask],替换用 df.loc[mask, 列] = 值
清洗脏数据脚本运行正常:7 行脏数据变 4 行干净数据
| 报错 | 原因 | 修复 |
|---|---|---|
ValueError: invalid literal for int() with base 10: '12.5' | astype(int) 转不了带小数点的文本 | 先用 astype(float),再决定要不要取整 |
KeyError: '城市' | 列名写错(比如多了空格) | print(df.columns) 查看实际列名,必要时 df.columns = [c.strip() for c in df.columns] |
AttributeError: Can only use .dt accessor with datetimelike values | dt.day 用在没转成日期的列上 | 先 pd.to_datetime 转换再访问 .dt |
TypeError: unsupported operand type(s) for +: 'str' and 'float' | 文本列没转类型就做运算 | 先 astype(float) 或 to_numeric |
AttributeError: 'DataFrame' object has no attribute 'applymap' | 新版 pandas 移除了 applymap | 改用 df.map()(旧版本两个都支持) |
SettingWithCopyWarning | 在筛选出来的副本上做修改 | 用 .loc 修改,或先 df = df.copy() |