4. 环保数据表结构识别与批量合并

环保监测数据在汇总阶段最常见的困境,不是缺少数据,而是“数据长得不一样”。同一个空气质量自动站,运维单位A导出的CSV第一行是标题“XX市2024年1月日报”,第二行才是字段名;运维单位B的Excel前两行是合并单元格的说明,第三行才是表头;而省平台导出的文件干脆没有表头,列顺序固定但列名缺失。再加上“PM2.5”“pm25”“细颗粒物(μg/m³)”这类同义不同名的列,以及mg/m³与μg/m³混用,直接纵向拼接必然错列、错单位。本节要解决的就是:让脚本先“看懂”每张表的结构,再按统一规则合并成一张宽表。

一、表头行识别:先定位真正的字段行

表头行识别的本质是“在一张表的前若干行中,找出最像字段名的那一行”。工程上不必上复杂模型,用三条启发式规则组合即可覆盖绝大多数环保数据文件:

  • 非空率:字段行通常整行非空,而标题行往往只有第一个单元格有值,其余为空。
  • 唯一性:字段名在同一行内不应重复,重复往往意味着这是数据行或合并单元格残留。
  • 类型倾向:字段行中文本型单元格占比高,而数据行中数值型占比高。可对前10行逐行统计“能转成数字的单元格比例”,取该比例首次明显下降的行作为表头候选。

下面是一段可直接复用的表头探测函数,返回表头所在行号(0基)与列名列表:

import pandas as pd, re

def detect_header(path, max_scan=10):
    raw = pd.read_csv(path, header=None, nrows=max_scan, dtype=str)
    best_row, best_score = 0, -1
    for i in range(len(raw)):
        row = raw.iloc[i].dropna().astype(str).str.strip()
        row = row[row != ""]
        if len(row) == 0:
            continue
        non_empty = len(row) / raw.shape[1]          # 非空率
        unique = row.nunique() / len(row)            # 唯一性
        numeric_ratio = row.str.match(r"^-?\d+(\.\d+)?$").mean()
        score = non_empty * 0.5 + unique * 0.3 + (1 - numeric_ratio) * 0.2
        if score > best_score:
            best_row, best_score = i, score
    return best_row

# 示例:某文件探测结果为第2行(0基),即第3行是表头
# 读取时跳过前2行,并把该行作为列名
df = pd.read_csv("station_a_202401.csv", header=detect_header("station_a_202401.csv"))

对Excel文件,把 read_csv 换成 read_excel(..., header=None) 即可,逻辑完全一致。需要注意:合并单元格在pandas中会表现为“首格有值、其余为NaN”,因此非空率规则对这类文件尤其有效。若探测结果与人工判断不符,应把该文件加入“人工复核清单”,而不是强行套用。

二、列名映射:把同义列收敛到标准字段

识别出表头后,下一步是把各文件的列名映射到一套标准字段字典。环保数据建议先定义标准列,例如:站点编号、监测时间、PM2.5、PM10、SO2、NO2、CO、O3、温度、湿度,并明确每个字段的单位。映射采用“别名表 + 规范化”两级策略:先做字符串规范化(去空格、去括号内容、转小写、全角转半角),再查别名表。

import unicodedata

ALIAS = {
    "pm2.5": "PM2.5", "pm25": "PM2.5", "细颗粒物": "PM2.5",
    "pm10": "PM10", "可吸入颗粒物": "PM10",
    "so2": "SO2", "二氧化硫": "SO2",
    "no2": "NO2", "二氧化氮": "NO2",
    "co": "CO", "一氧化碳": "CO",
    "o3": "O3", "臭氧": "O3",
    "站点编号": "站点编号", "站点": "站点编号", "site_id": "站点编号",
    "监测时间": "监测时间", "时间": "监测时间", "datetime": "监测时间",
}

def norm(name):
    s = unicodedata.normalize("NFKC", str(name)).strip().lower()
    s = re.sub(r"[((].*?[))]", "", s)   # 去掉单位括号
    s = re.sub(r"\s+", "", s)
    return s

def map_columns(cols):
    out, unmapped = {}, []
    for c in cols:
        key = norm(c)
        if key in ALIAS:
            out[c] = ALIAS[key]
        else:
            unmapped.append(c)
    return out, unmapped

映射完成后务必打印 unmapped 列表。未映射列通常有两类:一类是确实无关的辅助列(如“备注”“录入人”),可直接丢弃;另一类是别名表没覆盖到的新写法,应补充进 ALIAS 后重跑。这个“未映射清单”就是映射规则持续演进的入口。

三、单位统一:在数值层面做换算

单位不统一是环保数据合并中最隐蔽的错误来源。常见情况是气体浓度有的用μg/m³、有的用mg/m³,CO甚至有用ppm的。处理原则是:先确定每个源列的单位,再统一换算到标准单位。单位信息可能藏在列名括号里(如“SO2(mg/m³)”),也可能藏在文件说明行里,还可能完全没有——此时必须依据来源约定人工确认,不能猜。

换算关系要写死在配置中,避免口算出错:1 mg/m³ = 1000 μg/m³;CO在标准状态下 1 ppm ≈ 1.145 mg/m³(20℃、101.325 kPa,仅作示例换算系数,实际应依据监测标准规定的参比状态取值)。换算时只对数值列操作,且要先 pd.to_numeric(errors="coerce"),把“--”“ND”“未检出”等替换为缺失值,再乘系数。

单位换算必须在合并之前、按文件分别执行。若先合并再统一乘系数,会把本来已是μg/m³的文件也放大1000倍,造成数量级错误且难以察觉。

四、批量合并为统一宽表

把上述三步串成流水线:遍历目录下所有CSV/Excel → 探测表头 → 读取 → 列名映射 → 单位换算 → 补充来源标识列 → 纵向拼接 → 按“站点编号+监测时间”去重排序 → 输出宽表。下面的结构图展示了这一数据流与关键判断点:

多源环保文件 → 统一宽表 的数据流 原始文件(异构) A站.csv(标题行+表头) B站.xlsx(合并单元格) 省平台.csv(无表头) ① 表头行探测 非空率 · 唯一性 数值占比评分 取最高分行 低分→人工复核 ② 列名映射 规范化:去括号/空格 别名表 ALIAS 未映射列→清单 补充别名后重跑 ③ 单位统一 识别源单位(列名/说明) mg/m³ → μg/m³ ×1000 非数值→缺失值 按文件分别换算 ④ 纵向拼接 + 来源标识 新增 source_file 列,保留可追溯性 ⑤ 按 站点编号+监测时间 去重排序 输出统一宽表 unified_wide.csv 探测异常→人工复核

拼接时用 pd.concat(dfs, ignore_index=True, sort=False),sort=False 可避免因列顺序不同产生大量NaN列。合并后立即做三项校验:行数是否等于各文件有效行数之和(允许去重后减少);关键字段缺失率是否在可接受范围;单位换算后的数值量级是否合理(例如PM2.5日均值应在个位数到数百μg/m³之间,若出现0.05这类值,多半是mg/m³未换算)。

五、一份可执行的合并配置示例

把易变的部分抽成配置,脚本只负责执行,便于非编程人员维护:

配置项示例值作用
标准字段站点编号, 监测时间, PM2.5, PM10, SO2, NO2, CO, O3宽表列顺序与命名基准
标准单位气体:μg/m³;CO:mg/m³换算目标单位
时间格式%Y-%m-%d %H:%M统一监测时间解析
去重键站点编号 + 监测时间避免重复上报
缺失值标记--, ND, 未检出, 空串统一转为NaN
建议在输出宽表的同时,生成一份“合并日志”:记录每个源文件的表头行号、映射到的标准列、被丢弃的列、单位换算系数、有效行数。出现数据异常时,日志比宽表本身更能快速定位是哪一批文件、哪一步处理出了问题。

完成以上流程后,得到的宽表才具备跨来源可比性。后续的缺失值插补、异常值识别、统计汇总都应建立在这张结构统一、单位一致的表上,而不是在拼接阶段“先合了再说”。结构识别与单位统一看似是准备工作,实际上决定了整条清洗链路的上限。