中国研究生数学建模竞赛(华为杯)学习笔记------数据预处理全流程
文章目录

原始数据体检
先把问题分清楚,再选择处理方式。

图中年份格式不对应,城市名称不统一,特殊符号缺失,数量及异常,单位混用,重复记录。
原始数据处理到可建模数据的基本顺序
- 读取数据
- 字段体检
- 识别缺失
- 统一格式
- 识别异常
- 处理缺失
- 删除重复(检查联合主键)
- 可视验证(前后对比)
- 导出留痕(数据+日志)

数据检查

python
# 读取 Excel 文件,保留原始数据副本
df_raw = pd.read_excel("城市年度数据.xlsx") # 读取原始数据,不做任何改动
df = df_raw.copy() # 复制一份作为工作副本,后续操作都在 df 上进行,保护原始数据
# ---------- 数据初步探查 ----------
print(df.shape) # 查看数据维度:(行数, 列数)
df.head() # 查看前 5 行数据,直观了解数据内容
df.info() # 查看每列的名称、非空数量、数据类型,以及内存占用
# 可发现哪些列存在缺失、哪些列类型需要转换(如数值被识别为 object)
df.describe(include="all") # 生成统计描述:
# 数值列 → 计数、均值、标准差、最值、四分位数
# 分类列 → 唯一值数、最高频值及其出现次数
# include="all" 表示同时包含数值列和分类列
df.isna().sum() # 统计每列的缺失值(NaN)数量,用于确定后续清洗策略
df.duplicated().sum() # 统计完全重复的行数,判断是否需要去重
缺失与异常处理
缺失值识别
统一转成NaN便于后续处理。特殊符号不处理,缺失率会算低;后面的中位数、插值也可能报错。

python
# ---------- 1. 缺失值符号规范化 ----------
# 数据中可能用各种符号表示"缺失",先统一收集起来
missing_symbols = ["--", "未知", "缺失", "NA", ""] # 常见的非标准缺失标记
# 将这些符号统一替换为标准的 NaN,便于后续 pandas 缺失值处理函数识别
df = df.replace(missing_symbols, np.nan)
# ---------- 2. 数值列类型转换 ----------
# 读取 Excel 时,因存在 "--" 等符号,数值列常被识别为 object(字符串)类型
# errors="coerce":无法转换为数值的内容(如残留的非数字字符串)→ 强制转为 NaN,而不是报错
for col in ["GDP", "人口", "能耗", "污染排放"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
# ---------- 3. 计算各列缺失率 ----------
# isna().mean():每列缺失值的比例(0~1),乘以 100 转为百分比
# 用于评估数据质量,决定后续是"填充"还是"删除"
missing_rate = df.isna().mean() * 100
缺失值处理
在数据清洗中,处理缺失值是最常见的任务之一。很多人的第一反应是用均值填补,但这种做法并不总是合理。是否选用均值,取决于数据本身的分布和结构:当数据分布近似对称时,均值确实能代表典型水平;但当数据存在偏态或极端值时,均值会被拉偏,此时用中位数更稳妥。此外还有两种更贴合业务场景的策略------当不同群体(比如不同城市)的量级差异明显时,应该分组填充,避免把一线城市的数值和中小城市混在一起平均;而当数据是连续的时间序列时,最合理的做法是线性插值,利用前后时间点的趋势来估算中间的缺失值。
总结成一句话:对称分布用均值,偏态分布用中位数,群体差异大用分组填充,时间序列用插值。

案例分析
假设我们有北京 GDP 的三年片段:2021 年为 38136 亿元,2022 年缺失,2023 年为 43761 亿元。如果此时用全表均值来填补 2022 年,显然不合理------因为缺的是时间序列中间的一年,它前后两年的数值就是最有信息量的参考。正确做法是取前后两年的平均值,即 (38136 + 43761) ÷ 2 = 40948.5 亿元。这个例子也说明:填补策略应当利用数据内在的先后关系,而不是简单地"抹平"所有缺失。
但这个案例里还藏着一个更隐蔽的坑:三行数据的城市名分别是"北京"、"北京市"、"Beijing"。如果不先统一名称,pandas 会把它们当成三个不同的城市,按城市分组插值就会失效------每个"城市"名下只剩一两个有效数据点,根本无从插值。所以正确的流程是先做城市名称归一化,再按年份排序、分组插值。
代码实现
python
# 第一步:统一名称(把 北京 / 北京市 / Beijing 合并为同一个城市)
# df['城市'] = df['城市'].replace({'北京市': '北京', 'Beijing': '北京'})
# 第二步:按城市和年份排序,保证每个城市内部年份递增
df = df.sort_values(['城市', '年份'])
# 第三步:按城市分组,组内对 GDP 做线性插值并写回原列
g = df.groupby('城市')['GDP']
df['GDP'] = g.transform(
lambda s: s.interpolate() # 用前后年份的值线性估算缺失年份
)
异常值识别
数据清洗中的另一大任务是异常值检测。面对一列数值,很多人靠"看谁不顺眼就删谁",这种做法既容易误删真实数据,也容易漏掉隐藏的错误。更可靠的做法是用统计方法划定一个"正常范围",把超出范围的记录先标记出来,再做人工判断。其中最常用的就是 IQR(四分位距)法。
它的原理很简单:把数据从小到大排序后,取第 25 百分位数(Q₁)和第 75 百分位数(Q₃),两者之差就是 IQR,代表数据中间 50% 的分布宽度。以这个宽度为基准,向两侧各扩展 1.5 倍,就得到了下界(Q₁ − 1.5 × IQR)和上界(Q₃ + 1.5 × IQR)。落在界外的记录,就是需要重点检查的候选异常值。这个方法的好处是稳健------上下界由四分位数决定,极端值本身几乎不影响它们的位置,不像均值和标准差那样会被异常值"带跑"。
代码实现
python
# 第一步:计算第 25 和第 75 百分位数
Q1 = df['人口'].quantile(0.25)
Q3 = df['人口'].quantile(0.75)
IQR = Q3 - Q1 # 四分位距:中间 50% 数据的跨度
# 第二步:划定正常范围的上下界
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
# 第三步:筛选出超出范围的记录(候选异常值)
outliers = df[(df['人口'] < lower) |
(df['人口'] > upper)]
在本案例的数据集(城市年度数据)中,运行上述代码后筛出了一条记录:城市为 Beijing,时间是 2022 年,人口为 219000------远高于其余城市。结合常识判断,这很可能是录入时多写了两个 0(真实值可能是 2190 左右的量级,具体单位要看数据说明)。这是一个典型的"假异常":数值上极端,但成因是录入错误,而非真实的城市人口突变。
异常值判断
检测只是第一步,真正的难题在于:拿到一条离群记录后,该拿它怎么办? 数据处理的常见误区就在这里:很多人看到数值离群,第一反应是直接删除,图个干净省事。这种做法的风险在于------如果那条"异常"其实是真实数据(比如超大城市的真实人口),删掉它就等于亲手扭曲了数据集;而如果它是录入错误却没被处理,它又会持续污染后续的统计结果。所以,异常值处理的核心原则是:先判断成因,再对症下药,绝不因为"离群"就删除。

异常值按成因大致可以分为三类,每一类对应不同的处理方式。
第一种是录入错误。 数据在采集或录入过程中出了差错。这类错误的正确处理方式是核对原始来源后修正;如果原始来源也无法确认正确值,就记为缺失值,交给后续的空值处理流程(均值、中位数或插值)去补。
第二种是真实极端值。 数值虽然离群,但它是真实发生的------比如一线城市的人口相对中小城市就是天然极端。这类数据必须保留,并在分析报告中说明保留原因。真实的极端值往往正是分析中最有信息量的部分,删掉它们等于丢掉了故事的主角。
第三种是无法确认的情况。 既找不到原始来源佐证是笔误,也判断不出它是否真实。对这类记录,稳妥的做法是先做标记(比如增加一列 is_outlier 标记),然后分别在有它和无它的两种数据集上跑分析,比较处理前后的结果。如果结果差异不大,说明它影响有限;如果差异显著,就需要回到业务层面进一步追查。
格式与重复
原始数据往往来自不同来源、由不同人录入,这就导致同一个信息有多种写法。比如年份可能是"2020"也可能是"2022/01";同一个城市可能写作"北京"、"北京市"或"Beijing";同一种能耗,有的用"吨"有的用"万吨";更麻烦的是,同一城市同一年可能出现了两条记录。如果这些问题不解决就急着建模,后果很严重:分组统计时同一个城市会被拆成三个实体,导致统计虚高;单位不统一会让数值比较毫无意义;重复记录会成倍放大某些城市的权重,扭曲相关分析和模型训练的结果。所以,名称、日期、单位、主键这四类字段,必须在建模前统一,这是任何后续分析可靠性的前提。
四类字段的规范化要点
第一是日期字段统一。 原始数据里,有时是完整的"2020年",有时是"2022/01"这种带月份的格式。建模和分组通常只需要年份,所以需要提取四位数年份,统一转成整数,才能作为时间维度的可靠主键。
第二是名称字段统一。 这是最容易出问题的一类。北京、北京市、Beijing 指的是同一个城市,但如果不映射,pandas 会当成三个不同的值。解决方法是建立一张名称映射表,把各种写法统一映射到标准名称,再对字段做替换。
第三是单位字段统一。 能耗这类指标,原始数据可能混合了"吨"和"万吨"两种单位。单位不统一,数值就没有可比性。正确做法是统一换算成标准单位(本例统一为"万吨"),让所有记录处于同一度量纲之下。
第四是主键去重。 城市年度数据里,每一行的唯一标识应该是"城市 + 年份"的组合。如果同一城市同一年出现了两条记录,就说明数据有重复,需要用联合主键去重,保证每个城市每年只有一行。
代码实现
python
# 1. 年份提取为四位整数(处理 "2020年" / "2022/01" 等格式)
df['年份'] = (df['年份'].astype(str)
.str.extract(r'(\d{4})')[0]
.astype(int))
# 2. 城市名称映射(把 北京/北京市/Beijing 统一为标准名称)
df['城市'] = df['城市'].replace(city_map)
# 3. 能耗统一为"万吨"(把"吨"换算成"万吨")
df['能耗'] = df['能耗'].apply(to_万吨)
# 4. 城市-年份应唯一,按联合主键去重
df = df.drop_duplicates(['城市', '年份'])
规范化完成后,需要回头验证是否达标。合格的数据应该满足四点:年份都是整数;城市只保留标准名称;能耗统一为"万吨";重复记录数为 0(本例中重复记录从 1 条降到了 0 条)。这些检查点可以直接用 .dtypes、.unique() 和 .duplicated().sum() 等命令快速核验。
AI辅助清洗实操
在数据清洗的实操环节,很多人和 AI 协作时习惯甩出一句"帮我把这张表清洗干净",然后期待一次到位。这种做法风险很大:一方面,AI 一次性改动过多会掩盖每一步的具体操作,出了问题难以回溯;另一方面,字段类型、缺失、重复、异常这四类问题互相牵制,顺序错了往往会"越洗越乱",比如还没统一城市名称就去做分组插值,结果同一城市被拆散,插值自然失效。更现实的是,如果让 AI 直接动手改数据,它可能在没摸清数据全貌的情况下就下了判断------比如把真实的极端值当成错误删掉。因此,正确地做法是把清洗拆成"诊断---清洗---说明"三个轮次,每轮只做一件事,并且明确约束 AI 的行为。


在空值处理环节,我们知道了"按城市分组、组内插值"是处理城市 GDP 时间序列缺失的正确思路。但这里隐藏着一个 AI 常犯的典型错误:它可能会直接写 df.groupby("城市")"GDP" 去做填补,却忽略了城市名称本身尚未统一。在这个数据集里,"北京""北京市""Beijing"其实是同一个城市,但在未归一化之前,pandas 会把它们当成三个完全不同的组。于是原本应该一条连续时间序列的北京,被拆成了三截,每一截都只剩零星的一两个数据点,前后年份凑不齐,插值也就无法生效------结果是分成了三个组,缺失值依然填不上。这个案例很好地说明了:AI 的思路大体正确,但它往往不会主动意识到"字段值是否已规范"这个前置条件。
要修正这个问题,需要把操作顺序调整成四步。第一步,映射城市名称------把"北京""北京市""Beijing"统一映射为标准名称,这是所有分组操作的前提。第二步,转换 GDP 为数值型------确保该列是数值而不是字符串,否则插值会报错或得到错误结果。第三步,按城市排序并插值------先按"城市 + 年份"排序,保证时间顺序正确,再分组做线性插值。第四步,输出填补前后的记录核验------把填补前后的数据打印出来对比,确认缺失值确实被合理填上,没有出现"仍未填上"或"填出明显不合理数值"的情况。
第1轮:只检查
请生成数据质量检查代码,不修改原始数据;输出字段类型、特殊缺失、重复和异常候选。
第2轮:分步清洗
先统一缺失符号与字段类型,再处理名称、单位、缺失和异常;每一步返回检查结果。
第3轮:生成说明
根据清洗日志生成论文描述;只能使用已提供的数字,不得编造处理数量。
代码流水线

真实的数据分析项目中,真正的考验在于这些步骤能不能被复现、能不能被追溯。如果只是把一堆命令代码堆在一起跑一遍,当时数据被改了哪些、删了多少条、填了什么值,事后很难说清楚------尤其当论文需要如实描述处理过程、或团队其他人需要复现你的结果时,这种"一次性代码"几乎没法交付。因此,规范的做法是把清洗整理成一条有明确阶段、可反复运行、自动记录过程的流水线。这样既能保证结果可复现,也能让每一步的操作有据可查。
先看整体的处理管线,它把清洗拆成六个边界清晰的环节。第一个环节 raw() 是源头,负责建立原始数据的只读副本,保证后续任何操作都不破坏最初的输入。第二个环节 audit() 做质量体检,相当于给数据拍照存档------记录字段类型、缺失、重复、异常等初始状况。第三个环节 standardize() 负责格式与单位统一,处理城市名映射、日期提取、单位换算这类"规范化"问题。第四个环节 clean() 处理缺失与异常,是真正动手修正数据的部分。第五个环节 validate() 做验证并生成日志,检查清洗结果是否达标。最后的 export() 把干净的数据导出为建模表。六个环节像流水线一样依次传输数据,每一步只关心自己的职责。
函数化结构
为了让这条流水线真正可运行、可维护,建议把每个阶段封装成独立函数,再由一个主函数按顺序调用。下面是一个推荐的结构:
python
def clean_city_data(df_raw):
df = df_raw.copy() # 1. 建立原始数据只读副本
log = [] # 2. 初始化日志,记录每一步操作
df = normalize_missing(df, log) # 3. 规范化缺失符号
df = standardize_fields(df, log) # 4. 统一格式与单位
df = clean_values(df, log) # 5. 处理缺失与异常值
df = drop_key_duplicates(df, log) # 6. 按主键去重
validate_clean_data(df) # 7. 验证清洗结果
return df, pd.DataFrame(log) # 8. 返回干净数据 + 清洗日志
这个结构有两个优点。第一,每个函数都接收 df 和 log------df 让数据在函数间依次传递,log 让每个函数把"我做了什么"追加到日志里,从而实现自动化的过程记录。第二,主函数最后同时返回 df 和 pd.DataFrame(log)------返回值不只是清洗后的数据,还包括一份结构化的清洗日志,这两者合起来才构成完整、可追溯的处理记录。
日志记录
日志每一步至少应该包含五项内容:处理前的记录数(基线,知道从多少行开始);处理规则与涉及字段(做了什么、针对哪些列);修改、填补或删除的数量(这是论文里最常引用的数字);处理后的复查结果(这一步是否生效、数据形态如何);以及异常无法确认时的标记(记录哪些记录存疑、为何未处理)。有了这五项,整个清洗过程就成了一本"账本",随时可以核对。

一份合格的日志应该包含步骤(操作序号)、发现的问题(针对什么)、处理方式(用了什么规则)、数量(处理了多少)、结果(处理后数据变成什么样)。
在第 X 步、针对 Y 问题、用了 Z 规则、处理了 N 条、结果变成了 M。
| 步骤 | 发现的问题 | 处理方式 | 数量 | 结果 |
|---|---|---|---|---|
| 1 | 特殊缺失符号 | 统一转为 NaN | 3个 | 进入后续处理 |
| 2 | 城市名称不统一 | 映射为标准名称 | 5条 | 3个标准城市 |
| 3 | GDP/能耗/污染缺失 | 分组插值或中位数 | 3个 | 缺失率降为 0 |
| 4 | 人口数量级异常 | 回查后修正 | 1条 | 219000→2184 |
| 5 | 城市---年份重复 | 联合主键去重 | 1条 | 16→15 条记录 |
验证与论文表达
可视化验证

清洗完成后,应把数据画成图做最后的可视化验收。以 GDP 序列为例:填补前,折线在 2020、2021 年正常上升,但 2022 年因数据缺失出现断裂,趋势被切断;用前后年份线性插值填补后,2020---2024 连成一条完整平滑的上升曲线,缺失点被补在了"应该在的位置"。看图时主要检查三点:插值后趋势是否自然、有没有产生新的跳变、清洗结果能否支持后续建模。这些图的作用是双重的------对内是质量检查的放大镜,一眼看出趋势断裂、量级异常等问题;对外则是论文里解释处理依据的直观证据,一张填补前后对比图往往比文字说明更有说服力。
论文表达

论文方法部分的写作要诀是:把问题、方法、理由和结果写具体,而不是一句"进行了适当处理"带过。示例写法:先交代原始规模(16 条记录),再依次说明特殊符号转缺失值、字段数值化、按城市分组结合相邻年份线性插值(并给出理由------不同城市经济量级不同)、异常值回查修正、名称与单位统一、按"城市---年份"联合主键删除 1 条重复记录,最后用结果收尾(保留 15 条、缺失率降为 0)。提交前按五项清单自查:字段名来自原表、数量与日志一致、单位已统一、异常值有依据、清洗后已验证------核心底线是所有数字和原因必须来自清洗日志,这样方法部分才经得起复现和追问。
论文写法示例:"原始数据共包含 16 条城市年度记录。首先将 --、未知 等特殊符号统一转换为缺失值,并对 GDP、人口、能耗和污染排放字段进行数值化处理。考虑到不同城市的经济量级存在差异,对 GDP 等少量缺失值按城市分组,并结合相邻年份进行线性插值;对人口字段中识别出的数量级异常值,回查原始记录后进行修正。随后统一年份、城市名称及能耗单位,并以'城市---年份'为联合主键删除 1 条重复记录。预处理后共保留 15 条有效记录,各建模字段缺失率降为 0,可用于后续相关分析与预测建模。"
学习内容
【数模国赛数据预处理全流程:从 "脏数据" 到可建模数据,缺失值 / 异常值 / AI 清洗一次讲透,助力国赛模型跑出好结果!】