| Day 24 | 数据清洗:缺失值、重复值、类型转换与异常值 |

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=150min=-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=150min=-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 valuesdt.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()
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇