Python 数据处理实战:清洗 CSV 与 JSON
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
把前面学到的东西串起来:读一份不干净的 CSV,清洗、统计,输出一份 JSON 报告。脚本可直接运行、可测试。
环境准备
Python 3.13,只用标准库。
python -m venv .venv && source .venv/bin/activate
场景与数据
data/sales.csv(故意留了脏数据):
date,city,amount,note
2026-10-01,上海,120.50,
2026-10-01,北京,
2026-10-02,上海,abc,金额异常
2026-10-02,,80.00,
2026-10-03,北京,200.00,
问题:空金额、非数字金额、空城市、首尾空格。
第一步:定义数据结构
用 dataclass 表达一行清洗后的数据,比到处用字典清楚:
# clean.py
from dataclasses import dataclass, asdict
from datetime import date
from pathlib import Path
@dataclass
class Sale:
date: date
city: str
amount: float
第二步:逐行清洗
import csv
from collections import Counter
def parse_row(row: dict[str, str]) -> tuple[Sale | None, str | None]:
"""清洗一行。返回 (数据, 跳过原因),跳过原因为 None 表示成功。"""
city = (row.get("city") or "").strip()
raw_amount = (row.get("amount") or "").strip()
if not city:
return None, "城市为空"
if not raw_amount:
return None, "金额为空"
try:
amount = float(raw_amount)
except ValueError:
return None, f"金额不是数字:{raw_amount!r}"
if amount < 0:
return None, f"金额为负:{amount}"
return Sale(date.fromisoformat(row["date"].strip()), city, amount), None
要点:
- 字段一律
strip(),空值用显式判断。 - 转换失败用
try/except ValueError而不是if x.isdigit()(isdigit认不了120.50和负数)。 - 返回「(结果, 原因)」让上层能统计被丢弃的原因,而不是静默丢数据。
第三步:读取并统计
def load_sales(path: Path) -> tuple[list[Sale], Counter[str]]:
sales: list[Sale] = []
skipped: Counter[str] = Counter()
with path.open(encoding="utf-8", newline="") as f:
for row in csv.DictReader(f):
sale, reason = parse_row(row)
if sale is None:
skipped[reason or "未知原因"] += 1
continue
sales.append(sale)
return sales, skipped
第四步:聚合与报告
def summarize(sales: list[Sale]) -> dict:
by_city: dict[str, float] = {}
by_city_count: dict[str, int] = {}
for sale in sales:
by_city[sale.city] = by_city.get(sale.city, 0.0) + sale.amount
by_city_count[sale.city] = by_city_count.get(sale.city, 0) + 1
top_city = max(by_city, key=by_city.get) if by_city else None
return {
"total_records": len(sales),
"total_amount": round(sum(s.amount for s in sales), 2),
"average_amount": round(sum(s.amount for s in sales) / len(sales), 2) if sales else 0,
"by_city": {city: round(amount, 2) for city, amount in sorted(by_city.items())},
"records_by_city": dict(sorted(by_city_count.items())),
"top_city": top_city,
}
第五步:输出 JSON 报告
import json
def write_report(report: dict, out_path: Path) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
完整入口
def main() -> int:
base = Path(__file__).resolve().parent
src = base / "data" / "sales.csv"
out = base / "output" / "report.json"
if not src.exists():
print(f"找不到数据文件:{src}", file=sys.stderr)
return 1
sales, skipped = load_sales(src)
report = summarize(sales)
report["skipped"] = dict(skipped)
write_report(report, out)
print(f"有效 {len(sales)} 条,跳过 {sum(skipped.values())} 条 → {out}")
for reason, count in skipped.most_common():
print(f" 跳过原因 {reason}:{count} 条")
return 0
if __name__ == "__main__":
sys.exit(main())
运行:
python clean.py
# 有效 2 条,跳过 3 条 → .../output/report.json
# 跳过原因 金额为空:1 条
# 跳过原因 金额不是数字:'abc':1 条
# 跳过原因 城市为空:1 条
报告内容:
{
"total_records": 2,
"total_amount": 320.5,
"average_amount": 160.25,
"by_city": { "北京": 200.0, "上海": 120.5 },
"records_by_city": { "北京": 1, "上海": 1 },
"top_city": "北京",
"skipped": { "金额为空": 1, "金额不是数字:'abc'": 1, "城市为空": 1 }
}
让它可测试
parse_row 是纯函数,测试很容易写:
# tests/test_clean.py
from clean import parse_row
def test_parse_normal():
sale, reason = parse_row({"date": "2026-10-01", "city": " 上海 ", "amount": "120.5"})
assert reason is None
assert sale.city == "上海"
assert sale.amount == 120.5
def test_parse_bad_amount():
sale, reason = parse_row({"date": "2026-10-02", "city": "上海", "amount": "abc"})
assert sale is None
assert reason is not None
load_sales 需要文件,用 pytest 的 tmp_path fixture 造临时 CSV(见 pytest 那一篇)。
常见错误与注意事项
1. 写 CSV 忘了 newline=""
Windows 下每行之间多出空行。
2. 用 float 做金额计算
0.1 + 0.2 != 0.3。金额精确计算用 decimal.Decimal:
from decimal import Decimal
Decimal("120.50") + Decimal("0.25") # Decimal('120.75')
展示用 float 也无妨,但累加、对账必须用 Decimal。
3. 静默丢掉异常行
不统计跳过原因,最后对不上数也查不出问题。
4. 编码没写死
open(..., encoding="utf-8") 与 write_text(..., encoding="utf-8") 都要显式写。
5. 一个函数做了全部事
把「解析 / 统计 / 输出」拆开,才能单独测解析逻辑。
6. 用 isdigit() 判断数字
认不了小数与负数。直接 float() + try/except 最可靠。
小结
- 清洗流程:读 → 逐行解析(记录跳过原因)→ 聚合 → 输出。
- 用
dataclass建模、Counter统计跳过原因、pathlib管路径。 - 输出 JSON 用
ensure_ascii=False+indent=2。 - 把解析逻辑抽成纯函数,配 pytest 做回归测试。