1. 定位 pandas 读取 CSV 时的编码与分隔符错位

问题现场:一份“看起来正常”的 CSV

假设拿到一份导出文件 sales_export.csv,用记事本打开能看到中文商品名,但字段之间既有逗号又有分号,个别行还夹着制表符。直接执行 pd.read_csv("sales_export.csv") 后,典型症状是:列数比预期多、Unnamed: 0 之类的列出现、中文变成 锟斤拷 或 中、本该是数字的列变成 object。这三类症状分别指向编码、分隔符、dtype 三个不同根因,必须分开定位,不能靠反复试参数碰运气。

原始字节流 BOM? GBK? UTF-8? , ; \t 混用 前 4 字节最关键 编码探测 chardet / charset_normalizer 看 BOM:EF BB BF 置信度 < 0.8 需人工复核 分隔符推断 csv.Sniffer 统计每行候选符出现次数 次数稳定且 >0 才可信 dtype 检查 df.dtypes / df.shape 数值列应为 int/float object 即疑似错位 症状 → 根因映射 中文乱码 / 锟斤拷 → 编码不匹配(GBK 当 UTF-8) → 修复:encoding="gbk" 或 "utf-8-sig" 列数变多 / Unnamed 列 → 分隔符错位(, 与 ; 混用) → 修复:sep=";" 或 sep=None, engine="python" 数值列变 object → 错位后数字混入文本 → 修复:先修分隔符,再 astype 修复后必须通过的 4 项校验 ① 形状 df.shape 列数 = 表头数 行数 = 数据行数 ② 列名 无 Unnamed / 无重复 中文列名可正常显示 ③ dtype 金额/数量为数值型 日期列可 to_datetime ④ 抽样 df.head(3) 人工核对 首尾行字段对齐

第一步:先看字节,不要先猜编码

编码问题最忌讳“试一个 encoding 参数看能不能跑”。正确做法是先读原始字节,判断是否存在 BOM,再交给探测器给出候选编码。BOM 是 UTF-8 文件开头的 EF BB BF,若存在,用 utf-8-sig 读取会自动剥离,否则第一列列名会带上不可见字符,导致后续按列名取值失败。

from pathlib import Path
import charset_normalizer

raw = Path("sales_export.csv").read_bytes()
print(raw[:4])  # 例如 b'\xef\xbb\xbf' 说明有 UTF-8 BOM

best = charset_normalizer.from_bytes(raw).best()
print(best.encoding, best.percent_chaos)  # 候选编码与乱码比例

判断依据:若 percent_chaos 很低(接近 0)且编码为 utf_8,优先用 utf-8-sig;若探测器给出 gb18030 或 gbk,说明文件是中文 Windows 环境导出,直接用 gb18030 兼容性最好(它是 GBK 的超集)。不要用 latin-1 强行读,它永远不报错,但会把中文变成乱码且掩盖真实问题。

第二步:用 Sniffer 推断分隔符,再用统计交叉验证

混合分隔符文件里,csv.Sniffer 只看前若干行,容易误判。稳妥做法是:让 Sniffer 给出候选,再对全文件逐行统计候选符出现次数,只有“每行次数一致且大于 0”的符号才是真分隔符。

import csv, io
from collections import Counter

text = raw.decode("gb18030", errors="replace")
sample = "\n".join(text.splitlines()[:20])
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
print("Sniffer 候选:", repr(dialect.delimiter))

lines = [ln for ln in text.splitlines() if ln.strip()]
for cand in [",", ";", "\t", "|"]:
    counts = Counter(ln.count(cand) for ln in lines)
    print(cand, dict(counts))

判读方法:若 , 的出现次数分布很散(如 {1: 30, 2: 12, 0: 5}),而 ; 稳定为 {3: 47},则真分隔符是 ;,逗号只是字段内容里的千分位或文本标点。此时应显式写 sep=";",而不是依赖 sep=None 自动推断——自动推断在混合文件上不稳定,且每次运行结果可能不同。

第三步:读入后立刻做 dtype 与形状体检

编码和分隔符都定下来后,读入只是开始。列错位最隐蔽的表现是“能读进来、不报错,但数值列变成 object”。因此读入后必须马上检查形状、列名和 dtype,把问题挡在分析之前。

import pandas as pd

df = pd.read_csv(
    "sales_export.csv",
    encoding="gb18030",
    sep=";",
    dtype={"订单号": "string"},   # 订单号含前导零,必须按字符串读
    na_values=["", "NULL", "-"],
)

print(df.shape)
print(df.columns.tolist())
print(df.dtypes)

# 关键断言:金额、数量必须是数值型
for col in ["金额", "数量"]:
    if not pd.api.types.is_numeric_dtype(df[col]):
        bad = df[pd.to_numeric(df[col], errors="coerce").isna()][col].head()
        print(f"{col} 非数值样本:", bad.tolist())

如果 金额 是 object,先别急着 astype(float)。打印几个非数值样本,常见原因是:千分位逗号("1,234.5")、货币符号、或整列错位导致文本混入。前两者用 str.replace(",", "").astype(float) 处理;若是错位,说明分隔符仍不对,要回到第二步。订单号这类含前导零的列必须用 dtype="string" 读入,否则 00123 会变成 123,属于不可逆的信息丢失。

不要用 errors="ignore" 或 encoding="latin-1" 来“让代码跑通”。前者会静默丢字符,后者让乱码看起来像正常文本,两者都会把编码问题推迟到下游才爆发,排查成本成倍增加。

可复用的定位脚本

把上述三步串成一个函数,输入文件路径,输出诊断结论。这样每拿到一份新导出文件,先跑诊断再决定读取参数,避免凭经验猜。

def diagnose_csv(path):
    raw = Path(path).read_bytes()
    has_bom = raw.startswith(b"\xef\xbb\xbf")
    enc = charset_normalizer.from_bytes(raw).best().encoding
    enc = "utf-8-sig" if has_bom else enc

    text = raw.decode(enc, errors="replace")
    lines = [ln for ln in text.splitlines() if ln.strip()]
    scores = {}
    for cand in [",", ";", "\t", "|"]:
        counts = Counter(ln.count(cand) for ln in lines)
        top, freq = counts.most_common(1)[0]
        scores[cand] = (top, freq / len(lines))
    sep = max(scores, key=lambda c: (scores[c][1], scores[c][0]))
    return {"encoding": enc, "bom": has_bom, "sep": sep, "scores": scores}

print(diagnose_csv("sales_export.csv"))

输出中 scores 的第二个值是该候选符“最常见出现次数”占行数的比例,越接近 1 越可信。若所有候选都低于 0.9,说明文件结构本身不规则(可能含多段不同格式),需要按行分段处理,而不是一个 read_csv 解决。

修复前后对照

检查项修复前(默认参数)修复后(显式参数)
列数7 列,多出 Unnamed: 05 列,与表头一致
中文列名锟斤拷 / 乱码正常显示
金额 dtypeobjectfloat64
订单号int,前导零丢失string,保留 00123
行数因错位多出空行与源文件数据行一致

验证通过的标准是四项同时满足:形状与表头匹配、无 Unnamed 列、数值列为数值型、df.head(3) 人工核对字段对齐。四项缺一,说明还有未定位的根因,应回到字节层重新检查,而不是继续在下游做列名清洗掩盖问题。