用 Python 流式处理大型 CSV:逐条检查并统计标签
CSV 文件很大,但你可能只想知道每种标签各有多少条记录。把整个文件读进列表,会为这个简单问题占用多余内存。逐条读取并更新几个计数器,就能完成统计。
这篇教程用 Python 标准库写一个命令行工具:读取样本标签表,检查每条记录,只有整个文件通过检查后才输出 JSON 汇总。它适合用作连接蛋白质序列与标签之前的一项基础检查。
1. 先约定输入格式
示例接受 UTF-8 编码的 CSV,可以带 BOM。文件必须按下面的顺序包含两列:
| 列名 | 允许的值 |
|---|---|
sample_id |
非空字符串,首尾不能有空白字符 |
label |
严格限定为 0、1 或 unknown |
这里用 unknown 表示标签尚不可用,不能把它直接当作负标签。下面的数据都是为教程编写的示例。
工具统计的是记录数,不是去重后的样本数。同一个 ID 出现多次,就会重复计数;唯一性检查需要另外制定规则,并保存额外状态。ID 始终按字符串处理,001 不会变成 1。只有表头的文件合法,计数全为零;完全空白的文件缺少表头,会报错。
2. 下载并运行
使用 Python 3.11 或更新版本,不需要安装第三方库。示例已在 Python 3.14.7 上运行检查。
下载 summarize_labels.py,保存到新目录,然后创建输入并运行:
cat > labels.csv <<'CSV'
sample_id,label
001,1
002,0
"sample,003",unknown
004,1
005,0
006,1
CSV
python3 summarize_labels.py --help
python3 summarize_labels.py labels.csv
预期输出:
{
"records": 6,
"label_counts": {
"0": 2,
"1": 3,
"unknown": 1
}
}
"sample,003" 中的逗号属于 ID。CSV 解析器能识别这个带引号的字段,直接使用 line.split(",") 则会错误地增加字段数。带引号的字段也可以包含换行,因此一行文本未必就是一条完整记录。格式处理方式见 Python CSV 文档。
3. 完整脚本
"""Stream a UTF-8 CSV with exactly sample_id,label columns; count 0/1/unknown."""
import argparse
import csv
import json
def summarize(path):
counts = {"0": 0, "1": 0, "unknown": 0}
records = 0
with open(path, encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle, strict=True)
try:
header = next(reader, None)
if header != ["sample_id", "label"]:
raise ValueError("Expected header: sample_id,label (in that order)")
for row in reader:
where = f"Record ending at physical line {reader.line_num}"
if len(row) != 2:
raise ValueError(f"{where}: expected 2 fields, got {len(row)}")
sample_id, label = row
if not sample_id or sample_id != sample_id.strip():
raise ValueError(f"{where}: sample_id is empty or has outer whitespace")
if label not in counts:
raise ValueError(f"{where}: label must be 0, 1, or unknown")
counts[label] += 1
records += 1
except csv.Error as exc:
raise ValueError(f"CSV parse error near physical line {reader.line_num}: {exc}") from exc
return {"records": records, "label_counts": counts}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", help="Path to the input CSV")
args = parser.parse_args()
try:
result = summarize(args.input)
except (OSError, UnicodeError, ValueError) as exc:
parser.exit(1, f"error: {exc}\n")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
打开文件时使用 newline="",让 CSV 解析器处理换行约定。utf-8-sig 支持开头带有可选 BOM 的 UTF-8 输入。解析器返回字符串,strict=True 会在检测到不合法的 CSV 输入时抛出错误。这些设置不能代替业务检查,因此脚本仍然明确检查表头、字段数量、ID 和标签。相关选项见 Python CSV 方言与格式参数。
汇总会在文件成功读完并关闭后才打印。如果第 500,000 条记录出错,程序以状态码 1 退出,把原因写到标准错误,标准输出中不会出现汇总。前面的记录虽然已经在内部计数,但不会把不完整的计数当成最终结果。
4. 检查失败时的表现
遇到未约定的标签,工具应明确报错:
cat > bad-labels.csv <<'CSV'
sample_id,label
001,1
002,maybe
CSV
python3 summarize_labels.py bad-labels.csv
echo "exit=$?"
预期错误和退出状态:
error: Record ending at physical line 3: label must be 0, 1, or unknown
exit=1
错误中的行号指向记录结束时的物理行。即使带引号的记录跨越多行,也能据此定位。输入文件只以读取方式打开。
如果用 shell 重定向保存结果,要注意 shell 可能在 Python 检查输入之前就创建或清空目标文件。自动化任务应先写临时路径,确认退出状态成功后,再替换上一次成功的报告。
5. 流式读取节省什么
脚本不会保留已经处理过的记录。主要工作数据是一条解析后的记录、输入缓冲区和三个计数器,不会累积成一个随记录数增长的列表。计数增加时,整数的存储也会增长;单条记录特别大时,仍然需要相应内存。这并不是严格的内存上限。
标签集合固定,是这里的关键条件。如果改成用字典统计任意样本 ID,就需要为每个不同的 ID 保存一个条目。即使输入逐条读取,字典仍然可能不断增大。同样,调用 list(reader) 会把剩余记录全部装入内存。
流式读取也无法省去读取和解析整个文件的时间。它适合总数等小规模汇总。如果任务需要全局排序、精确去重或大型连接,而中间数据已经放不进内存,就要选择其他存储方案。
本地检查覆盖了六条记录的示例、0 和 1 各占一半的 100,000 条记录、BOM 和 CRLF、带引号的逗号与换行、仅含表头的文件、重复 ID,以及格式错误的记录。这些检查验证了相应行为,不是速度基准测试。
6. 常见问题
| 现象 | 处理方法 |
|---|---|
Expected header: sample_id,label |
按此顺序导出这两个列名,并移除多余列。 |
expected 2 fields |
检查缺失字段、空记录和未加引号的逗号。 |
label must be 0, 1, or unknown |
运行前明确映射来源标签;空格和空标签都会被拒绝。 |
| UTF-8 解码错误 | 确认来源编码并转换为 UTF-8,不要静默丢弃无法解码的字节。 |
| CSV 字段长度错误 | 检查字段内容与导出格式;如果确实需要这么长的字段,在审查过的修改版中设置合适的 csv.field_size_limit()。 |
为了复现统计结果,读取期间不要修改源文件,并用数据集校验和清单记录文件的校验值。这个工具不会锁定文件,也不会创建快照。
7. 让汇总状态保持简单
每次读取一条 CSV 记录,检查后只保留结果需要的状态。这个标签统计任务只需要三个计数器;ID 唯一性检查和数据集连接,应当另行处理。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-stream-large-csv.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。