3. 文本格式解析与转换实战
科学计算中大量原始数据以文本形式流转:仪器导出的 CSV、日志系统写出的 TSV、Web 接口返回的 JSON。它们看起来都是“纯文本”,但真正读进内存变成一张统一表格之前,编码、分隔符、缺失值这三类问题几乎必然出现。本节围绕 Python pandas,把异构文本数据清洗成统一 DataFrame 的完整链路走一遍。
一、编码:先确定字节如何映射为字符
文本文件本质是字节序列,编码决定字节到字符的映射规则。常见的有 UTF-8(无 BOM)、UTF-8 with BOM(Windows Excel 导出常见,文件头带 \xef\xbb\xbf)、GBK/GB18030(中文 Windows 环境遗留数据)、Latin-1(部分欧美仪器)。编码判断错误时,典型表现是 UnicodeDecodeError,或读进来出现“锟斤拷”“é”这类乱码。
实操上先用 chardet 或 charset_normalizer 做探测,再显式指定编码读取,不要依赖默认值:
import pandas as pd
from charset_normalizer import from_path
guess = from_path("sensor_export.csv").best()
print(guess.encoding, guess.percent_chaos) # 例如 utf_8 0.0
df = pd.read_csv("sensor_export.csv", encoding=guess.encoding)
# 若确认是 Excel 导出的带 BOM 文件,可直接:
df = pd.read_csv("sensor_export.csv", encoding="utf-8-sig")
判断依据很直接:percent_chaos 接近 0 说明探测可信;若读出后中文列名仍异常,优先怀疑 BOM 未剥离,改用 utf-8-sig。对于 GBK 文件,encoding="gb18030" 兼容性优于 gbk。
二、分隔符:不只是逗号和制表符
CSV 名义上是逗号分隔,但实际会遇到分号(欧洲区域设置)、竖线、多空格对齐,甚至“分隔符出现在字段内部”的情况。pandas 的 sep 参数支持正则,engine="python" 时可用多字符分隔。字段内含分隔符或换行时,必须依靠引号包裹,此时 quoting 与 quotechar 要匹配源文件约定。
# 分号分隔、字段用双引号包裹、含千分位逗号
df1 = pd.read_csv("euro_data.csv", sep=";", quotechar='"')
# 不规则空白对齐的定宽/多空格文本
df2 = pd.read_csv("aligned.txt", sep=r"\s+", engine="python")
# TSV 直接指定制表符,避免被误判
df3 = pd.read_csv("run_log.tsv", sep="\t")
一个常见陷阱是 sep=None, engine="python" 让 pandas 自动嗅探分隔符,它对规整文件有效,但对含大量缺失值的稀疏表容易误判,生产脚本中建议显式指定。另一个陷阱是列数不齐:某行列数多于表头时,pandas 会把多出的内容塞进索引或报错,此时用 on_bad_lines="warn" 先观察,再决定是跳过还是修复。
三、缺失值:统一“空”的多种写法
文本里的缺失有太多面孔:空字符串、NA、N/A、null、NaN、-999、--。pandas 默认只把空串和一组常见标记识别为 NaN,其余需要显式声明。na_values 支持列表和按列字典,keep_default_na 控制是否保留默认集合。
na_tokens = ["", "NA", "N/A", "null", "NaN", "--", "-999"]
df = pd.read_csv(
"mixed.csv",
encoding="utf-8-sig",
sep=",",
na_values=na_tokens,
keep_default_na=True,
dtype={"sample_id": "string"}, # 防止 ID 被推断成数字
)
注意 -999 这类“哨兵值”在物理量中常表示无效读数,把它纳入 na_values 比读入后再替换更安全,因为它不会污染数值列的 dtype 推断。读入后还可用 df.isna().sum() 逐列核对缺失规模,判断是否需要插值或整列剔除。
四、JSON:从嵌套结构到扁平表
JSON 的难点不在编码(规范要求 UTF-8),而在结构:字段可能嵌套对象或数组,直接 read_json 会得到 object 类型的列。json_normalize 能把嵌套字典展开成列,用点号表示层级;数组则可用 record_path 展开成多行。
import json
from pandas import json_normalize
with open("api_result.json", encoding="utf-8") as f:
raw = json.load(f)
# raw 形如 {"meta": {...}, "records": [{"id":1,"meas":{"t":20.5,"p":101.3}}, ...]}
flat = json_normalize(
raw["records"],
sep=".", # 嵌套键展开为 meas.t、meas.p
meta=["id"],
)
# 数组字段展开为多行
events = json_normalize(raw["records"], record_path="events",
meta=["id"], errors="ignore")
展开后常出现全空列(某些记录没有该字段),可用 flat.dropna(axis=1, how="all") 清理;数值列若因个别字符串混入变成 object,用 pd.to_numeric(col, errors="coerce") 强制转换,无法解析的值变为 NaN,正好与缺失值处理衔接。
五、统一表格:对齐列名与类型
异构来源合并时,列名大小写、空格、单位后缀往往不一致。建议在读入后立即做一次规范化:列名去空格、转小写、统一单位后缀;随后用 pd.concat 纵向拼接,并显式声明 dtype 或拼接后统一转换,避免同一列在不同来源中分别是 int 和 object。
def normalize_columns(df):
df.columns = (df.columns.str.strip().str.lower()
.str.replace(r"[ /]+", "_", regex=True))
return df
frames = [normalize_columns(d) for d in [df1, df2, df3]]
unified = pd.concat(frames, ignore_index=True, sort=False)
unified["timestamp"] = pd.to_datetime(unified["timestamp"], errors="coerce")
unified["value"] = pd.to_numeric(unified["value"], errors="coerce")
print(unified.dtypes, unified.isna().sum())
下面的示意图把本节的处理链路与关键判断点串起来,便于对照排查:
六、结果核对与常见故障定位
清洗是否成功,不靠“看起来对”,而靠几个可量化的检查点。第一,df.shape 与源文件记录数是否一致,行数骤减通常意味着 on_bad_lines 静默丢弃或分隔符误判。第二,df.dtypes 中数值列是否仍为 object,若是,说明混入了非数字字符或缺失标记未被识别。第三,df.isna().sum() 的分布是否合理,某列缺失率异常高往往指向列名错位或 na_values 误伤。第四,抽样 df.head() 与 df.sample(5) 对照原始文件,确认字段没有整体偏移一列。
UnicodeDecodeError → 编码未指定或探测错误;列名首列带 \ufeff → BOM 未剥离,改用 utf-8-sig;所有列挤成一列 → sep 与实际分隔符不符;数值列变 object → 缺失标记或千分位逗号未处理;行数少于预期 → 坏行被跳过或引号未闭合导致跨行吞并。把上述步骤固化成一个小函数,后续每接入一种新来源,只需调整编码、sep、na_values 三个参数,即可复用同一套清洗逻辑。这正是文本格式互操作的核心:格式差异被收敛到读入参数层,下游计算只面对一张列名统一、类型明确、缺失可统计的表格。