返回首页

Roadmap
更新于 2026年10月24日 全文 1341 字未阅读

第 3.3 节 常见问题#

这一节收集了初学者在「洗数据」环节最常卡的 10 个问题。每个问题都给出直接答案,能跑代码的都附上完整示例。

怎么用这一节 不用通读。清洗时遇到问题用 Ctrl+F 搜关键词,直接跳到对应那条。

准备一下数据,下面每题都会用到:

import pandas as pd
import numpy as np

data = {
    "订单号":   [1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008],
    "下单日期": ["2024-01-05", "2024-01-08", "2024-01-12", "2024-02-03",
                 "2024-02-15", "2024-03-02", "2024-03-20", "2024-04-08"],
    "城市":     ["北京", "上海", "北京", "广州", "上海", "北京", "深圳", "广州"],
    "商品":     ["手机", "电脑", "耳机", "手机", "键盘", "显示器", "耳机", "手机"],
    "销量":     [2, 1, 3, 2, 5, 1, 4, 2],
    "单价":     [3999.0, 7999.0, 599.0, 3999.0, 299.0, 1299.0, 599.0, 3999.0],
    "金额":     [7998.0, 7999.0, 1797.0, 7998.0, 1495.0, 1299.0, 2396.0, 7998.0],
    "客户评分": [4.5, 4.0, 4.8, 3.9, 4.2, 4.6, 4.1, 4.9],
}
df = pd.DataFrame(data)

dirty = df.copy()
dirty.loc[1, "金额"] = np.nan
dirty.loc[5, "客户评分"] = np.nan
dirty = pd.concat([dirty, dirty.iloc[[0]]], ignore_index=True)

Q1:如何统计每列的缺失值数量?#

答: isna().sum()。

print(dirty.isna().sum())
订单号         0
下单日期        0
城市           0
商品           0
销量           0
单价           0
金额           1
客户评分        1
dtype: int64

几个常见的衍生问法:

dirty.isna().sum().sum()     # 整张表一共缺几个:2
dirty.isna().any()           # 每一列有没有缺失(返回布尔 Series)
dirty.isna().any().any()     # 整张表有没有缺失:True
dirty.isna().all()           # 每一列是不是全空(返回布尔 Series)

算缺失比例(做数据质量报告时常用):

missing_ratio = dirty.isna().sum() / len(dirty)
print(missing_ratio)

缺失比例超过 30% 的列,通常要考虑「这一列还有没有分析价值」。

Q2:isna() 和 notna() 有什么区别?#

答: 正好相反。isna() 问「有没有空」,notna() 问「有没有值」。

dirty.isna()      # True = 这里缺了
dirty.notna()     # True = 这里有值

notna() 最常见的用法是「只要有值的」:

# 所有格子都有值的行
print(dirty[dirty.notna().all(axis=1)])

axis=1 表示「沿着列的方向看每一行」,axis=0 表示「沿着行的方向看每一列」。 这是 Pandas 里最容易搞混的轴参数,记法是:axis=1 是横着看,作用在行上。

另外还有两个等价写法:

dirty.isna()          # ✅ 推荐
dirty.isnull()        # 同义,老写法
dirty[pd.isna(dirty)] # 也行,但啰嗦

Q3:如何使用均值、中位数或固定值填充缺失值?#

答: 都用 fillna(),区别只是填什么。

t = dirty.copy()

# 固定值
t["金额"] = t["金额"].fillna(0)

# 均值
t["金额"] = t["金额"].fillna(t["金额"].mean())      # 4425.857...

# 中位数
t["金额"] = t["金额"].fillna(t["金额"].median())    # 2396.0

# 用前一个值填(时间序列常用)
t["金额"] = t["金额"].ffill()

# 用后一个值填
t["金额"] = t["金额"].bfill()

一次填多列用字典:

dirty.fillna({"金额": 0, "客户评分": 5.0})

均值还是中位数? 看数据有没有极端值。上面这个例子里:

  • 均值 = 4425.86
  • 中位数 = 2396.0

差别这么大,是因为金额分布是右偏的(大部分订单在 1500~8000,但有一两笔 7998 把均值往上拉)。 如果你的数据里有明显偏高的极端值,用中位数更稳。

Q4:dropna() 和 fillna() 分别适用于什么场景?#

答: 看缺失的严重程度和这行的信息还有没有用。

fillna()dropna()
做什么补上缺的格子删掉整行
行数不变变少
什么时候用缺失少、且那行其它信息有用缺失多、或关键字段缺失
典型场景评分缺失(不影响其它字段)金额缺失(金额没法补)
print(len(dirty.dropna()))                    # 6 —— 任何一格空就删
print(len(dirty.dropna(subset=["金额"])))      # 7 —— 只因金额空而删
print(len(dirty.dropna(how="all")))            # 8 —— 只有整行全空才删

实用策略:先试 subset,再试 fillna

# 优先只删关键字段缺失的行
clean = dirty.dropna(subset=["金额"])
# 剩下的小缺失再填
clean["客户评分"] = clean["客户评分"].fillna(clean["客户评分"].median())

这样比「一上来全删」损失的数据少得多。

Q5:如何删除完全重复的行?#

答: drop_duplicates(),默认保留第一次出现的。

print(len(dirty))                  # 9
print(len(dirty.drop_duplicates())) # 8

想先看看哪些行重复,用 duplicated():

print(dirty.duplicated())
[False False False False False False False False  True]

True 表示这一行和前面某一行完全一样。

drop_duplicates() 返回新表,不改原表。

dirty = dirty.drop_duplicates()   # ✅ 接住返回值
dirty.drop_duplicates()           # ❌ 白干

Q6:如何根据指定列判断重复记录?#

答: 用 subset 参数指定「拿哪几列来判断重复」。

# 只看「城市」和「商品」两列,这两列一样就算重复
print(len(dirty.drop_duplicates(subset=["城市", "商品"])))   # 7

也可以先标记再处理,这样你能看清到底哪些行有问题:

print(dirty.duplicated(subset=["城市", "商品"]))
[False False False False False False False  True  True]

业务上该按哪几列判断重复? 选**「能唯一标识一条业务记录」的字段**:

  • 订单表 → 订单号
  • 用户表 → 用户 ID
  • 日报表 → 日期(或日期 + 门店)

不要图省事用整行去重。 如果同一笔订单的两行金额不一样(改过价),整行去重会把它们都留下,你就把这笔订单算了两遍。

Q7:drop_duplicates() 中的 keep 参数有什么作用?#

答: 它决定「一组重复记录里,保留哪一条」。

keep 取值效果
"first"(默认)保留第一次出现的
"last"保留最后一次出现的
False全部删掉,一条不留
print(len(dirty.drop_duplicates(subset=["城市", "商品"])))                  # 7
print(len(dirty.drop_duplicates(subset=["城市", "商品"], keep="last")))     # 7
print(len(dirty.drop_duplicates(subset=["城市", "商品"], keep=False)))      # 5

keep="last" 的实战场景 你的订单表里,同一个订单号出现了两次:一次是早上录的旧价,一次是下午改的新价。你知道后来的更对:

df = df.drop_duplicates(subset=["订单号"], keep="last")

反过来,如果你的数据是「不断追加的新记录」,用默认的 keep="first"。

Q8:如何批量重命名列名或修改列名格式?#

答: rename(columns=...),字典和函数都能用。

# 字典:指定改哪些
df.rename(columns={"订单号": "order_id", "城市": "city"}).columns.tolist()
['order_id', '下单日期', 'city', '商品', '销量', '单价', '金额', '客户评分']
# 函数:每一列都过一遍
df.rename(columns=lambda c: c + "_x").columns.tolist()
['订单号_x', '下单日期_x', '城市_x', '商品_x', '销量_x', '单价_x', '金额_x', '客户评分_x']

处理「列名带空格」这种脏数据,更推荐直接改 columns 属性:

d2 = pd.DataFrame([[1, 2]], columns=[" 订单号 ", "下 单 日 期"])

d2.columns = d2.columns.str.strip()              # 去首尾空格
d2.columns = d2.columns.str.replace(" ", "")     # 去掉所有空格
print(d2.columns.tolist())
['订单号', '下单日期']

str 后缀家族(处理列名格式的瑞士军刀):

df.columns.str.strip()        # 去首尾空格
df.columns.str.lower()        # 转小写
df.columns.str.upper()        # 转大写
df.columns.str.replace(" ", "_")  # 替换
df.columns.str.title()        # 每个词首字母大写

Q9:replace() 和条件筛选后赋值有什么区别?#

答: 两种都是「改值」,但适用场景不同。

# 方式 A:replace —— 值到值的映射
df["商品"].replace({"手机": "智能手机"})

# 方式 B:条件筛选后赋值 —— 满足规则才改
df.loc[df["商品"] == "手机", "商品"] = "智能手机"

怎么选?

  • 只是一对一或一对多的值映射 → 用 replace(),写起来最清楚;
  • 条件是业务规则(而不是简单的值相等)→ 用 .loc,比如:
# 销量超过 3 的订单,单价打八折
df.loc[df["销量"] > 3, "单价"] = df.loc[df["销量"] > 3, "单价"] * 0.8

# 金额超过 5000 的,标记为大单
df.loc[df["金额"] > 5000, "订单类型"] = "大单"

用 .loc 赋值前记得 .copy()

df = df.copy()   # ✅
df.loc[df["销量"] > 3, "单价"] = ...

不加的话 Pandas 2.x 会给 SettingWithCopyWarning 警告,数据处理起来很脆弱。

顺便提一个坑:replace() 的正则匹配默认是关闭的。

df["城市"].replace({"北.*": "北方"})                    # 不生效
df["城市"].replace({"北.*": "北方"}, regex=True)        # 生效

Q10:如何使用 astype()、to_numeric() 和 to_datetime() 转换数据类型,并处理转换失败的情况?#

答: 三个方法分工不同,失败时处理方式也不同。

astype() —— 类型明确、能保证转成功时用,失败直接报错:

df["订单号"].astype(str).head(2).tolist()    # ['1001', '1002']
df["销量"].astype("int64")
pd.Series(["1001", "abc", "1003"]).astype(int)
# ValueError: invalid literal for int() with base 10: 'abc'

to_numeric() —— 字符串转数字,失败用 errors="coerce" 变 NaN:

s = pd.Series(["1001", "abc", "1003"])
pd.to_numeric(s, errors="coerce").tolist()
[1001.0, nan, 1003.0]

to_datetime() —— 字符串转日期,失败用 errors="coerce" 变 NaT:

pd.to_datetime(pd.Series(["2024-01-05", "不是日期"]), errors="coerce")
0   2024-01-05
1          NaT
dtype: datetime64[ns]

实战的标准三步走:

# 1. 先转换
df["金额"] = pd.to_numeric(df["金额"], errors="coerce")
df["下单日期"] = pd.to_datetime(df["下单日期"], errors="coerce")

# 2. 看看有多少转失败了
print(df["金额"].isna().sum())
print(df["下单日期"].isna().sum())

# 3. 根据数量决定:少就填掉,多就查源头
if df["金额"].isna().sum() < 5:
    df["金额"] = df["金额"].fillna(0)
else:
    raise ValueError("金额列脏数据太多,需要人工核对")

errors 的另一个取值是 "raise"(默认),意思是转换失败就抛异常。

  • "raise" —— 严格,出错就停(默认)
  • "coerce" —— 宽容,转不了变 NaN
  • "ignore" —— 直接放弃这一列,返回原样

日常清洗基本都用 "coerce",然后自己处理那些 NaN。

顺便提一个细节:astype("int64") 是直接截断,不是四舍五入。

df["客户评分"].astype("int64")              # 4.8 → 4
df["客户评分"].round(1).astype("int64")     # 4.8 → 5

需要四舍五入就先 round() 再转。

小结#

这一节的 10 个问题,其实都围绕五个动作展开:

  1. 体检——isna().sum() 看清问题,notna() 找有值的部分;
  2. 缺失值——fillna() 填(固定值 / 均值 / 中位数 / ffill),dropna(subset=...) 删;
  3. 重复值——drop_duplicates(subset=..., keep=...),先 duplicated() 看清;
  4. 列名和取值——rename(columns=...) 改列名,replace() / .loc[条件, 列] = 值 改数据;
  5. 类型——astype() 硬转,to_numeric() / to_datetime() 配 errors="coerce" 软转。

下一章预告 数据洗干净了,该问业务问题了。「每个城市卖了多少」「哪种商品最赚钱」「北京和上海哪个客单价高」——第 4 章教你用 groupby() 和 pivot_table() 把一张明细表变成一张汇总报表。

阅读 --