1. 定位 pandas 读取 CSV 时的编码与分隔符错位
问题现场:一份“看起来正常”的 CSV
假设拿到一份导出文件 sales_export.csv,用记事本打开能看到中文商品名,但字段之间既有逗号又有分号,个别行还夹着制表符。直接执行 pd.read_csv("sales_export.csv") 后,典型症状是:列数比预期多、Unnamed: 0 之类的列出现、中文变成 锟斤拷 或 ä¸Â、本该是数字的列变成 object。这三类症状分别指向编码、分隔符、dtype 三个不同根因,必须分开定位,不能靠反复试参数碰运气。
第一步:先看字节,不要先猜编码
编码问题最忌讳“试一个 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: 0 | 5 列,与表头一致 |
| 中文列名 | 锟斤拷 / 乱码 | 正常显示 |
| 金额 dtype | object | float64 |
| 订单号 | int,前导零丢失 | string,保留 00123 |
| 行数 | 因错位多出空行 | 与源文件数据行一致 |
验证通过的标准是四项同时满足:形状与表头匹配、无 Unnamed 列、数值列为数值型、df.head(3) 人工核对字段对齐。四项缺一,说明还有未定位的根因,应回到字节层重新检查,而不是继续在下游做列名清洗掩盖问题。