从数据清洗到特征工程:数学建模中的pandas实战指南
如果你参加过数学建模竞赛,或者做过任何涉及真实数据的课程设计,大概率体会过那种“模型还没开始写,数据先把人折腾疯”的阶段。几十万行表格、缺失值、重复记录、乱七八糟的日期格式、单位不统一……这时候pandas就是你手里最顺手的工具——没有之一。
玩数学建模这几年,pandas几乎贯穿了我每一道题的完整流程,从数据读取、清洗、变换,到特征提取、分组聚合、结果导出,它都能高效完成。可以这么说:只要题目给了数据文件,pandas就是第一道关卡。模型再花哨,数据预处理不到位,后面全白搭。
这篇东西写给两类人:一是刚接触数学建模、想用pandas但不知道怎么下手的同学;二是已经会用pandas但总觉得自己只停留在“照着教程敲代码”阶段的同学。我会把我实际踩过的坑、验证过好用的写法、以及那些文档里不会明说但极其关键的小细节,全都整理出来。
1. 建模之前,pandas为什么是绕不开的第一步
1.1 数学建模竞赛中的典型数据场景
数学建模题目里的数据,很少是干干净净给你的。我整理了几类最常见的“脏数据”形态:
第一类是表格型数据,比如人口统计、经济指标、销售记录,通常以CSV或Excel格式给出。这类数据的问题是:列名不统一、单位混用、存在大量缺失值。
第二类是从文本中抽取的结构化信息,比如社交媒体评论、问卷开放题、新闻报道。表面上看是文本,但建模时你得把它转成词频矩阵、情感分数、主题分布这类数值特征。
第三类是时间序列数据,比如天气记录、交通流量、股票价格。这类数据里,“时间”本身是一个维度,pandas的时间索引处理能力在这类场景中非常关键。
第四类是空间或网格数据,比如遥感影像的像素值、地形高程数据。这类数据通常规模很大,pandas负责读取、切片、统计,真正跑模型时再交给专门的库。
无论哪种场景,你都得先把数据装进一个统一的结构里,才能开始清洗和分析。pandas的DataFrame就是为这个场景设计的——它像一个“超级Excel表格”,但比Excel灵活太多。
1.2 为什么是pandas而不是Excel或纯Python
Excel当然也能做数据预处理,但遇到几十万行数据时卡顿得让人崩溃。更重要的是,Excel的操作很难记录和复现——你点了几百次鼠标,回头想复盘自己的清洗逻辑,基本只能靠记忆。
纯Python的话,用列表和字典也能存数据,但做列运算、分组统计、透视表这些操作时,代码会变得又臭又长。比如计算某个分组的中位数,用纯Python得写好几层循环,用pandas一行搞定。
pandas底层基于NumPy的数组结构,大部分操作是向量化的,也就是用C语言级别的方式批量处理数据,速度比逐行遍历快几个数量级。这在数学建模里很重要——你可能需要在短时间内对同一份数据做十几次不同角度的清洗和探索,如果每次等两分钟,心态就会崩。
所以我的结论很明确:Excel适合日常轻量使用,纯Python适合写底层逻辑,而pandas是这两者之间的最佳中间层——既灵活又高效,而且代码可记录、可复现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. pandas的两个核心数据结构:DataFrame和Series
2.1 Series:一维带标签数组,理解它的索引机制是关键
Series是pandas里最基本的数据结构,可以理解成一列带标签的数据。每个元素都有对应的索引标签,这比普通的Python列表多了一个维度。
我在实际使用中最常踩的坑是索引对齐问题。当你对两个Series做运算时,pandas不是按位置对齐,而是按索引标签对齐。看这个例子:
python复制import pandas as pd
s1 = pd.Series([1, 2, 3], index=['a', 'b', 'c'])
s2 = pd.Series([10, 20, 30], index=['c', 'b', 'a'])
result = s1 + s2
print(result)
输出结果:
code复制a 31
b 22
c 13
注意,s2的原始顺序是c、b、a,但相加时pandas自动按照a、b、c的标签进行对齐。这个特性大多数时候是便利的,但一旦两个Series的索引不完全重合,结果就会出现NaN值,这也是新手最容易困惑的地方。
2.2 DataFrame:二维表格结构,掌握三种索引方式
DataFrame是pandas最常用的数据结构,它是有行索引和列索引的二维表格。掌握了DataFrame,等于掌握了pandas的核心。
我总结了一套自己的索引口诀:位置用iloc,标签用loc,快捷方式用方括号。 这三者经常混用,但语义完全不同。
df.iloc[2, 3]:取第3行第4列(从0开始计数),纯粹按位置。df.loc[2, 'column_name']:取索引标签为2的那一行、列名为column_name的那个值。df['column_name']:取一整列,返回Series。df[['col1', 'col2']]:取多列,返回DataFrame。df[df['score'] > 80]:布尔索引筛选行。
在实际建模中,我最常用的是loc和布尔索引。比如筛选所有年龄大于30且收入不为空的样本,写成一行:
python复制subset = df.loc[(df['age'] > 30) & (df['income'].notna()), :]
注意这里必须用&而不是and,而且每个条件都要加括号,这是新手特别容易忽略的点。原因是pandas对数组做逻辑运算时,&是逐元素操作符,而and是Python关键字,无法作用于数组。
2.3 读取数据时的参数选择
数学建模题目给的数据文件,格式五花八门。我最常遇到的是CSV和Excel文件,偶尔也会碰到spss或sas格式的数据。
读取CSV文件时,有四个参数要格外留意:
python复制df = pd.read_csv(
'data.csv',
encoding='utf-8',
sep=',',
header=0,
parse_dates=['date_column']
)
encoding参数是中文数据最容易踩的坑。我遇到过好多次,CSV文件用Excel打开一切正常,但pandas一读就报UnicodeDecodeError,原因是文件实际是GBK编码,但pandas默认用utf-8去解码。解决方法是逐个尝试编码格式:
python复制# 先用with open快速侦查编码
with open('data.csv', 'rb') as f:
raw = f.read()
print(raw[:100]) # 根据内容猜测编码
# 或者直接用errors='ignore'忽略错误
df = pd.read_csv('data.csv', encoding='gbk', errors='ignore')
sep参数则要小心——有些CSV文件表面上是逗号分隔,实际混入了分号或制表符。如果读取出来的数据只有一列,多半是分隔符没设对。
读取Excel文件时,sheet_name参数可以指定工作表名称或索引。另外Excel表格里经常有合并单元格,读取后会出现大量NaN,这个后面专门说。
3. 数据清洗与预处理:建模前的常规操作
3.1 缺失值处理的完整决策链
数据中的缺失值是最普遍的问题,而处理方式直接影响模型质量。我总结的决策链如下:
第一步,统计缺失情况,判断缺失比例和分布。这一步能让你大概了解严重程度。
python复制# 统计每列缺失值数量
missing_count = df.isna().sum()
print(missing_count)
# 统计每列缺失比例
missing_ratio = df.isna().mean() * 100
print(missing_ratio)
第二步,根据缺失比例决定策略:
- 缺失比例低于5%:直接删除缺失行,或者用均值/中位数填充。
- 缺失比例5%-30%:优先用统计量填充,或者用前后值填充。
- 缺失比例超过30%:需要评估这一列是否还有建模价值,如果用处不大,直接删除整列。
第三步,选择填充方法,要结合业务场景:
python复制# 数值列:用均值或中位数填充
df['age'].fillna(df['age'].median(), inplace=True)
# 时间序列列:用前向填充
df['temperature'].fillna(method='ffill', inplace=True)
# 分类列:用众数填充
df['gender'].fillna(df['gender'].mode()[0], inplace=True)
这里我吃过一次亏:有一列数据分布极不均匀(长尾分布),用均值填充后,模型的预测结果明显偏向了中心值,后来换成中位数才有所改善。所以对于有离群值的数据,优先考虑中位数而非均值。
3.2 重复值处理
重复值分成两种:完全重复和部分重复。完全重复指所有列的值都相同,直接删除即可。部分重复指只有某些关键列相同,需要根据业务逻辑决定保留哪条记录。
python复制# 删除完全重复行
df.drop_duplicates(inplace=True)
# 按指定列去重,保留最后一条
df.drop_duplicates(subset=['user_id', 'timestamp'], keep='last', inplace=True)
这里的关键是理解subset和keep两个参数。subset指定按哪些列判断重复,keep决定保留哪一行。数学建模里常遇到的是用户行为记录,同一用户可能有多条重复记录,这时需要结合时间戳保留最后一条,或者结合其他字段做聚合处理。
3.3 数据类型转换与异常值识别
有时候数据读进来后,列的类型不符合预期。最常见的坑是把数值列读成了字符串列:
python复制# 查看所有列的数据类型
print(df.dtypes)
# 强制转换,遇到无法转换的变成NaN
df['price'] = pd.to_numeric(df['price'], errors='coerce')
这种问题常出现在价格、金额这类字段,因为原始数据里可能混入了“元”“万元”这样的单位文本。用errors='coerce'后,无法转换的值会变成NaN,然后再走一遍缺失值处理流程即可。
异常值识别方面,我常用两种方法。一种是基于统计学的IQR法:
python复制Q1 = df['score'].quantile(0.25)
Q3 = df['score'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
outliers = df[(df['score'] < lower_bound) | (df['score'] > upper_bound)]
print(outliers)
另一种是基于业务逻辑的直接判断,比如年龄大于120、负数的收入等,这些一眼就能看出是错误数据。
异常值处理要谨慎:数学建模题里有些异常值本身就是题目的考点,比如数据录入错误、设备测量异常等,考的就是考生有没有能力识别并处理。但也有题目故意把异常点藏在数据里,那个点恰恰是模型需要预测的关键结果。所以要不要删除异常值,我的原则是“先保留,后判断”——在探索性分析阶段不轻易删除,等明确了建模目标再做决定。
4. 分组、聚合与透视:从数据中挖出有效信息
4.1 groupby分组聚合的完整用法
groupby是pandas在数学建模中最实用的功能之一,它可以按一个或多个字段分组,然后对每个组执行聚合运算。
python复制# 按城市分组,统计每个城市的平均收入、最大年龄、样本数
result = df.groupby('city').agg({
'income': ['mean', 'median'],
'age': 'max',
'user_id': 'count'
})
我早期写这类代码时,总是分好几步操作——先groupby,再分别算均值、中位数,最后用merge拼接。后来发现直接用agg配合字典一次性搞定,代码可读性和效率都好得多。
多列分组的场景也很常见:
python复制# 按城市和年龄段分组
result = df.groupby(['city', 'age_group']).agg({'income': 'mean'}).reset_index()
要注意的是,groupby之后的分组键会变成索引,如果后续还要用这些列,必须加reset_index()把索引还原成普通列。
4.2 理解agg聚合函数里的常用统计量
agg函数支持非常丰富的聚合方式,除了sum、mean、median、max、min、count这些基础统计量,还有std、var、quantile、nunique等。
其中nunique是我特别推荐的一个——它统计每组中不同值的个数,在识别数据质量问题时非常好用。比如统计每个用户购买了几种不同的商品类型:
python复制purchase_types = df.groupby('user_id')['product_category'].nunique()
4.3 透视表与交叉表的应用
透视表是Excel里的常用功能,pandas也有对应的pivot_table实现。它本质上是用一个DataFrame生成一个新的汇总表格——行是一个维度,列是另一个维度,值是第三个维度的聚合。
python复制pivot = pd.pivot_table(
df,
values='income',
index='city',
columns='education_level',
aggfunc='mean',
fill_value=0
)
这个操作在建模中常用于生成特征矩阵。比如预测房屋价格时,可以用“地区”和“房型”两个维度生成价格均值表,然后把这个均值表作为一个新特征合并回原始数据。
还有一个crosstab函数,专门用来统计两个分类变量的频数分布:
python复制cross = pd.crosstab(df['gender'], df['purchase_status'])
这个在分析两个变量之间关系时特别直观,配合卡方检验可以初步判断变量是否相关。
5. 时间序列数据的处理技巧
5.1 时间格式统一与索引构建
数学建模经常碰到时间序列数据,而时间格式往往不统一——“2024/3/15”“2024年3月15日”“2024-03-15 14:30:00”这些格式可能同时出现在一份数据里。处理方法是统一转换成pandas的datetime类型:
python复制df['date'] = pd.to_datetime(df['date'], format='mixed', errors='coerce')
format='mixed'可以让pandas自动识别多种格式,但有的时候它会识别错,比如把“03/04/2024”当作4月3日而不是3月4日。这种情况建议手动指定dayfirst=True或者传具体的格式化字符串。
把时间列转成索引后,就可以按时间切片:
python复制df.set_index('date', inplace=True)
# 选取2024年3月的数据
march_data = df.loc['2024-03']
5.2 重采样与滑动窗口
时间序列建模中,resample和rolling两个方法特别常用。resample是按固定频率重新采样,比如把每天的销售数据聚合成每月的数据:
python复制monthly = df.resample('M').agg({'sales': 'sum', 'customers': 'mean'})
rolling是滑动窗口,适合计算移动平均线:
python复制df['sales_ma7'] = df['sales'].rolling(window=7, center=True).mean()
这里有个细节:rolling默认计算的是当前行及其之前window-1行的均值,如果想要对称的窗口,需要设置center=True。我在处理时间序列特征时,经常同时计算窗口均值、窗口最大值和窗口标准差,这些特征对预测模型非常有用。
5.3 时间滞后特征的生成
对于时间序列预测问题,“滞后特征”是核心技巧之一。用pandas生成滞后特征非常方便:
python复制df['lag_1'] = df['sales'].shift(1)
df['lag_7'] = df['sales'].shift(7)
shift是把值向后平移指定行数,lag_1表示前一天的销售额,lag_7表示一周前的销售额。这个操作在预测模型中能捕捉时间序列的自相关性。实际应用中,我通常生成多个滞后阶数,通过相关性分析筛选出最合适的几个。
6. 特征工程中的pandas高阶用法
6.1 用apply和lambda批量生成特征
特征工程里最常用的操作是把一个函数应用到每一行或每一列。apply函数可以实现这个需求:
python复制# 对每一行计算一个综合得分
df['total_score'] = df.apply(
lambda row: row['exam1'] * 0.3 + row['exam2'] * 0.3 + row['exam3'] * 0.4,
axis=1
)
axis=1表示按行操作,axis=0表示按列操作。这个参数是新手最容易混淆的,我的记忆方法是:axis=1是横着走,把整行数据传入函数;axis=0是竖着走,把整列数据传入函数。
6.2 用map和replace转换分类变量
分类变量在建模前通常需要转换为数值编码。最简单的方式是用map:
python复制gender_map = {'男': 1, '女': 0}
df['gender_code'] = df['gender'].map(gender_map)
如果分类变量有多个取值,可以用replace配合字典批量替换:
python复制df['education'] = df['education'].replace({
'小学': 1,
'初中': 2,
'高中': 3,
'本科': 4,
'硕士及以上': 5
})
这种方法保留了分类变量的大小关系,适用于有序分类变量。对于无序分类变量,更常用的做法是one-hot编码,pandas的get_dummies函数一行搞定:
python复制df = pd.get_dummies(df, columns=['city', 'product_type'], drop_first=True)
drop_first=True表示每个分类变量只生成k-1个虚拟变量,避免多重共线性问题。在线性回归模型中,这个参数尤为重要,不然模型会因变量之间存在完全相关关系而无法正常求解。
6.3 合并多个数据源的连接操作
数学建模题有时会同时提供多个数据文件,需要把它们合并成一张大表。merge函数类似于SQL里的JOIN:
python复制merged = pd.merge(
df_main, df_extra,
on='user_id',
how='left'
)
how参数有四种取值:inner表示只保留两边都匹配的行,left保留左表全部行,right保留右表全部行,outer保留全部行。我在实际中最常用的是left,因为通常主表是核心数据,其他表是补充信息。
还有一个容易被忽略的坑:如果两个表都有名为“score”的列,merge后会产生score_x和score_y两个列,这时候需要手动指定suffixes参数:
python复制merged = pd.merge(
df_main, df_extra,
on='user_id',
how='left',
suffixes=('_main', '_extra')
)
7. 典型建模场景:一个完整的pandas实战案例
7.1 场景描述与数据概览
我拿之前做过的一个销售预测模拟题来说明完整流程。题目给了一个电商平台的销售数据,包含约50000条订单记录,字段有日期、商品类别、销售额、客户城市、客户年龄、是否促销等。目标是根据历史数据预测未来一个月每天的销售额。
拿到数据后,第一步不是建模,而是先做一次快速探索性分析(EDA),目的是认识数据整体状况,发现潜在问题。
python复制import pandas as pd
df = pd.read_csv('sales_data.csv', encoding='utf-8')
print(df.shape)
print(df.head())
# 查看基本信息
df.info()
# 查看数值列的统计描述
df.describe()
df.info()会打印每列的数据类型和非空值数量,能快速发现哪里缺失严重、哪列类型不对。我拿到任何数据都先跑这一句。
7.2 清洗过程实录
检查发现两个问题:日期列是字符串格式,销售额有少量缺失,部分行有重复。
日期列转换:
python复制df['date'] = pd.to_datetime(df['date'])
df = df.sort_values('date')
销售额缺失的处理:由于缺失行占比不到2%,直接删除。
python复制df = df[df['sales'].notna()]
重复值处理:
python复制df.drop_duplicates(inplace=True)
7.3 特征工程与结果输出
处理完清洗后,开始构造时间序列特征:
python复制# 按日期聚合
daily = df.groupby('date').agg({'sales': 'sum'}).reset_index()
# 生成滞后特征
for lag in [1, 2, 3, 7]:
daily[f'sales_lag_{lag}'] = daily['sales'].shift(lag)
# 生成移动平均和标准差
daily['sales_ma7'] = daily['sales'].rolling(7).mean()
daily['sales_std7'] = daily['sales'].rolling(7).std()
# 添加星期几特征
daily['day_of_week'] = daily['date'].dt.dayofweek
# 去掉前7行没有完整滞后特征的数据
daily_clean = daily.dropna().reset_index(drop=True)
这个特征工程完成后,数据就变成适合机器学习模型的格式了:每一行是一个日期,特征是滞后值、移动平均、星期几,标签是当天的销售额。后续接上线性回归、随机森林或者LSTM,就是顺理成章的事情。
最终保存清洗后的数据:
python复制daily_clean.to_csv('sales_features.csv', index=False, encoding='utf-8-sig')
这里有个小细节:encoding='utf-8-sig'会在文件开头加上BOM标记,使得生成的CSV用Excel打开时中文不乱码。数学建模论文附件的常见要求之一,就是把处理后的数据保存成规范格式,这个细节能省不少麻烦。
8. 常见报错与排查经验实录
8.1 高频报错速查表
我整理了这几年使用pandas时最常遇见的报错,以及对应的解决方案:
| 报错信息 | 出现原因 | 解决方法 |
|---|---|---|
KeyError |
列名不存在或拼写错误 | 用df.columns查看所有列名,检查空格大小写 |
SettingWithCopyWarning |
在切片后的副本上修改数据 | 用.loc直接修改,或用.copy()显式创建副本 |
ValueError: Length of values (1) does not match length of index |
赋值时长度不匹配 | 检查是否不小心把一个标量赋给了多行数据 |
TypeError: unsupported operand type(s) |
数据类型混用,字符串和数值参与运算 | 用pd.to_numeric或astype转换类型 |
UnicodeDecodeError |
文件编码与读取参数不匹配 | 换成gbk或utf-8编码,或先探测文件编码 |
MemoryError |
数据量太大,内存不足 | 分块读取、只读必要列,或使用dtype参数优化存储 |
8.2 深坑:SettingWithCopyWarning的根源
这个警告几乎每个pandas使用者都会遇到,而且很多人直接忽略它,但实际上它是一个隐患——它意味着你的代码可能没有修改到原始数据,而修改了一个临时副本。
问题通常出在这样的写法:
python复制df_filtered = df[df['sales'] > 100]
df_filtered['new_column'] = 1 # 这里会触发警告
原因是df_filtered是df的视图,pandas无法确定你到底是想修改原始数据还是副本。最稳妥的解决方法是:在切片后显式复制,或者直接使用.loc操作原始数据。
python复制# 方法1:显式复制
df_filtered = df[df['sales'] > 100].copy()
df_filtered['new_column'] = 1
# 方法2:直接用loc赋值到原表
df.loc[df['sales'] > 100, 'new_column'] = 1
这两种方法都不会触发警告,而且语义更清晰。我个人的习惯是:如果只是临时查看和分析,不需要保留修改结果,用第一种;如果需要把修改结果保存回原表,用第二种。
8.3 内存优化技巧
当数据量达到几百万行时,pandas的默认操作可能会把内存塞得满满当当。此时可以通过指定dtype来大幅减小内存占用。
python复制df = pd.read_csv(
'big_data.csv',
dtype={
'user_id': 'int32',
'category_code': 'int8',
'value': 'float32'
}
)
如果数据里有大量中文字符串列,可以先把它们转为category类型:
python复制df['city'] = df['city'].astype('category')
这样做的原理是:category类型只用整数存储类别,字符串只保留一份副本,内存占用能减少数倍。在数据量大的时候,这个优化立竿见影。
9. 我在pandas使用中的几个深刻教训与心得
9.1 inplace参数:用还是不用
早期写代码时我喜欢到处加inplace=True,觉得代码简洁。后来发现这个习惯害人不浅——如果我需要保留原始数据做对比,或者要回滚清洗操作,inplace会直接覆盖掉原始数据,想后悔都没机会。
现在我的习惯是:默认不用inplace,而是用正常赋值。如果确实想覆盖原变量,也会先做好备份再操作。
python复制# 不推荐
df.dropna(inplace=True)
# 推荐
df = df.dropna()
df_backup = df.copy() # 备份
9.2 链式操作的可读性与调试难度
pandas支持链式调用,一段代码可以连续处理多个步骤:
python复制result = (
df
.query("sales > 100")
.groupby(['city', 'date'])
.agg({'sales': 'sum'})
.reset_index()
.sort_values('sales', ascending=False)
)
这样写确实很流畅,但调试的时候很不方便——中间的某个环节出问题时,你很难定位到底是哪一步出了问题。我的建议是:探索性分析阶段可以多用链式操作,快速尝试各种聚合方式;但正式提交或复现时,把关键步骤拆开写,每步加注释,这样回头检查时我们能清楚地知道整个流程的脉络。
9.3 建模时处理数据的一个原则:先验证再信任
最后分享一个我觉得最重要、也最想让大家记住的心得:对任何数据处理操作的结果,先做验证,再信任它。 我的验证方法很简单:在清洗前记录数据的行数和关键列的统计量,清洗后再做对比。
python复制# 清洗前
before_stats = {
'rows': len(df),
'sales_sum': df['sales'].sum(),
'unique_users': df['user_id'].nunique()
}
# 高强度清洗后
after_stats = {
'rows': len(df),
'sales_sum': df['sales'].sum(),
'unique_users': df['user_id'].nunique()
}
print('清洗前:', before_stats)
print('清洗后:', after_stats)
这个方法可以立即发现哪些操作改变了数据的基本结构。比如如果去重后销售总额变化很大,那说明重复数据可能包含有效信息,不应该简单删除——这种洞察往往能直接影响最终的建模效果。
还有一个建议:写数据处理代码时,每做完一个操作就用.head()或.info()快速检查一次。虽然稍微麻烦点,但能避免在错误的数据上一路狂奔到最后才发现问题。
pandas这个工具,说难也不难,核心就是DataFrame的索引、过滤、分组、合并这几个操作,以及处理缺失值和重复值的基本套路。但是想把数据整得又快又准,还需要大量的实际练习和踩坑。每次比赛或项目结束,把用过的数据处理流程整理成自己的一套代码模板,下次遇到类似的数据就能直接复用。这样积累下来,你会发现pandas不仅是建模前的一个准备步骤,更是整个建模流程中帮你建立对数据信心的关键环节。
