4. 修复数值列中的千分位、货币符号与全角字符

问题定位:为什么金额列被读成了 object

当 pandas 读入一张含金额的表格后,如果该列 dtype 显示为 object,通常不是"数据本身有问题",而是列里混入了无法直接转成数字的字符。最常见的三类污染源是:千分位逗号(1,234.56)、货币符号(¥、$、元)、以及全角数字与全角符号(1234.56、,)。它们各自让 to_numeric 在某个字符处失败,整列因此退化为字符串类型。

下面这张图把"污染字符 → 清洗动作 → 类型结果"的对应关系摊开,便于在真实数据里按图索骥地定位是哪一类字符在作祟。

金额列 object → float 的三条清洗路径 原始列 amount dtype = object "¥1,234.56" "1234.56" "$ 88.00" 千分位逗号 str.replace(",", "") 货币符号 / 单位 str.replace(r"[¥$元]", "", regex=True) 全角数字 / 符号 str.normalize("NFKC") to_numeric errors="coerce" 失败行 → NaN float64 可计算 关键:清洗顺序建议先 NFKC 归一化 → 去货币符号 → 去千分位逗号 → to_numeric;用 errors="coerce" 把漏网行暴露为 NaN,再核对 NaN 数量是否等于原始非空行数。

构造一份可复现的脏数据

为了把问题讲透,先造一列同时含三类污染的金额数据,并混入一个真正的空值,方便后面验证"清洗后 NaN 只应来自原始空值"。

import pandas as pd
import numpy as np

raw = pd.Series([
    "¥1,234.56",
    "$ 88.00",
    "1234.56",      # 全角数字与全角小数点
    "2,000",
    "元 3,500.10",
    None,
    "1,000,000.00",
])
print(raw.dtype)          # object
print(raw.tolist())

此时直接 pd.to_numeric(raw) 会抛 ValueError,因为第一个元素就含 ¥ 与 ,。这正是"金额列被读成 object"的典型现场。

分步清洗:归一化 → 去符号 → 去逗号 → 转数值

清洗顺序有讲究。全角字符必须先做 Unicode 归一化(NFKC),否则全角逗号 , 和全角小数点 . 不会被后续的 ASCII 替换命中;货币符号与千分位逗号在归一化之后再统一去除,最后交给 to_numeric。

s = raw.copy()

# 1) 全角 → 半角(NFKC 会把 1234.56 变成 1234.56)
s = s.str.normalize("NFKC")

# 2) 去掉货币符号与单位(含全角空格)
s = s.str.replace(r"[¥$¥元\s]", "", regex=True)

# 3) 去掉千分位逗号
s = s.str.replace(",", "", regex=False)

# 4) 转数值,无法解析的置为 NaN 以便暴露
amount = pd.to_numeric(s, errors="coerce")

print(amount)
print(amount.dtype)       # float64

预期结果:1234.56, 88.00, 1234.56, 2000.0, 3500.10, NaN, 1000000.0,类型为 float64。注意第 3 个元素原本是全角 1234.56,经 NFKC 后与第 1 个元素数值一致,这是归一化生效的直接证据。

验证无信息丢失

清洗最容易出的问题是"悄悄丢行"或"悄悄改值"。用下面三项检查把风险钉死:

  1. NaN 数量守恒:清洗后 NaN 数应等于原始空值数(本例为 1)。若多出来,说明有行被 coerce 吞掉,需要回查。
  2. 逐行回算:把清洗后的数值按原格式(千分位 + 两位小数)还原,与去符号后的原始字符串比对。
  3. 量级抽查:对最大值、最小值做人工核对,防止小数点被误删导致数量级错误。
# 检查1:NaN 守恒
orig_na = raw.isna().sum()
new_na = amount.isna().sum()
assert orig_na == new_na, f"NaN 数量不一致: {orig_na} vs {new_na}"

# 检查2:逐行回算比对(跳过原始空值)
mask = raw.notna()
rebuilt = amount[mask].map(lambda v: f"{v:,.2f}")
expected = (raw[mask]
            .str.normalize("NFKC")
            .str.replace(r"[¥$¥元\s]", "", regex=True)
            .str.replace(",", "", regex=False)
            .astype(float)
            .map(lambda v: f"{v:,.2f}"))
assert (rebuilt.values == expected.values).all(), "存在数值被改动"

# 检查3:量级抽查
print(amount.describe())
若第 2 项检查失败,通常是某个货币符号未被正则覆盖(例如 €、HK$)。把失败行的原始字符串打印出来,逐个补进正则字符集即可,不要图省事用 errors="ignore" 掩盖。

封装成可复用的清洗函数

把上述逻辑收成一个函数,参数化货币符号集合,便于在不同表上复用:

def clean_amount(series, currency_chars=r"¥$¥€元"):
    s = series.astype("string").str.normalize("NFKC")
    s = s.str.replace(f"[{currency_chars}\\s]", "", regex=True)
    s = s.str.replace(",", "", regex=False)
    return pd.to_numeric(s, errors="coerce")

amount = clean_amount(raw)
print(amount.dtype, amount.isna().sum())
不要用 series.astype(float) 直接强转,它遇到任何非法字符都会整列报错且不告诉你哪一行出错;to_numeric(errors="coerce") 配合 NaN 守恒检查,才能既完成转换又保留可追溯性。