数据清洗与预处理¶

目标:掌握数据导入、缺失值/重复值处理、类型转换、异常值检测、文本清洗、标准化与归一化、列名调整、分类变量编码,以及一个综合案例(清洗问卷数据)。
工具:pandas、numpy、(可选)scikit-learn 的预处理模块。

本章延续“讲解 → 示范 → 举例 → 注意事项”的结构。所有代码均含中文注释,便于课堂演示与自学。

10.1 数据导入与初步查看¶

讲解:数据分析前先导入数据并快速“体检”:查看行列规模、前几行、数据类型与基本统计。
常用函数:pd.read_csv()、df.head()、df.info()、df.describe()。

In [1]:
import pandas as pd
import numpy as np

# 构造一个模拟的问卷数据 DataFrame(也可换成 pd.read_csv('yourfile.csv') 导入实际数据)
data = {
    "id": [1, 2, 3, 4, 5, 5],  # 故意放一个重复 id=5
    "name": ["张三", "李四", "王五", "赵六", "周七", "周七"],
    "gender": ["男", "女", "女", None, "男", "男"],  # None 表示缺失
    "age": [20, 22, None, 21, 200, 21],  # 200 明显异常,第三个缺失
    "major": ["社会学", "历史学", "文学", "社会学", "历史学", "历史学"],
    "join_date": ["2025-03-01", "2025/03/02", "2025-03-05", "2025-03-08", "03-10-2025", "2025-03-10"],
    "q1_satisfaction": [5, 4, 3, None, 5, 5],
    "q2_hours_ai": ["2", "3", "1", "2", "100", "2"],  # 100 可能是输入错误
    "free_text": ["课程很好!", "讲解清晰   ", "内容太难;希望更多例子", "  老师讲得不错", "很好", "很好"]
}
df = pd.DataFrame(data)

# 初步查看
print("行列规模:", df.shape)
display(df.head(3))          # 查看前3行
print("\n数据类型信息:")
print(df.info())
print("\n基本统计:")
display(df.describe(include='all'))
C:\Users\Zhouq\AppData\Roaming\Python\Python39\site-packages\pandas\core\computation\expressions.py:21: UserWarning: Pandas requires version '2.8.4' or newer of 'numexpr' (version '2.8.3' currently installed).
  from pandas.core.computation.check import NUMEXPR_INSTALLED
C:\Users\Zhouq\AppData\Roaming\Python\Python39\site-packages\pandas\core\arrays\masked.py:60: UserWarning: Pandas requires version '1.3.6' or newer of 'bottleneck' (version '1.3.5' currently installed).
  from pandas.core import (
行列规模: (6, 9)
id name gender age major join_date q1_satisfaction q2_hours_ai free_text
0 1 张三 男 20.0 社会学 2025-03-01 5.0 2 课程很好!
1 2 李四 女 22.0 历史学 2025/03/02 4.0 3 讲解清晰
2 3 王五 女 NaN 文学 2025-03-05 3.0 1 内容太难;希望更多例子
数据类型信息:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 6 entries, 0 to 5
Data columns (total 9 columns):
 #   Column           Non-Null Count  Dtype  
---  ------           --------------  -----  
 0   id               6 non-null      int64  
 1   name             6 non-null      object 
 2   gender           5 non-null      object 
 3   age              5 non-null      float64
 4   major            6 non-null      object 
 5   join_date        6 non-null      object 
 6   q1_satisfaction  5 non-null      float64
 7   q2_hours_ai      6 non-null      object 
 8   free_text        6 non-null      object 
dtypes: float64(2), int64(1), object(6)
memory usage: 560.0+ bytes
None

基本统计:
id name gender age major join_date q1_satisfaction q2_hours_ai free_text
count 6.000000 6 5 5.000000 6 6 5.000000 6 6
unique NaN 5 2 NaN 3 6 NaN 4 5
top NaN 周七 男 NaN 历史学 2025-03-01 NaN 2 很好
freq NaN 2 3 NaN 3 1 NaN 3 2
mean 3.333333 NaN NaN 56.800000 NaN NaN 4.400000 NaN NaN
std 1.632993 NaN NaN 80.054357 NaN NaN 0.894427 NaN NaN
min 1.000000 NaN NaN 20.000000 NaN NaN 3.000000 NaN NaN
25% 2.250000 NaN NaN 21.000000 NaN NaN 4.000000 NaN NaN
50% 3.500000 NaN NaN 21.000000 NaN NaN 5.000000 NaN NaN
75% 4.750000 NaN NaN 22.000000 NaN NaN 5.000000 NaN NaN
max 5.000000 NaN NaN 200.000000 NaN NaN 5.000000 NaN NaN

10.2 缺失值处理(检测 / 填充 / 删除)¶

讲解:缺失值会影响统计与建模。常见策略:

  • 检测:df.isna().sum()
  • 删除:dropna()(谨慎使用,可能丢失信息)
  • 填充:fillna()(均值/中位数/众数/固定值)
    注意:填充值要合理,并记录处理方案以便复现。
In [2]:
# 检测缺失值
print("各列缺失值数:\n", df.isna().sum())

# 示例1:用众数填充 gender;用中位数填充 age;用指定值填充 q1_satisfaction
gender_mode = df['gender'].mode(dropna=True)[0]
df['gender'] = df['gender'].fillna(gender_mode)

age_median = df['age'].median(skipna=True)
df['age'] = df['age'].fillna(age_median)

df['q1_satisfaction'] = df['q1_satisfaction'].fillna(3)  # 以3作为中性填充示例

print("\n缺失处理后各列缺失值数:\n", df.isna().sum())
display(df.head())
各列缺失值数:
 id                 0
name               0
gender             1
age                1
major              0
join_date          0
q1_satisfaction    1
q2_hours_ai        0
free_text          0
dtype: int64

缺失处理后各列缺失值数:
 id                 0
name               0
gender             0
age                0
major              0
join_date          0
q1_satisfaction    0
q2_hours_ai        0
free_text          0
dtype: int64
id name gender age major join_date q1_satisfaction q2_hours_ai free_text
0 1 张三 男 20.0 社会学 2025-03-01 5.0 2 课程很好!
1 2 李四 女 22.0 历史学 2025/03/02 4.0 3 讲解清晰
2 3 王五 女 21.0 文学 2025-03-05 3.0 1 内容太难;希望更多例子
3 4 赵六 男 21.0 社会学 2025-03-08 3.0 2 老师讲得不错
4 5 周七 男 200.0 历史学 03-10-2025 5.0 100 很好

10.3 重复值处理¶

讲解:重复记录会导致统计失真。

  • 检测:df.duplicated()
  • 删除:df.drop_duplicates()
    策略:对同一 id 多条记录时,可按时间或数据完整度选择保留其中一条。
In [3]:
# 检测重复行
print("是否存在重复行:", df.duplicated().any())
print("重复行索引:", df[df.duplicated()].index.tolist())

# 简单策略:直接去重(保留第一次出现)
df = df.drop_duplicates().reset_index(drop=True)
print("去重后形状:", df.shape)
display(df)
是否存在重复行: False
重复行索引: []
去重后形状: (6, 9)
id name gender age major join_date q1_satisfaction q2_hours_ai free_text
0 1 张三 男 20.0 社会学 2025-03-01 5.0 2 课程很好!
1 2 李四 女 22.0 历史学 2025/03/02 4.0 3 讲解清晰
2 3 王五 女 21.0 文学 2025-03-05 3.0 1 内容太难;希望更多例子
3 4 赵六 男 21.0 社会学 2025-03-08 3.0 2 老师讲得不错
4 5 周七 男 200.0 历史学 03-10-2025 5.0 100 很好
5 5 周七 男 21.0 历史学 2025-03-10 5.0 2 很好

10.4 数据类型转换(字符串→数值 / 日期 / 类别)¶

讲解:原始数据常以字符串形式出现,需要转换:

  • 数值:pd.to_numeric(errors='coerce')
  • 日期:pd.to_datetime(errors='coerce')(可自动识别常见格式)
  • 类别:astype('category')
    注意:转换失败设为 NaT/NaN,后续再处理。
In [4]:
# 将 q2_hours_ai 转为数值(遇到无法转换的设为 NaN)
df['q2_hours_ai'] = pd.to_numeric(df['q2_hours_ai'], errors='coerce')

# 将 join_date 转为日期
df['join_date'] = pd.to_datetime(df['join_date'], errors='coerce')

# 将 major 转为类别类型(节省内存、利于分类分析)
df['major'] = df['major'].astype('category')

print(df.dtypes)
display(df[['q2_hours_ai','join_date','major']].head())
id                          int64
name                       object
gender                     object
age                       float64
major                    category
join_date          datetime64[ns]
q1_satisfaction           float64
q2_hours_ai                 int64
free_text                  object
dtype: object
q2_hours_ai join_date major
0 2 2025-03-01 社会学
1 3 NaT 历史学
2 1 2025-03-05 文学
3 2 2025-03-08 社会学
4 100 NaT 历史学

10.5 异常值检测与处理¶

讲解:异常值可能来自录入错误或极端情况。

  • 简单数值规则:如年龄 0<age<100
  • 四分位距(IQR)法:Q1-1.5*IQR 至 Q3+1.5*IQR 之外为异常
    策略:更正、裁剪(winsorize)、或设为缺失再填充;必须结合领域知识判断。
In [5]:
# 简单规则:年龄在[0,100]之外视为异常,设为 NaN 再用中位数填充
outlier_mask_age = (df['age'] < 0) | (df['age'] > 100)
print("异常年龄数量:", outlier_mask_age.sum())
df.loc[outlier_mask_age, 'age'] = np.nan
df['age'] = df['age'].fillna(df['age'].median())

# 对学习AI小时数进行 IQR 检测
q1 = df['q2_hours_ai'].quantile(0.25)
q3 = df['q2_hours_ai'].quantile(0.75)
iqr = q3 - q1
lower, upper = q1 - 1.5*iqr, q3 + 1.5*iqr
print("q2_hours_ai 合理范围:", (lower, upper))

# 将超过范围的值设为上/下阈值(winsorize 裁剪示例)
df['q2_hours_ai'] = df['q2_hours_ai'].clip(lower, upper)

display(df[['age','q2_hours_ai']])
异常年龄数量: 1
q2_hours_ai 合理范围: (0.875, 3.875)
age q2_hours_ai
0 20.0 2.000
1 22.0 3.000
2 21.0 1.000
3 21.0 2.000
4 21.0 3.875
5 21.0 2.000

10.6 文本清洗(去空白/特殊字符/统一大小写)¶

讲解:文本常见清洗动作:

  • 去前后空白:str.strip()
  • 统一空白:str.replace(r"\s+", " ", regex=True)
  • 统一大小写:str.lower()
  • 去除标点/特殊字符(基于正则)
    注意:中文文本的标点与空格情况和英文不同,正则要充分测试。
In [6]:
# 对 free_text 进行简单清洗
df['free_text_clean'] = (
    df['free_text']
    .astype(str)
    .str.strip()
    .str.replace(r"\s+", " ", regex=True)  # 统一多空白为单空格
    .str.replace(";", ";")                  # 统一中文分号为英文分号
    .str.replace(",", ",")                  # 统一中文逗号为英文逗号
)

display(df[['free_text','free_text_clean']])
free_text free_text_clean
0 课程很好! 课程很好!
1 讲解清晰 讲解清晰
2 内容太难;希望更多例子 内容太难;希望更多例子
3 老师讲得不错 老师讲得不错
4 很好 很好
5 很好 很好

10.7 数据标准化与归一化¶

讲解:为消除量纲影响或提升模型稳定性,常对数值变量做:

  • 标准化:均值0、方差1((x-mean)/std)
  • 归一化:映射到 [0,1]((x-min)/(max-min))
    注意:在训练/测试划分场景,应使用训练集拟合的参数对测试集变换。
In [7]:
from sklearn.preprocessing import StandardScaler, MinMaxScaler

num_cols = ['age', 'q1_satisfaction', 'q2_hours_ai']
num_df = df[num_cols].copy()

# 标准化
std_scaler = StandardScaler()
std_scaled = std_scaler.fit_transform(num_df)
std_scaled_df = pd.DataFrame(std_scaled, columns=[c+"_std" for c in num_cols])

# 归一化
mm_scaler = MinMaxScaler()
mm_scaled = mm_scaler.fit_transform(num_df)
mm_scaled_df = pd.DataFrame(mm_scaled, columns=[c+"_minmax" for c in num_cols])

scaled_result = pd.concat([df[num_cols], std_scaled_df, mm_scaled_df], axis=1)
display(scaled_result.head())
age q1_satisfaction q2_hours_ai age_std q1_satisfaction_std q2_hours_ai_std age_minmax q1_satisfaction_minmax q2_hours_ai_minmax
0 20.0 5.0 2.000 -1.732051 0.928477 -0.344759 0.0 1.0 0.347826
1 22.0 4.0 3.000 1.732051 -0.185695 0.758470 1.0 0.5 0.695652
2 21.0 3.0 1.000 0.000000 -1.299867 -1.447989 0.5 0.0 0.000000
3 21.0 3.0 2.000 0.000000 -1.299867 -0.344759 0.5 0.0 0.347826
4 21.0 5.0 3.875 0.000000 0.928477 1.723796 0.5 1.0 1.000000

10.8 重命名列与重新排序¶

讲解:统一规范的列名便于协作与复现。

  • 重命名:df.rename(columns={...})
  • 调整顺序:按列表重排列。
In [8]:
# 统一英文化列名(示例)
rename_map = {
    "name": "Name", "gender": "Gender", "major": "Major",
    "join_date": "JoinDate", "q1_satisfaction": "Q1_Satisfaction",
    "q2_hours_ai": "Q2_HoursAI", "free_text": "FreeText", "free_text_clean": "FreeTextClean"
}
df2 = df.rename(columns=rename_map)

# 重新排序
new_order = ["id", "Name", "Gender", "age", "Major", "JoinDate",
             "Q1_Satisfaction", "Q2_HoursAI", "FreeText", "FreeTextClean"]
df2 = df2[new_order]

display(df2.head())
id Name Gender age Major JoinDate Q1_Satisfaction Q2_HoursAI FreeText FreeTextClean
0 1 张三 男 20.0 社会学 2025-03-01 5.0 2.000 课程很好! 课程很好!
1 2 李四 女 22.0 历史学 NaT 4.0 3.000 讲解清晰 讲解清晰
2 3 王五 女 21.0 文学 2025-03-05 3.0 1.000 内容太难;希望更多例子 内容太难;希望更多例子
3 4 赵六 男 21.0 社会学 2025-03-08 3.0 2.000 老师讲得不错 老师讲得不错
4 5 周七 男 21.0 历史学 NaT 5.0 3.875 很好 很好

10.9 处理分类变量(One-Hot / Label Encoding)¶

讲解:模型常需要数值特征:

  • Label Encoding:将类别映射为整数(有序假设)
  • One-Hot:每个类别一列(无序更安全)
    注意:类别很多时,One-Hot 会带来维度膨胀。
In [9]:
from sklearn.preprocessing import LabelEncoder

# Label Encoding(对 Gender)
le = LabelEncoder()
df2['Gender_le'] = le.fit_transform(df2['Gender'].astype(str))

# One-Hot(对 Major)
df_ohe = pd.get_dummies(df2, columns=['Major'], prefix='Major')
display(df2[['Gender','Gender_le']].head())
display(df_ohe.filter(like='Major_').head())
Gender Gender_le
0 男 1
1 女 0
2 女 0
3 男 1
4 男 1
Major_历史学 Major_文学 Major_社会学
0 False False True
1 True False False
2 False True False
3 False False True
4 True False False

10.10 综合案例:清洗问卷调查数据(从原始到可分析)¶

任务:对第 10.1 的“原始”问卷数据完成以下步骤并得到“可分析版”:

  1. 去重、类型转换(数值/日期/类别);
  2. 缺失/异常处理;
  3. 文本清洗;
  4. 编码(性别/专业);
  5. 生成一份“清洗日志”(记录关键变更)。
In [10]:
# 1) 复制原始数据(此处用 df2 的英文化版本继续)
clean = df2.copy()
log = []  # 简单字符串日志列表

# 2) 去重
dup_n = clean.duplicated().sum()
if dup_n > 0:
    log.append(f"去重:删除 {dup_n} 行重复记录")
clean = clean.drop_duplicates().reset_index(drop=True)

# 3) 类型检查:已经在前面做过,这里示意追加说明
log.append("类型转换:Q2_HoursAI->数值;JoinDate->日期;Major->分类")

# 4) 缺失与异常:年龄限制到[0,100],缺失用中位数填充
before_na = clean.isna().sum().sum()
clean['age'] = clean['age'].mask((clean['age']<0)|(clean['age']>100), np.nan)
clean['age'] = clean['age'].fillna(clean['age'].median())
after_na = clean.isna().sum().sum()
log.append(f"缺失/异常:总缺失从 {before_na} 降到 {after_na}")

# 5) 文本清洗:已在 FreeTextClean 中
log.append("文本清洗:生成 FreeTextClean(strip/多空白合一/标点统一)")

# 6) 编码:性别 Label Encoding;专业 One-Hot
from sklearn.preprocessing import LabelEncoder
le2 = LabelEncoder()
clean['Gender_le'] = le2.fit_transform(clean['Gender'].astype(str))
clean = pd.get_dummies(clean, columns=['Major'], prefix='Major')
log.append("编码:Gender->LabelEncoding;Major->One-Hot")

# 7) 导出可分析子集(示例)
analysis_cols = ['id','Name','Gender','Gender_le','age','JoinDate','Q1_Satisfaction','Q2_HoursAI','FreeTextClean']
analysis_cols += [c for c in clean.columns if c.startswith('Major_')]
analysis_ready = clean[analysis_cols].sort_values('id')

display(analysis_ready)
print("\n—— 清洗日志 ——")
print("\n".join(log))
id Name Gender Gender_le age JoinDate Q1_Satisfaction Q2_HoursAI FreeTextClean Major_历史学 Major_文学 Major_社会学
0 1 张三 男 1 20.0 2025-03-01 5.0 2.000 课程很好! False False True
1 2 李四 女 0 22.0 NaT 4.0 3.000 讲解清晰 True False False
2 3 王五 女 0 21.0 2025-03-05 3.0 1.000 内容太难;希望更多例子 False True False
3 4 赵六 男 1 21.0 2025-03-08 3.0 2.000 老师讲得不错 False False True
4 5 周七 男 1 21.0 NaT 5.0 3.875 很好 True False False
5 5 周七 男 1 21.0 2025-03-10 5.0 2.000 很好 True False False
—— 清洗日志 ——
类型转换:Q2_HoursAI->数值;JoinDate->日期;Major->分类
缺失/异常:总缺失从 2 降到 2
文本清洗:生成 FreeTextClean(strip/多空白合一/标点统一)
编码:Gender->LabelEncoding;Major->One-Hot

注意事项与小结¶

  1. 记录可追溯性:任何清洗动作(删除/填充/裁剪)都应记录到“清洗日志”。
  2. 最小改动原则:在不了解数据的前提下,不要过度“修理”数据。
  3. 领域知识驱动:异常值判断要与领域常识结合(如年龄范围、学习时长的上限等)。
  4. 训练/测试一致性:建模场景中,变换(标准化/编码)要用训练集拟合、再作用于测试集。
  5. 保存中间版本:原始数据(raw)→中间数据(intermediate)→清洗数据(clean)。

练习:把你的真实问卷或文本语料进行相同流程的清洗,并把每一步写入“清洗日志”。

In [ ]: