标准的CSV文件确实简单,一行表头加若干行数据,Pandas的read_csv默认参数就能轻松读入。但实际工作中的文件往往没那么规矩:财务系统导出的报表顶部带三四行说明文字和生成时间,多级表头把大类和小类分行显示,甚至同一列的名称在逗号后面还附带单位。这类文件如果直接丢给read_csv,拿到的DataFrame表头会变成Unnamed之类的占位符,后续清洗非常痛苦。本文将正则表达式和Pandas结合起来,先解析表头结构,再精准读取数据,给出一套可复用的处理思路。

一、先搞清楚文件的真实结构
动手写代码之前,强烈建议先用文本方式打开文件看看原始内容。很多人拿到文件就急着调参数,结果反复试错浪费大量时间。用Python的open函数配合itertools.islice读取前十几行,就能快速判断表头占据几行、数据从第几行开始、分隔符是什么。
from itertools import islice
with open('report.csv', 'r', encoding='gbk') as f:
for i, line in enumerate(islice(f, 15)):
print(i, repr(line))注意这里用了repr而不是直接打印,因为repr会显示换行符和不可见字符,能帮你发现文件究竟是CRLF还是LF结尾、是否存在制表符混用的问题。编码方面,Windows下导出的中文CSV经常是GBK或GB18030,如果用默认的UTF-8打开会直接抛出UnicodeDecodeError。稳妥的做法是先用chardet库探测编码,或者用errors='replace'先粗略读取再判断。
观察原始行时重点记录三件事:数据行的起始行号、表头所在行、分隔符和引用符。这三个信息决定了后面调用read_csv时该传哪些参数。
二、用正则表达式动态定位数据起始行
如果文件格式固定,直接写死skiprows=3就完事了。但更多场景是批量处理几十上百个文件,每个文件的说明行数量还不一样,这时就需要用正则表达式自动识别数据从哪里开始。常见的特征是数据行以日期或数字开头,而说明行以中文或杂项文字开头。
import re
import pandas as pd
# 假设数据行的第一列是形如 2024-03-15 或 20240315 的日期
date_pattern = re.compile(r'^\d{4}[-/]?\d{2}[-/]?\d{2}[,;\t]')
def find_data_start(path, encoding='gbk'):
with open(path, 'r', encoding=encoding) as f:
for idx, line in enumerate(f):
if date_pattern.match(line.strip()):
return idx # 数据起始行号
raise ValueError('未找到数据起始行')
start = find_data_start('report.csv')
df = pd.read_csv('report.csv', skiprows=start, encoding='gbk')
print(df.head())这个方案的关键在于正则模式的设计。表头行如果是“日期,销售额,利润”这种,第一列文字是“日期”两个汉字,不会匹配日期正则;而真实数据行首列是具体日期值,能够命中。如果你的数据首列是纯数字编号,可以用r'^\d+[,;]'这样的模式,但要小心说明行里的序号也会误匹配,必要时结合第二列的模式一起判断,比如r'^\d+,\d+(\.\d+)?'要求第二列必须是数字。
还有一种做法是反向定位:找到表头行的位置后,数据起始行就是表头行号加一。比如通过搜索包含“合计”或“总计”等关键词之外的表头特征词来确定表头位置,代码逻辑更直观,适合表头特征明显的文件。
三、处理多级表头并展开成扁平列名
多级表头是最容易出错的情况。典型的报表结构是第一行是大类,第二行是小类,大类名称只写在每组的第一列,后面跟若干空单元格。Pandas虽然支持header=[0,1]读多级表头,但前提是结构完全规范,空单元格会让MultiIndex变成一堆Unnamed。
正确姿势是先用header=None把所有行当数据读进来,再手动处理前几行,用ffill把横向的空单元格向前填充,最后拼接两级名称:
import pandas as pd
raw = pd.read_csv('report.csv', header=None, skiprows=2, nrows=2, encoding='gbk')
# 横向填充大类名称,把空单元格补齐
level1 = raw.iloc[0].ffill()
level2 = raw.iloc[1]
# 拼接成扁平列名,例如 "销量/一月"、"销量/二月"
flat_columns = [f'{a}/{b}' for a, b in zip(level1, level2)]
df = pd.read_csv('report.csv', header=None, skiprows=4, encoding='gbk')
df.columns = flat_columns
print(df.head())这里第一次读取只取表头两行(nrows=2),第二次读取跳过表头取数据,两次读取虽然多一次IO,但逻辑清晰不易出错。如果文件很大,可以改用csv模块只读前几行,避免反复扫描整个文件。
列名拼接之后还可以做进一步清洗。比如单位信息经常混在列名里,“销售额(万元)”,用正则把括号内容提取出来存到单独的元数据字典,列名只保留纯文字,后续做数据聚合时不会因为列名不规则而出问题:
import re
unit_pattern = re.compile(r'[((](.+?)[))]')
def split_unit(col_name):
m = unit_pattern.search(col_name)
if m:
return unit_pattern.sub('', col_name).strip(), m.group(1)
return col_name, None
clean_cols = []
units = {}
for c in df.columns:
name, unit = split_unit(c)
clean_cols.append(name)
if unit:
units[name] = unit
df.columns = clean_cols
print('单位信息:', units)四、分块读取与容错处理
有些CSV文件尾部还带合计行或页脚说明,读进来的最后几行是字符串,会导致整列被推断成object类型。处理办法有两个:一是用skipfooter参数配合engine='python'跳过尾部行数;二是读入后用正则过滤掉非法行。后者更灵活,适合尾部行数不固定的场景。
# 过滤掉第一列不是数字或日期的数据行
mask = df.iloc[:, 0].astype(str).str.match(r'^\d{4}-\d{2}-\d{2}$')
df_clean = df[mask].copy()
# 过滤后再转换类型,避免合计行干扰推断
df_clean['销售额'] = pd.to_numeric(df_clean['销售额'], errors='coerce')
df_clean = df_clean.dropna(subset=['销售额'])对于上GB的大文件,建议用chunksize分块读取,在每块上应用同样的过滤逻辑,最后用pd.concat合并。需要注意的是pd.concat会复制数据,如果内存吃紧,可以先把每块过滤结果转成Parquet分片落盘再合并,效率会好很多。
最后提一点:正则表达式虽然强大,但复杂表头的解析逻辑最好封装成独立函数并配上单元测试。文件来源一旦变更格式,测试能第一时间暴露问题,比在生产环境里排查脏数据划算得多。把表头解析、数据读取、类型转换分成三步,每步职责单一,整套流程就能稳定应对各类不规则的CSV导出文件。