数据清洗往往不是最令人愉快的任务!然而,如果你问任何一位资深数据专业人士他们最有价值的技能是什么,许多人会告诉你:是他们将杂乱无章的数据整理成有用信息的能力。

本文涵盖了所有成功数据项目中必不可少的清洗技术。我还提供了实用的代码示例和一个样本数据集,方便你跟着操作,将这些方法应用到自己的工作中。

让我们开始吧!

注意:你可以使用这份样本混乱数据集跟随本文进行操作。

1. 处理缺失值

缺失数据或许是现实世界数据中最常见的问题。它可能导致分析结果偏差,使结论不可靠。因此,识别和处理缺失值是数据清洗的第一步。

识别缺失值

首先,需要量化并了解缺失数据的分布情况:

import pandas as pd

import numpy as np

# 载入样本数据集

df = pd.read_csv('messy_data.csv')

# 统计每列缺失值的数量和百分比

missing_count = df.isnull().sum()

missing_percentage = (df.isnull().sum() / len(df)) * 100

# 显示有缺失值的列,并按缺失数量降序排列

missing_data = pd.concat([missing_count, missing_percentage], axis=1,

keys=['Count', 'Percentage'])

print(missing_data[missing_data['Count'] > 0].sort_values('Count', ascending=False))

这段代码会加载数据集,统计每列缺失值数量及其占比,并仅显示含有缺失值的列,按缺失条目数排序。

删除策略

有时,当别无选择时,你可能需要删除含有缺失值的行或列。

删除完全为空的行:

df_clean = df.dropna(how='all')

删除关键列(如 name、email、age)存在缺失的行:

df_clean = df.dropna(subset=['name', 'email', 'age'])

删除缺失值超过50%的整列:

threshold = len(df) * 0.5

df_clean = df.dropna(axis=1, thresh=threshold)

填补缺失值(插补)

插补是用有意义的估计值替换缺失项。针对不同数据类型,插补策略也不同:

对于数值型数据(如 age),用中位数(受异常值影响较小);

对于收入(income),用平均值;

对于评价(customer_rating),用众数(最常见值);

对于类别型数据(如 gender),用众数或合理默认值。

df['age'] = df['age'].fillna(df['age'].median())

df['income'] = df['income'].fillna(df['income'].mean())

df['customer_rating'] = df['customer_rating'].fillna(df['customer_rating'].mode()[0])

df['gender'] = df['gender'].fillna(df['gender'].mode()[0])

df['comments'] = df['comments'].fillna('No comment provided')

更高级的方法是 KNN 插补,根据相似记录推测缺失值:

from sklearn.impute import KNNImputer

numeric_cols = ['age', 'income', 'customer_rating', 'purchase_amount']

imputer = KNNImputer(n_neighbors=3)

df[numeric_cols] = pd.DataFrame(

imputer.fit_transform(df[numeric_cols]),

columns=numeric_cols,

index=df.index

)

继续上文,以下是数据清洗指南的第2-4部分翻译:

2. 删除重复值

重复记录会导致统计结果失真,甚至得出错误结论。

首先,我们检查所有列的完全重复值:

# 查找完全重复的行

exact_duplicates = df.duplicated().sum()

print(f"Number of exact duplicate rows: {exact_duplicates}")

# 删除完全重复行

df_unique = df.drop_duplicates()

接着,寻找基于关键标识(如 name 和 email)的“功能性”重复:

# 查找可能的功能性重复(基于关键字段)

potential_duplicates = df.duplicated(subset=['name', 'email'], keep=False)

print(f"Number of potential functional duplicates: {potential_duplicates.sum()}")

print("Potential duplicate records:")

print(df[potential_duplicates].sort_values('name'))

对于重复项,我们保留信息最完整(缺失值最少)的记录:

# 先按非空字段数量排序,再去重

df['completeness'] = df.notna().sum(axis=1)

df_sorted = df.sort_values('completeness', ascending=False)

df_clean = df_sorted.drop_duplicates(subset=['name', 'email'])

df_clean = df_clean.drop(columns=['completeness'])

3. 标准化文本数据

文本不一致会制造不必要的“多样性”,增加分析难度。标准化文本字段能确保一致性。

首先,统一文本字段的大小写:姓名用标题格式,国家用大写,职位用小写。

# 统一类别字段的大小写

df['name'] = df['name'].str.title()

df['country'] = df['country'].str.upper()

df['job_title'] = df['job_title'].str.lower()

利用映射字典标准化国家名称和性别:

# 标准化国家名称

country_mapping = {

'US': 'USA',

'U.S.A.': 'USA',

'United States': 'USA',

'united states': 'USA',

'United states': 'USA'

}

df['country'] = df['country'].replace(country_mapping)

# 标准化性别取值

gender_mapping = {

'M': 'Male',

'm': 'Male',

'Male': 'Male',

'male': 'Male',

'F': 'Female',

'f': 'Female',

'Female': 'Female',

'female': 'Female'

}

df['gender'] = df['gender'].replace(gender_mapping)

最后,定义自定义函数,结合关键词标准化教育背景:

# 标准化学历信息

def standardize_education(edu_str):

if pd.isna(edu_str):

return np.nan

edu_str = str(edu_str).lower().strip()

if 'bachelor' in edu_str:

return "Bachelor's Degree"

elif 'master' in edu_str or 'mba' in edu_str or 'msc' in edu_str:

return "Master's Degree"

elif 'phd' in edu_str or 'doctor' in edu_str:

return "Doctorate"

else:

return "Other"

df['education'] = df['education'].apply(standardize_education)

4. 处理异常值

异常值可能极大影响统计与建模效果,正确识别和处理异常值至关重要。

首先,确保数值型字段为数字格式:

df['income'] = pd.to_numeric(df['income'], errors='coerce')

df['age'] = pd.to_numeric(df['age'], errors='coerce')

然后,采用两种方法检测异常值:Z-score(正态分布假设)和 IQR(对非正态数据更鲁棒)。

# 用 Z-score 方法检测异常值

from scipy import stats

z_scores = stats.zscore(df['income'].dropna())

outliers_z = (abs(z_scores) > 3)

print(f"Z-score method identified {outliers_z.sum()} outliers in income")

# IQR 方法检测异常值

Q1 = df['income'].quantile(0.25)

Q3 = df['income'].quantile(0.75)

IQR = Q3 - Q1

lower_bound = Q1 - 1.5 * IQR

upper_bound = Q3 + 1.5 * IQR

outliers_iqr = ((df['income'] < lower_bound) | (df['income'] > upper_bound))

print(f"IQR method identified {outliers_iqr.sum()} outliers in income")

# 可视化异常值

import matplotlib.pyplot as plt

plt.figure(figsize=(10, 6))

plt.boxplot(df['income'].dropna())

plt.title('Income Distribution with Outliers')

plt.ylabel('Income')

plt.show()

最后,修正明显错误值(如负收入),并用分位数法(如 winsorization)削弱极端值的影响:

# 修正不应为负的数值

df.loc[df['income'] < 0, 'income'] = np.nan # 替换为缺失值,后续处理

# 分位数法对极端值进行截断

def cap_outliers(series, lower_percentile=0.05, upper_percentile=0.95):

lower_limit = series.quantile(lower_percentile)

upper_limit = series.quantile(upper_percentile)

return series.clip(lower=lower_limit, upper=upper_limit)

df['income_capped'] = cap_outliers(df['income'], 0.01, 0.99)

继续上文,以下是数据清洗指南的第5-7部分翻译:

5. 数据类型转换

确保数据类型正确能提升性能,并保证各列能够进行合适的操作。

我们先检查当前的数据类型,然后将字符串格式的数值型数据转换为真正的数值类型,对于无法转换的内容以 NaN 处理:

# 显示当前数据类型

print(df.dtypes)

# 将字符串转换为数值型

df['age'] = pd.to_numeric(df['age'], errors='coerce')

df['income'] = pd.to_numeric(df['income'], errors='coerce')

df['customer_rating'] = pd.to_numeric(df['customer_rating'], errors='coerce')

df['purchase_amount'] = pd.to_numeric(df['purchase_amount'], errors='coerce')

对于日期字段,由于数据格式可能不一致,我们自定义函数尝试多种格式解析,解析成功后还可提取年份、月份等信息,并计算在职天数:

# 处理日期格式不一致

def parse_date(date_str):

if pd.isna(date_str):

return np.nan

for fmt in ['%Y-%m-%d', '%Y/%m/%d', '%m/%d/%Y']:

try:

return pd.to_datetime(date_str, format=fmt)

except:

continue

return pd.NaT

df['start_date'] = df['start_date'].apply(parse_date)

# 提取有用时间特征

df['start_year'] = df['start_date'].dt.year

df['start_month'] = df['start_date'].dt.month

df['tenure_days'] = (pd.Timestamp('now') - df['start_date']).dt.days

最后,统一手机号格式,先去除所有非数字字符,然后按统一格式输出:

# 统一手机号格式

def standardize_phone(phone):

if pd.isna(phone):

return np.nan

digits_only = ''.join(c for c in str(phone) if c.isdigit())

if len(digits_only) == 10:

return f"{digits_only[:3]}-{digits_only[3:6]}-{digits_only[6:]}"

else:

return digits_only # 非10位时原样返回

df['phone_number'] = df['phone_number'].apply(standardize_phone)

6. 处理分类变量的不一致

分类变量常常存在拼写不一致、大小写混乱、类别过多等问题。

首先,查看职位名称的分布,然后通过映射将相似职位统一:

# 查看职位名称的唯一值分布

print(f"Original job title count: {df['job_title'].nunique()}")

print(df['job_title'].value_counts())

# 标准化职位名称

job_mapping = {

'sr. developer': 'senior developer',

'senior developer': 'senior developer',

'ux designer': 'designer',

'regional manager': 'manager',

'project manager': 'manager',

'product manager': 'manager',

'lead engineer': 'senior developer',

'bi analyst': 'data analyst',

'data scientist': 'data analyst',

'hr specialist': 'specialist',

'marketing specialist': 'specialist'

}

df['standardized_job'] = df['job_title'].str.lower().replace(job_mapping)

接着,将部门字段归并到更宽泛的类别中:

# 部门归类

dept_categories = {

'IT': 'Technology',

'Engineering': 'Technology',

'Analytics': 'Technology',

'Design': 'Creative',

'Marketing': 'Business',

'Product': 'Business',

'Executive': 'Management',

'Human Resources': 'Operations',

'Management': 'Management'

}

df['dept_category'] = df['department'].replace(dept_categories)

最后,将出现频率较低的职位归为“Other”:

# 处理稀有类别,归入“Other”

value_counts = df['standardized_job'].value_counts()

threshold = 2 # 低于该次数归为Other

frequent_jobs = value_counts[value_counts >= threshold].index

df['job_grouped'] = df['standardized_job'].apply(

lambda x: x if x in frequent_jobs else 'Other'

)

7. 特征缩放与归一化

如果各特征取值范围差异很大,许多机器学习算法会受到影响,因此特征缩放很重要。

首先选出需要缩放的数值型字段,并填补缺失值:

from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler

# 选取数值字段并填充缺失值

numeric_cols = ['age', 'income', 'customer_rating', 'purchase_amount']

numeric_df = df[numeric_cols].copy()

numeric_df = numeric_df.fillna(numeric_df.median())

然后分别用三种方法缩放:

标准化(StandardScaler):均值为0,标准差为1

scaler = StandardScaler()

scaled_data = scaler.fit_transform(numeric_df)

df_scaled = pd.DataFrame(scaled_data,

columns=[f"{col}_scaled" for col in numeric_cols],

index=df.index)

最小-最大缩放(MinMaxScaler):全部映射到[0,1]区间

min_max = MinMaxScaler()

minmax_data = min_max.fit_transform(numeric_df)

df_minmax = pd.DataFrame(minmax_data,

columns=[f"{col}_minmax" for col in numeric_cols],

index=df.index)

鲁棒缩放(RobustScaler):基于中位数和四分位数,对异常值不敏感

robust = RobustScaler()

robust_data = robust.fit_transform(numeric_df)

df_robust = pd.DataFrame(robust_data,

columns=[f"{col}_robust" for col in numeric_cols],

index=df.index)

将所有缩放结果与原始数据合并,并对比不同缩放效果:

df_with_scaled = pd.concat([df, df_scaled, df_minmax, df_robust], axis=1)

print(df_with_scaled[['income', 'income_scaled', 'income_minmax', 'income_robust']].head())

这样可以直观比较如“收入”(千位数)与“评分”(1-5分)等不同尺度特征在归一化后的表现。

继续上文,以下是数据清洗指南的第8-10部分翻译:

8. 字符串清洗与正则表达式

正则表达式对于清洗文本数据和从中提取信息非常有用。对于样本数据集(以及任何数据集),我们可以实现多种字符串清洗技巧。

首先,标准化姓名的大小写,并根据姓名提取“名”和“姓”:

import re

# 清理姓名:去除多余空格、标准化大小写

df['name_clean'] = df['name'].str.strip().str.title()

# 提取名和姓

def extract_names(full_name):

if pd.isna(full_name):

return pd.Series([np.nan, np.nan])

parts = full_name.strip().split()

if len(parts) >= 2:

return pd.Series([parts[0], parts[-1]])

else:

return pd.Series([parts[0] if parts else np.nan, np.nan])

name_parts = df['name_clean'].apply(extract_names)

df['first_name'] = name_parts[0]

df['last_name'] = name_parts[1]

对邮箱进行正则校验,并尝试修复常见问题:

# 校验和清洗邮箱地址

def clean_email(email):

if pd.isna(email):

return np.nan

pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'

if re.match(pattern, email):

return email.lower()

else:

# 修复常见问题

if '@' not in email:

return np.nan

if not re.search(r'\.[a-zA-Z]{2,}$', email):

return email.lower() + '.com'

return email.lower()

df['email_clean'] = df['email'].apply(clean_email)

提取邮箱的域名信息:

# 提取邮箱域名

df['email_domain'] = df['email_clean'].str.extract(r'@([^@]+)$')

将不同格式的电话号码统一为一致的格式:

# 标准化/提取电话号码

def extract_phone(phone):

if pd.isna(phone):

return np.nan

digits = re.sub(r'\D', '', str(phone))

if len(digits) == 10:

return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"

elif len(digits) > 10: # 包含国家码

return f"({digits[-10:-7]}) {digits[-7:-4]}-{digits[-4:]}"

else:

return np.nan

df['phone_standardized'] = df['phone_number'].apply(extract_phone)

这些技术让文本数据更一致,也能从非结构化或半结构化字段中提取结构化信息。

9. 脏数据中的特征工程

有时候,数据中的混乱本身也蕴含信息,你可以据此创造新的特征。

缺失值指示器:为关键字段创建“是否缺失”的哑变量

# 为关键字段创建缺失值指示器

missing_indicators = ['age', 'income', 'email', 'phone_number', 'comments']

for col in missing_indicators:

df[f'{col}_is_missing'] = df[col].isnull().astype(int)

数据质量评分:计算每条记录的完整性并分类

# 数据质量评分

df['quality_score'] = df.notna().sum(axis=1) / len(df.columns) * 10

df['quality_category'] = pd.cut(

df['quality_score'],

bins=[0, 6, 8, 10],

labels=['Poor', 'Average', 'Good']

)

标记可疑值,如整数倍收入、年龄不合理等:

# 检测潜在的数据录入错误

df['income_suspiciously_round'] = (df['income'] % 10000 == 0).astype(int)

df['age_out_of_range'] = ((df['age'] < 18) | (df['age'] > 80)).astype(int)

df['rating_out_of_range'] = ((df['customer_rating'] < 1) | (df['customer_rating'] > 5)).astype(int)

不仅要清洗脏数据,还应从中提取有用的信息。

10. 处理格式问题

不一致的格式会导致数据分析或建模时出现问题。

日期格式统一:

# 统一日期格式

df['start_date_clean'] = pd.to_datetime(df['start_date'], errors='coerce')

df['start_date_formatted'] = df['start_date_clean'].dt.strftime('%Y-%m-%d')

去除货币符号,方便数值转换:

# 处理货币格式问题

def clean_currency(amount):

if pd.isna(amount):

return np.nan

if isinstance(amount, (int, float)):

return amount

amount_str = str(amount)

amount_str = re.sub(r'[$,]', '', amount_str)

try:

return float(amount_str)

except:

return np.nan

df['purchase_amount_clean'] = df['purchase_amount'].apply(clean_currency)

统一数值格式,便于展示或报表:

# 统一数值显示格式

df['income_formatted'] = df['income'].apply(

lambda x: f"${x:,.2f}" if not pd.isna(x) else ""

)

对于要展示给最终用户或用于报表的数据,一致的格式尤为重要。

应用了多种清洗方法后,我们就得到了可以分析的最终干净数据集。

总结

我们已经覆盖了实际数据集中最常见数据质量问题的实用应对技巧。这些方法能帮助你将问题数据转化为可靠的数据资产。

数据清洗是一个迭代过程——每一步都能让你对数据的结构和质量有新的认识。上述模式可根据你的具体需求调整,并融入自动化工作流之中。

祝你数据清洗愉快!