2. 排查 Excel 多表头与合并单元格导致的列名异常

问题定位:多行表头与合并单元格如何破坏列名

含多行表头与合并单元格的 Excel 文件,在 pandas 默认读取时会出现三类典型异常:列名重复(合并单元格展开后多列同名)、NaN 列名(空白单元格被读成未命名列)、数据偏移(真正的表头行被当成数据行,或数据行被当成表头)。核心原因是 read_excel 默认 header=0,只取第一行作列名,而合并单元格在 Excel 中只有左上角单元格有值,其余为空白。

原始 Excel 结构(第1行大标题合并,第2行分组,第3行字段) 2024 销售数据(合并 A1:D1) 备注(合并 E1:F1) 华东(合并 A2:B2) 华南(合并 C2:D2) 状态 负责人 销售额 订单数 销售额 订单数 状态 负责人 1200 34 980 21 完成 张三 header=0 默认读取结果(异常) 列名: 2024 销售数据(合并 A1:D1) Unnamed: 1 Unnamed: 2 … → 真实字段行被当数据 → 列名重复 / NaN / 偏移 修复策略:skiprows + header + 列名清洗 ① skiprows=2 跳过前2行大标题/分组 header=0 指向真实字段行 ② 列名清洗 去空白、去 Unnamed 重复列名加后缀 _1/_2 ③ 校验 列名唯一、无 NaN dtypes 与行数核对 修复后列名(唯一、可读) 华东_销售额 | 华东_订单数 | 华南_销售额 | 华南_订单数 | 状态 | 负责人 → 无重复、无 NaN、无 Unnamed;数据行从第3行开始,行数与源表一致 → 若分组表头需保留,可 header=[0,1] 生成 MultiIndex 后再扁平化

复现步骤与关键代码

下面用一份构造的示例文件 sales_multiheader.xlsx 演示完整修复流程。示例数据为教学构造,非真实业务数据。

import pandas as pd
import numpy as np

# 1) 先"裸读"看结构:不设 header,观察前几行
raw = pd.read_excel("sales_multiheader.xlsx", header=None, nrows=5)
print(raw)
# 预期:第0行大标题、第1行分组、第2行真实字段、第3行起为数据

# 2) 定位真实字段行:找到第一行"非空单元格最多"的行
non_null = raw.notna().sum(axis=1)
header_row = int(non_null.idxmax())
print("真实字段行索引:", header_row)   # 示例中应为 2

# 3) 用 skiprows + header 正确读入
df = pd.read_excel("sales_multiheader.xlsx", skiprows=header_row, header=0)
print(df.columns.tolist())
# 预期:['销售额','订单数','销售额','订单数','状态','负责人'] —— 仍有重复

# 4) 列名清洗:去空白、去 Unnamed、重复加后缀
def clean_columns(cols):
    out, seen = [], {}
    for c in cols:
        name = str(c).strip()
        if name == "" or name.lower().startswith("unnamed"):
            name = "col"
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        out.append(name)
    return out

df.columns = clean_columns(df.columns)
print(df.columns.tolist())
# 预期:['销售额','订单数','销售额_1','订单数_1','状态','负责人']

# 5) 校验
assert df.columns.is_unique, "列名仍有重复"
assert not df.columns.isna().any(), "存在 NaN 列名"
assert df.shape[0] == len(pd.read_excel("sales_multiheader.xlsx", header=None)) - header_row - 1
print("校验通过,行数:", df.shape[0])
若分组表头(华东/华南)需要保留语义,可改用 header=[0,1] 读成 MultiIndex,再用 df.columns = ['_'.join(map(str, c)).strip() for c in df.columns] 扁平化,得到 华东_销售额 这类可读列名。
常见坑:skiprows 与 header 同时使用时,header 是相对于跳过后的行计数。若 skiprows=2, header=0,则原第3行(索引2)成为列名,原第4行起为数据——务必用 nrows 先验证,避免数据行被误当表头。

结果判读与验证要点

检查项通过标准失败时的典型原因
列名唯一性df.columns.is_unique == True合并单元格展开后同名,未加后缀
无 NaN 列名not df.columns.isna().any()空白单元格被读成 Unnamed/NaN
行数一致读入行数 = 源表总行数 − 表头行数header 定位错误导致数据行被吞
dtype 合理数值列非 object表头行混入数据,整列变字符串