PDF表格数据清洗避坑指南:如何用pandas精准提取‘建工‘类企业信息
PDF表格数据清洗避坑指南:如何用pandas精准提取‘建工’类企业信息
最近在帮一个做行业研究的朋友处理一批建筑企业的资质PDF文件,他遇到的麻烦挺典型的:几百份PDF,里面的表格格式五花八门,有的表头跨了好几列,有的单元格合并了又拆分,直接用常规的pd.read_csv或者read_excel的思路去处理,结果要么是数据错位,要么是明明肉眼可见的“建工”两个字,用代码就是筛选不出来。折腾了两天,他跑来问我有没有什么“一步到位”的魔法。说实话,没有魔法,但有系统性的避坑思路。PDF表格的数据清洗,尤其是面对工程、建筑这类行业名录,核心不是学会某个库的某个函数,而是建立起一套应对“脏数据”的防御性编程思维。今天,我们就以提取“建工”类企业信息为具体目标,深入聊聊用pandas处理PDF表格时,那些容易踩坑的细节和精准提取的实战技巧。
1. 理解PDF表格的“混乱本质”:为何数据会错位
在动手写代码之前,我们必须先理解对手。PDF(Portable Document Format)的设计初衷是保证视觉呈现的一致性,而非数据结构化。这意味着,你在屏幕上看到的一个规整表格,在PDF内部可能只是一系列没有逻辑关联的文本块和线条坐标。当我们用pdfplumber、tabula-py或camelot这类库去“提取”表格时,库实际上是在做一项复杂的模式识别工作:根据文本的对齐方式、相对位置和绘制线条来猜测哪些内容属于同一个单元格、同一行、同一列。
这个过程极易受到以下因素的干扰,导致提取出的DataFrame“脏乱差”:
- 合并单元格:这是最常见的混乱之源。提取后,合并区域可能只在第一个单元格有值,后续单元格为
None或空字符串,破坏了数据的规整结构。 - 隐形表头:表格可能有多层表头(例如,第一行是大类“企业信息”,第二行才是“企业名称”、“注册地址”等),库可能无法正确识别,将表头行当作数据行处理。
- 单元格内换行:一个单元格内的文本包含换行符(
\n),提取后可能变成一个列表或带有\n的字符串,影响后续的字符串匹配。 - 缺失边框:表格没有完整的边框线,库可能无法准确界定表格边界,导致文本内容被错误分组或遗漏。
- 页码与页眉/页脚干扰:页眉、页脚或页码数字如果位置靠近表格区域,可能被错误识别为表格的一部分。
注意:没有一种提取工具是完美的。
pdfplumber在识别由线条构成的表格方面精度很高;tabula-py(基于Tabula Java)擅长提取简单的表格数据;camelot则提供了“格子”和“流”两种解析模式应对不同情况。通常,对于复杂的工程类PDF,我会首选pdfplumber进行初步探索和诊断。
理解这些,你就会明白,直接从PDF读到完美DataFrame是小概率事件。我们的工作流必须是:提取 -> 诊断 -> 清洗 -> 验证。
2. 从PDF到DataFrame:稳健的提取与初步诊断
让我们从一个假设的、包含典型问题的PDF文件开始。假设文件construction_companies.pdf里有一个企业名录表,结构有些混乱。
import pdfplumber
import pandas as pd
from pathlib import Path
def extract_table_with_diagnostics(pdf_path, page_num=0):
"""
提取PDF表格并输出诊断信息,帮助我们理解原始数据结构。
"""
with pdfplumber.open(pdf_path) as pdf:
page = pdf.pages[page_num]
# 1. 首先,可视化表格边界(调试用)
# im = page.to_image()
# im.debug_tablefinder().save("debug_table.png") # 可保存图片查看识别效果
# 2. 提取所有表格
tables = page.extract_tables()
print(f"本页共识别到 {len(tables)} 个表格。")
for idx, table in enumerate(tables):
print(f"\n--- 表格 {idx+1} 的原始数据 (前3行) ---")
# 打印原始结构,观察合并单元格、空值情况
for i, row in enumerate(table[:3]):
print(f"行{i}: {row}")
print(f"表格 {idx+1} 的总行数: {len(table)}, 列数: {len(table[0]) if table else 0}")
# 假设我们只处理第一个表格
if tables:
# 将第一个表格转为DataFrame,注意此时表头可能也是数据的一部分
raw_df = pd.DataFrame(tables[0])
print(f"\n--- 转换后的原始DataFrame (前5行) ---")
print(raw_df.head())
return raw_df
else:
print("未识别到表格。")
return pd.DataFrame()
# 使用示例
pdf_path = Path("./data/construction_companies.pdf")
raw_df = extract_table_with_diagnostics(pdf_path)
运行这段诊断代码,你可能会看到类似下面的输出:
本页共识别到 1 个表格。
--- 表格 1 的原始数据 (前3行) ---
行0: ['序号', '企业名称', None, '注册地', '资质等级']
行1: [1, '省第一建工集团\n有限公司', None, 'A市', '特级']
行2: [2, None, '市建工工程公司', 'B市', '一级']
--- 转换后的原始DataFrame (前5行) ---
0 1 2 3 4
0 序号 企业名称 None 注册地 资质等级
1 1 省第一建工集团\n有限公司 None A市 特级
2 2 None 市建工工程公司 B市 一级
诊断结果分析:
- 合并单元格问题:表头行(行0)中,“企业名称”可能对应原始表格中一个跨两列的合并单元格,导致第2列为
None。在数据行中,第2行(索引2)的第1列是None,而“市建工工程公司”跑到了第2列,这极有可能是上一行“省第一建工集团”的合并单元格延续下来的错位。 - 换行符:企业名称“省第一建工集团\n有限公司”中包含换行符。
- 表头识别:库没有自动将第一行识别为表头,而是当作普通数据行处理了。
基于这个诊断,我们的清洗策略就需要针对性调整。
3. 深度清洗:处理合并单元格、错位与文本规范化
清洗的目标是得到一个结构清晰、每一列含义明确的DataFrame。我们针对上述诊断问题,编写清洗函数。
def clean_extracted_table(raw_df):
"""
清洗从pdfplumber提取的原始DataFrame。
"""
df = raw_df.copy()
# 情况1:手动指定表头(如果诊断发现第一行是表头)
# 假设我们确定原始DataFrame的第0行是表头
new_header = df.iloc[0] # 获取第一行作为表头
df = df[1:] # 取第一行之后的数据为数据体
df.columns = new_header # 设置新的列名
df = df.reset_index(drop=True)
print("--- 重置表头后的DataFrame ---")
print(df.head())
# 情况2:处理因合并单元格导致的列错位和空值
# 常见模式:某一列大量为空(None),而它的右侧列有值,这些值本应属于它。
# 例如,‘企业名称’列(列1)的某些行为空,而‘None’列(列2)有值。
# 我们需要将列2的值向前填充到列1的空位中。
if '企业名称' in df.columns:
# 首先,处理单元格内的换行符,将其替换为空格
df['企业名称'] = df['企业名称'].str.replace('\n', ' ', regex=False)
# 假设原始列结构是:[序号, 企业名称, None, 注册地, 资质等级]
# 在重置表头后,‘None’可能变成了一个奇怪的列名,或者我们通过位置判断。
# 更稳健的方法是:找到‘企业名称’列为空,但后续某列有值的行,进行填充。
# 这里我们假设错位的数据在紧邻的下一列(根据诊断结果)。
# 我们需要找到‘企业名称’为空的行的索引
mask_name_null = df['企业名称'].isna()
# 假设正确的名称在下一列(列名可能需要调整,这里用‘temp’示意)
# 在实际中,你需要根据诊断输出确定错位列的索引或名称。
# 例如,如果错位列在重置表头后叫‘Unnamed: 2',则:
if 'Unnamed: 2' in df.columns:
df.loc[mask_name_null, '企业名称'] = df.loc[mask_name_null, 'Unnamed: 2']
# 填充后,可以删除这个临时列
df = df.drop(columns=['Unnamed: 2'])
# 另一种情况,错位可能发生在同一列内,是上下行的延续,这时需要用向前填充(ffill)
# df['企业名称'] = df['企业名称'].ffill()
# 情况3:删除清洗后仍然完全为空的行或列
df = df.dropna(how='all').dropna(axis=1, how='all')
# 情况4:重置索引(可选,但通常是个好习惯)
df = df.reset_index(drop=True)
print("\n--- 清洗完成后的DataFrame ---")
print(df.head())
print(f"\n清洗后DataFrame形状: {df.shape}")
return df
# 应用清洗函数
cleaned_df = clean_extracted_table(raw_df)
经过清洗,cleaned_df应该看起来规整多了,企业名称列中的错位值已被纠正,换行符也被替换。
4. 精准筛选:解决“明明有数据却筛选不到”的核心难题
现在来到最关键的环节:从企业名称或注册单位列中筛选出包含“建工”的企业。这里藏着几个大坑。
坑1:大小写与空格。数据可能是“省建工集团”、“市建工公司”、“XX建工 工程局”,中间可能有空格,也可能没有。
坑2:NaN值。如果待筛选的列存在缺失值(NaN),直接调用.str.contains()会抛出ValueError。
坑3:部分匹配与模糊匹配。我们可能想找所有名称中带“建工”的企业,但也要小心误伤,比如“建议工程咨询公司”也包含“建工”二字。
下面是一个健壮的筛选函数:
def robust_filter_companies(df, column_name='企业名称', keyword='建工'):
"""
健壮地从指定列中筛选包含关键词的行。
"""
if column_name not in df.columns:
raise KeyError(f"DataFrame中不存在列名: '{column_name}'")
# 确保该列是字符串类型,非字符串或NaN会被转换为空字符串(后续处理)
s = df[column_name].astype(str)
# 关键参数:na=False
# 当遇到NaN时,astype(str)会将其转为字符串'nan',这可能导致意外匹配。
# 更安全的做法是:在转换前标记NaN,或者在contains里设置na=False。
# 但astype(str)后,na=False可能无效。因此,我们选择先填充NaN。
# 创建一个掩码,标记原始为NaN的位置
original_nan_mask = df[column_name].isna()
# 将原始NaN在字符串序列中替换为一个不可能出现的占位符,如‘__MISSING__’
s_filled = s.where(~original_nan_mask, '__MISSING__')
# 进行筛选:忽略大小写,并处理可能的空格
# 将关键词中的字符也做空格处理,例如“建工”匹配“建 工”
keyword_pattern = keyword.replace('', r'\s*') if len(keyword) > 1 else keyword
# 构建正则表达式,忽略大小写
pattern = re.compile(rf'{keyword_pattern}', re.IGNORECASE)
filter_mask = s_filled.str.contains(pattern, na=False) # na=False确保‘__MISSING__’不被当作NaN而报错
# 从结果中排除掉我们标记的缺失值行
filter_mask = filter_mask & (~original_nan_mask)
filtered_df = df[filter_mask].copy()
# 重置索引
filtered_df = filtered_df.reset_index(drop=True)
print(f"原始数据行数: {len(df)}")
print(f"筛选出包含关键词‘{keyword}’的行数: {len(filtered_df)}")
if not filtered_df.empty:
print("\n筛选结果预览:")
print(filtered_df[[column_name]]) # 只打印关键列预览
else:
print("未找到匹配项。建议检查:1. 关键词是否正确;2. 数据清洗是否彻底(如错位未纠正);3. 字符串中是否有特殊空格或字符。")
return filtered_df
import re # 需要导入re模块用于正则表达式
# 使用示例
keyword_to_filter = '建工'
result_df = robust_filter_companies(cleaned_df, column_name='企业名称', keyword=keyword_to_filter)
代码要点解析:
astype(str)与na=False的配合:直接对可能包含NaN的列使用.str.contains()会报错。我们先astype(str)将其全转为字符串(NaN变成'nan'),但这会引入新问题(‘nan’可能被匹配)。更精细的做法是先用isna()标记原始NaN,在字符串序列中将其替换为一个唯一占位符,然后在contains中使用na=False,最后在结果掩码中排除这些标记行。- 正则表达式模糊匹配:
rf'{keyword_pattern}'中的r表示原始字符串,f用于格式化。我们通过keyword.replace('', r'\s*')构造了一个允许在关键词字符间插入任意空格(\s*)的模式,这能匹配“建工”、“建 工”、“建 工”等多种情况。re.IGNORECASE实现不区分大小写。 - 防御性编程:检查列名是否存在,打印清晰的日志,便于调试。
为了更直观地展示不同筛选策略的差异,我们可以用一个简单的对比表格:
| 筛选方法 | 代码示例 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 精确匹配 | df[df[‘col’] == ‘省建工集团’] | 结果绝对准确 | 无法匹配变体,不灵活 | 已知完整、标准的名称 |
| 简单子串 | df[df[‘col’].str.contains(‘建工’, na=False)] | 简单直接 | 对NaN处理不当会报错;无法忽略大小写和空格 | 数据干净,格式统一 |
| 正则表达式(基础) | df[df[‘col’].str.contains(r’建工’, case=False, na=False)] | 可忽略大小写 | 仍无法处理“建 工”这类空格 | 大小写不敏感,但无多余空格的场景 |
| 正则表达式(高级) | pattern = re.compile(r’建\s*工’, re.I) df[df[‘col’].str.contains(pattern, na=False)] | 强大灵活,可定义复杂模式 | 需要正则知识,可能匹配过度(如“建议工程”) | 应对真实脏数据的主流选择 |
| 模糊匹配 | 使用fuzzywuzzy库计算相似度 | 能处理错别字、简称 | 计算开销大,需设定阈值,可能误匹配 | 名称不规范,有拼写错误 |
在实际项目中,我通常从高级正则表达式方法开始,如果发现仍有遗漏(例如,数据中存在“省一建”这样的简称),才会考虑引入模糊匹配作为补充。
5. 实战整合与异常处理流程
将以上所有步骤整合成一个完整的、可复用的流水线,并加入更全面的异常处理。
def extract_and_filter_companies(pdf_path, target_keyword='建工', output_excel_path=None):
"""
端到端的PDF表格提取、清洗、筛选流水线。
"""
all_filtered_dfs = []
try:
with pdfplumber.open(pdf_path) as pdf:
total_pages = len(pdf.pages)
print(f"开始处理PDF: {pdf_path}, 共{total_pages}页。")
for page_num, page in enumerate(pdf.pages):
print(f"\n正在处理第 {page_num + 1} 页...")
tables = page.extract_tables()
if not tables:
print(f" 第 {page_num + 1} 页未识别到表格,跳过。")
continue
for table_idx, table in enumerate(tables):
if not table or len(table) < 2: # 至少要有表头和数据行
continue
# 转换为原始DataFrame
raw_df = pd.DataFrame(table)
# **关键:动态判断表头**
# 启发式规则:如果第一行大部分单元格是字符串且看起来像列名(如‘序号’、‘名称’),则作为表头
first_row = raw_df.iloc[0]
# 简单判断:第一行非空值比例高,且不含纯数字(可能不是完美规则,可根据数据调整)
non_null_count = first_row.notna().sum()
# 这里只是一个示例,实际中可能需要更复杂的逻辑,甚至允许用户指定表头行数。
if non_null_count > len(first_row) / 2:
header_row = 0
data_start_row = 1
else:
# 如果第一行不像表头,则尝试将前两行合并?或者默认无表头。
# 这里我们保守处理,假设没有表头,使用通用列名
header_row = None
data_start_row = 0
raw_df.columns = [f'Col_{i}' for i in range(raw_df.shape[1])]
if header_row is not None:
df_for_clean = raw_df.iloc[data_start_row:].copy()
df_for_clean.columns = raw_df.iloc[header_row]
else:
df_for_clean = raw_df.copy()
# 基础清洗:去除完全空的行列,重置索引
df_cleaned = df_for_clean.dropna(how='all').dropna(axis=1, how='all').reset_index(drop=True)
# **关键:智能识别目标列**
# 不是所有PDF的列名都叫‘企业名称’或‘注册单位’。尝试通过关键词模糊查找列。
target_column = None
possible_column_names = ['企业名称', '注册单位', '单位名称', '公司名称', '名称']
for col in df_cleaned.columns:
col_str = str(col).lower().replace(' ', '').replace('\n', '')
for possible in possible_column_names:
if possible in col_str:
target_column = col
break
if target_column:
break
if not target_column:
print(f" 表格 {table_idx+1} 中未找到合适的目标列,跳过筛选。列名有: {list(df_cleaned.columns)}")
# 可以选择将整个表格保存下来供手动检查
continue
# 应用健壮的筛选函数
filtered_df = robust_filter_companies(df_cleaned, column_name=target_column, keyword=target_keyword)
if not filtered_df.empty:
# 添加来源信息,便于追溯
filtered_df['_source_page'] = page_num + 1
filtered_df['_source_table'] = table_idx + 1
all_filtered_dfs.append(filtered_df)
# 合并所有结果
if all_filtered_dfs:
final_result_df = pd.concat(all_filtered_dfs, ignore_index=True)
print(f"\n✅ 处理完成!共从 {len(all_filtered_dfs)} 个表格中筛选出 {len(final_result_df)} 条记录。")
if output_excel_path:
final_result_df.to_excel(output_excel_path, index=False)
print(f"结果已保存至: {output_excel_path}")
return final_result_df
else:
print("\n⚠️ 处理完成,但未筛选到任何匹配的记录。")
return pd.DataFrame()
except FileNotFoundError:
print(f"错误:未找到文件 {pdf_path}")
return pd.DataFrame()
except Exception as e:
print(f"处理过程中发生未知错误: {e}")
# 可以考虑在这里记录日志
return pd.DataFrame()
# 运行完整流程
final_df = extract_and_filter_companies(
pdf_path='./data/construction_companies.pdf',
target_keyword='建工',
output_excel_path='./output/filtered_companies.xlsx'
)
这个整合函数增加了动态表头判断和智能列名识别的逻辑,使得脚本对表格格式的适应性更强。异常处理块保证了程序在遇到问题文件时不会完全崩溃,而是给出友好提示。
处理这类数据清洗任务,最花时间的往往不是编写核心筛选代码,而是前期的数据诊断和针对性的清洗逻辑调整。没有一份脏数据是完全相同的,但掌握了诊断方法(查看原始提取结果)、清洗工具箱(处理合并单元格、空值、错位)和健壮的筛选技巧(正则表达式、na=False),你就能快速定位问题并构建出可靠的提取流水线。下次当你面对一堆混乱的PDF表格时,不妨先别急着写contains(‘建工’),而是把原始数据打印出来仔细端详几分钟,你会发现,很多问题其实就藏在那些None和错位的列里。
更多推荐
所有评论(0)