Pandas 数据清洗
问题
如何用 Pandas 进行数据清洗?缺失值、重复值、异常值分别怎么处理?
答案
数据清洗是数据分析中最耗时的环节(占 60%~80% 的时间),也是面试中的高频考点。脏数据进去,垃圾结论出来——清洗质量直接决定分析结论的可靠性。
数据清洗流程
1. 查看数据概况
清洗前先全面了解数据质量:
数据质量检查
import pandas as pd
import numpy as np
# 基本信息
df.info() # 列名、非空数、数据类型
df.describe() # 数值列统计
df.shape # (行数, 列数)
# 缺失值检查
df.isnull().sum() # 每列缺失数
df.isnull().sum() / len(df) # 缺失率
df.isnull().any() # 哪些列有缺失
# 重复值检查
df.duplicated().sum() # 完全重复的行数
df.duplicated(subset=['id']).sum() # 按指定列检查
# 唯一值检查
df['status'].value_counts() # 各值出现次数
df['status'].nunique() # 唯一值数量
2. 缺失值处理
面试核心
缺失值处理是面试必考题,要能说清楚"什么场景用什么方法",而不是只知道 fillna。
检测缺失
检测缺失值
# 单列检查
df['col'].isnull() # 返回布尔 Series
df['col'].notna() # 非缺失
# 筛选包含缺失值的行
df[df['col'].isnull()]
# 查看缺失值模式
df.isnull().sum().sort_values(ascending=False)
删除缺失
删除缺失值
# 删除任何列有缺失的行
df.dropna()
# 删除指定列有缺失的行
df.dropna(subset=['name', 'age'])
# 删除所有值都缺失的行
df.dropna(how='all')
# 删除缺失率超过 50% 的列
threshold = len(df) * 0.5
df.dropna(axis=1, thresh=threshold)
填充缺失
填充缺失值
# 固定值填充
df['city'].fillna('未知')
# 均值/中位数/众数填充
df['age'].fillna(df['age'].mean()) # 均值
df['age'].fillna(df['age'].median()) # 中位数(对异常值更稳健)
df['city'].fillna(df['city'].mode()[0]) # 众数(分类变量)
# 前向/后向填充(时间序列常用)
df['price'].ffill() # 用前一个值填充 (forward fill)
df['price'].bfill() # 用后一个值填充 (backward fill)
# 分组填充(更精确)
# 比如按城市分组,用各城市的中位数填充
df['salary'] = df.groupby('city')['salary'].transform(
lambda x: x.fillna(x.median())
)
# 插值法(连续数值)
df['temperature'].interpolate(method='linear') # 线性插值
df['temperature'].interpolate(method='time') # 按时间插值
缺失值处理策略选择
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| 缺失率 < 5% | 删除行 | 影响小,简单高效 |
| 缺失率 5%~30% | 填充 | 删除损失太多数据 |
| 缺失率 > 50% | 删除列或特殊标记 | 数据质量太差 |
| 数值型 + 无异常值 | 均值填充 | 保持分布 |
| 数值型 + 有异常值 | 中位数填充 | 抗异常值 |
| 分类型 | 众数填充或 "Unknown" | 保持一致性 |
| 时间序列 | ffill/bfill/插值 | 利用时序连续性 |
| 存在分组特征 | 分组填充 | 更精确 |
3. 重复值处理
重复值处理
# 查看重复行
df[df.duplicated()] # 完全重复
df[df.duplicated(subset=['user_id'])] # 按指定列判断
# 删除重复行
df.drop_duplicates() # 删除完全重复行(保留第一条)
df.drop_duplicates(subset=['user_id']) # 按 user_id 去重
df.drop_duplicates(subset=['user_id'], keep='last') # 保留最后一条
df.drop_duplicates(subset=['user_id'], keep=False) # 全部删除
# 实际场景:保留最新的一条记录
df = df.sort_values('create_time', ascending=False)
df = df.drop_duplicates(subset=['order_id'], keep='first')
4. 异常值处理
检测异常值
异常值检测
# 方法一:3σ 原则(正态分布数据)
mean = df['salary'].mean()
std = df['salary'].std()
outliers = df[(df['salary'] < mean - 3*std) | (df['salary'] > mean + 3*std)]
# 方法二:IQR 方法(箱线图原理,更稳健)
Q1 = df['salary'].quantile(0.25)
Q3 = df['salary'].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
outliers = df[(df['salary'] < lower) | (df['salary'] > upper)]
# 方法三:分位数法
lower = df['salary'].quantile(0.01)
upper = df['salary'].quantile(0.99)
outliers = df[(df['salary'] < lower) | (df['salary'] > upper)]
处理异常值
异常值处理
# 方法一:删除
df = df[(df['salary'] >= lower) & (df['salary'] <= upper)]
# 方法二:截断(Winsorize)—— 将超出的值拉到边界
df['salary'] = df['salary'].clip(lower=lower, upper=upper)
# 方法三:替换为 NaN,后续按缺失值处理
df.loc[df['salary'] > upper, 'salary'] = np.nan
5. 类型转换
数据类型转换
# 查看类型
df.dtypes
# 数值转换
df['price'] = pd.to_numeric(df['price'], errors='coerce') # 无法转换的变 NaN
# 日期转换
df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d')
df['date'] = pd.to_datetime(df['date'], errors='coerce') # 无法解析的变 NaT
# 分类类型(节省内存)
df['city'] = df['city'].astype('category')
# bool 转换
df['is_active'] = df['is_active'].astype(bool)
# 降低数值精度节省内存
df['id'] = df['id'].astype('int32') # int64 → int32
df['score'] = df['score'].astype('float32') # float64 → float32
6. 字符串清洗
字符串清洗
# 去除多余空格
df['name'] = df['name'].str.strip()
# 统一大小写
df['email'] = df['email'].str.lower()
# 替换特殊字符
df['phone'] = df['phone'].str.replace('-', '').str.replace(' ', '')
# 提取数字
df['amount'] = df['amount_str'].str.extract(r'(\d+\.?\d*)').astype(float)
# 统一分类值
df['gender'] = df['gender'].str.strip().str.lower()
df['gender'] = df['gender'].replace({'m': 'male', 'f': 'female', '男': 'male', '女': 'female'})
完整清洗模板
数据清洗实战模板
import pandas as pd
import numpy as np
def clean_data(df):
"""通用数据清洗流程"""
print(f"原始数据: {df.shape}")
# 1. 删除完全重复行
df = df.drop_duplicates()
print(f"去重后: {df.shape}")
# 2. 处理缺失值
# 数值列用中位数填充
num_cols = df.select_dtypes(include=[np.number]).columns
for col in num_cols:
df[col] = df[col].fillna(df[col].median())
# 分类列用众数填充
cat_cols = df.select_dtypes(include=['object', 'category']).columns
for col in cat_cols:
df[col] = df[col].fillna(df[col].mode()[0] if not df[col].mode().empty else 'Unknown')
# 3. 字符串清洗
for col in cat_cols:
df[col] = df[col].str.strip()
# 4. 异常值处理(IQR 方法截断)
for col in num_cols:
Q1 = df[col].quantile(0.25)
Q3 = df[col].quantile(0.75)
IQR = Q3 - Q1
df[col] = df[col].clip(Q1 - 1.5*IQR, Q3 + 1.5*IQR)
print(f"清洗后: {df.shape}")
return df
常见面试问题
Q1: 缺失值填充用均值还是中位数?
答案:
- 均值适合数据近似正态分布、无明显异常值的场景
- 中位数更稳健,不受异常值影响,推荐作为默认选择
- 分类变量用众数或特殊标记值如 "Unknown"
- 最佳实践是按业务分组填充,如用同品类的中位数填充缺失价格
Q2: dropna() 和 fillna() 怎么选?
答案:
- 缺失比例 < 5%:
dropna()删除即可,数据损失小 - 缺失比例 5%~30%:
fillna()填充,避免丢失过多样本 - 缺失比例 > 50%:考虑删除该列,或者缺失本身做为一个特征
- 如果缺失不是随机的(如高收入用户不愿填写收入),删除会引入偏差,应填充
Q3: 如何检测和处理异常值?
答案:
- 3σ 法则:假设正态分布,超过均值 ± 3 倍标准差的为异常值
- IQR 法:
Q1 - 1.5*IQR到Q3 + 1.5*IQR之外为异常值(更稳健) - 处理方式:删除、截断(
clip)、替换为 NaN 再填充 - 注意:需结合业务判断,如电商中的大额订单可能是正常的批发单
Q4: category 类型有什么好处?
答案:
# 将字符串列转为 category
df['city'] = df['city'].astype('category')
- 内存节省:重复字符串只存一次编码,可节省 50%~90% 内存
- 性能提升:groupby、排序等操作更快
- 适用场景:取值范围有限的分类列(如城市、状态、等级)
Q5: 如何高效处理千万级数据的清洗?
答案:
- 分块读取:
pd.read_csv(chunksize=100000)分批处理 - 指定类型:
dtype={'id': 'int32'}减少内存 - 只读需要的列:
usecols=['col1', 'col2'] - 使用 Parquet:列式存储,读取比 CSV 快 5~10 倍
- 考虑 Polars:Rust 后端,比 Pandas 快 5~20 倍