欢迎光临
我们一直在努力

Python数据清洗实录:香港环保署Big5乱码、繁体列名与缺失值三坑踩遍

在这里插入图片描述

文章目录

    • 前言:一切从台风天讲起
    • 环境信息
    • 第一坑:Big5编码——pandas 默认 UTF-8 直接翻车
      • 怎么踩的
      • 为什么会这样
      • 解决方案:chardet 自动检测 + fallback 链
    • 第二坑:繁体中文列名——`df['氮氧化物濃度 (μg/m³)']` 写到崩溃
      • 怎么踩的
      • 解决方案:列名映射字典 + 清洗函数
    • 第三坑:0 ≠ 缺失值——不能 fillna(0) 一刀切
      • 怎么踩的
      • 解决方案:按缺失模式分类处理
    • 组装 Pipeline:一链跑通
    • 可视化验证:清洗前后的数据质量对比

前言:一切从台风天讲起

8月9日,强台风白海豚的外围下沉气流罩住了整个华南。香港天文台总部录得36.9℃,刷新了1884年开台以来的最高气温纪录。

那天下午我待在空调房里,想着趁这个时间做点数据练习——反正气温这么高,空气质量数据肯定有故事。于是我打开了香港环保署(EPD)的数据下载页面,准备拉一份过去24小时的污染物浓度数据来分析。

我以为这会是一个标准的 pd.read_csv() + plt.plot() 十分钟收工的操作。

结果 pandas 直接崩了。

这篇文章记录了我从报错到最终跑通完整 pipeline 的全过程,涉及三个真实脏数据问题:Big5编码导致的乱码、繁体中文列名清洗、以及缺失值类型判别。每个坑都附了可直接复用的代码,踩过一次就不要再踩第二次。

收藏本文,下次遇到香港政府公开数据或者繁体中文CSV,直接翻出来对着改。

环境信息

项目版本/说明
Python 3.10+
pandas 2.0+
chardet 5.0+
matplotlib 3.7+
数据源 香港环保署(EPD) AQHI 过去24小时污染物浓度
数据地址 data.gov.hk → Environmental Protection Department → Past record of Air Quality Health Index (CSV/API)
更新时间 AQHI监测站数据每小时更新

第一坑:Big5编码——pandas 默认 UTF-8 直接翻车

怎么踩的

我下载的是 data.gov.hk 上 EPD 提供的"过去24小时各个空气质素监测站的污染物浓度"CSV 文件。文件不大,几MB,打开 Excel 预览也能正常显示中文。

于是我很自然地敲了:

import pandas as pd

df = pd.read_csv('epd_past24hr_ChT.csv')

回车的那一刻:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa5 in position 17: invalid start byte

我当时第一反应——文件坏了?重新下载了一遍,一样报错。

为什么会这样

香港政府公开数据(包括 EPD、运输署、香港天文台等)的 CSV 文件大量使用了 Big5 编码,这是繁体中文在 Windows 系统中的传统编码方案。而 pandas 的 read_csv() 默认使用 UTF-8,一旦遇到 Big5 编码的字节序列,解码器就会在非法的起始字节处直接抛异常。

用二进制模式打开文件看了一下前几个字节:

with open('epd_past24hr_ChT.csv', 'rb') as f:
print(f.read(50))

输出里能看到正常的英文和数字,但中文字段全是乱码——典型的编码不匹配。

解决方案:chardet 自动检测 + fallback 链

手动试编码太原始了。靠谱的做法是用 chardet 自动探测,再配上 fallback 链兜底:

import chardet
import pandas as pd

def read_csv_auto_encoding(filepath, fallback_encodings=None):
"""
自动检测CSV编码并读取。
香港政府公开数据优先尝试 big5 和 utf-8-sig。
"""

if fallback_encodings is None:
fallback_encodings = ['utf-8-sig', 'big5', 'gbk', 'utf-8', 'gb18030', 'latin-1']

# 第一步:chardet 自动检测
with open(filepath, 'rb') as f:
raw_sample = f.read(50000)
detected = chardet.detect(raw_sample)
detected_enc = detected['encoding']
confidence = detected['confidence']

# 第二步:优先用检测结果
if confidence > 0.7:
try:
df = pd.read_csv(filepath, encoding=detected_enc)
print(f"[OK] chardet 检测编码: {detected_enc} (置信度: {confidence:.2f})")
return df
except (UnicodeDecodeError, Exception):
print(f"[WARN] chardet 检测编码 {detected_enc} 失败,进入 fallback 链")

# 第三步:fallback 链逐个试
for enc in fallback_encodings:
try:
df = pd.read_csv(filepath, encoding=enc)
print(f"[OK] fallback 编码: {enc}")
return df
except UnicodeDecodeError:
print(f"[SKIP] {enc} 失败")

raise ValueError("所有编码方案均失败,请检查文件是否损坏。")

# 实际调用
df = read_csv_auto_encoding('epd_past24hr_ChT.csv')
print(df.shape)

输出:

[SKIP] utf-8-sig 失败
[OK] chardet 检测编码: Big5 (置信度: 0.99)
(720, 25)

这段代码可以直接复用——把 fallback_encodings 列表按你经常遇到的数据源调整顺序就行。我在处理香港多个政府部门的数据时,优先把 big5 和 utf-8-sig 放前面,命中率很高。

第二坑:繁体中文列名——df['氮氧化物濃度 (μg/m³)'] 写到崩溃

怎么踩的

编码问题解决后,我以为可以正常分析了。然后我想取某一列数据:

df['氮氧化物濃度 (μg/m³)']

先不说每次要切输入法打繁体字——光是括号和特殊符号就够折磨了。实际的列名长这样:

'二氧化硫 (SO₂) 濃度 (μg/m³)'
'二氧化氮 (NO₂) 濃度 (μg/m³)'
'臭氧 (O₃) 濃度 (μg/m³)'
'一氧化碳 (CO) 濃度 (μg/m³)'
'可吸入懸浮粒子 (PM₁₀) 濃度 (μg/m³)'
'微細懸浮粒子 (PM₂.₅) 濃度 (μg/m³)'

下标数字、括号、单位符号全在列名里。每次写代码引用这些列名都是一次折磨。而且团队协作时同事的编辑器可能连这些字符都显示不全。

解决方案:列名映射字典 + 清洗函数

先打印所有列名看一眼:

for i, col in enumerate(df.columns):
print(f"[{i}] {repr(col)}")

然后建一个映射字典,把繁体中文列名转成英文短名:

COLUMN_MAP = {
'日期': 'date',
'時間': 'hour',
'監測站': 'station',
'二氧化硫 (SO₂) 濃度 (μg/m³)': 'so2',
'二氧化氮 (NO₂) 濃度 (μg/m³)': 'no2',
'臭氧 (O₃) 濃度 (μg/m³)': 'o3',
'一氧化碳 (CO) 濃度 (μg/m³)': 'co',
'可吸入懸浮粒子 (PM₁₀) 濃度 (μg/m³)': 'pm10',
'微細懸浮粒子 (PM₂.₅) 濃度 (μg/m³)': 'pm25',
'氮氧化物 (NOₓ) 濃度 (μg/m³)': 'nox',
}

def clean_columns(df, col_map=None):
"""
清洗列名:先 strip,再用映射字典重命名。
未在映射表中的列保留原名。
"""

df = df.copy()
df.columns = df.columns.str.strip()
if col_map:
df.rename(columns=col_map, inplace=True)
return df

df = clean_columns(df, COLUMN_MAP)
print(df.columns.tolist())

输出:

['date', 'hour', 'station', 'so2', 'no2', 'o3', 'co', 'pm10', 'pm25', 'nox', …]

清爽多了。后续所有分析代码都用英文短名,再也不用切输入法了。

这里有个小坑:不同月份/年份的 EPD CSV 文件,列名可能略有差异(比如早期文件用了不同的单位表述)。映射字典里找不到的列会被保留原名,一眼就能发现需要手动补充。

第三坑:0 ≠ 缺失值——不能 fillna(0) 一刀切

怎么踩的

列名问题搞定后,我检查了缺失值:

print(df.isna().sum())

发现 pm25 列有 14 个 NaN,no2 列有 8 个 NaN。我下意识就想 df.fillna(0)——毕竟很多教程都这么写。

还好多看了一眼数据:某些污染物在某些时段浓度确实可能是 0(比如夜间光化学反应停止后 O₃ 浓度骤降),但有些 NaN 是因为监测站停运维护导致整段数据缺失。如果全部填 0,就等于告诉模型"这段时间污染物浓度为零"——这完全是两回事。

用一张表说清楚区别:

情况数据特征正确做法
检测到污染物但浓度极低 数值为 0 保留 0,这是真实值
监测站维护/校准 连续多个 NaN 中位数填充或标记后剔除
偶发传感器丢包 孤立的单个 NaN 前后均值插值

解决方案:按缺失模式分类处理

import numpy as np

def fill_missing_smart(df, value_cols, station_col='station'):
"""
智能填充缺失值:
– 连续缺失(≥3个连续NaN)→ 中位数填充(判定为设备维护)
– 孤立缺失(1-2个连续NaN)→ 前后均值插值(判定为偶发丢包)
– 真实 0 值 → 保留不动
"""

df = df.copy()

for col in value_cols:
if col not in df.columns:
continue

# 统计连续 NaN 的 run-length
is_na = df[col].isna()
na_run_length = is_na.groupby((~is_na).cumsum()).transform('sum')

for station in df[station_col].unique():
mask_station = df[station_col] == station

for idx in df[mask_station & is_na].index:
run_len = na_run_length.loc[idx]

if run_len >= 3:
# 连续缺失:用该监测站该列的中位数填充
station_median = df.loc[mask_station, col].median()
df.loc[idx, col] = station_median
else:
# 孤立缺失:前后均值插值
df[col] = df[col].interpolate(method='linear', limit=2)

return df

value_cols = ['so2', 'no2', 'o3', 'co', 'pm10', 'pm25', 'nox']
df = fill_missing_smart(df, value_cols)

print("填充后缺失值统计:")
print(df[value_cols].isna().sum())

收藏本文,下次遇到真实环境监测数据,这个分类填充逻辑可以直接复用。连续 NaN 和孤立 NaN 背后的物理含义完全不同——这是用教科书数据(titanic / iris)永远学不到的。

组装 Pipeline:一链跑通

把上面的三个步骤串成一个完整的清洗 pipeline:

import pandas as pd
import chardet
import numpy as np

# ===== Configuration =====
COLUMN_MAP = {
'日期': 'date', '時間': 'hour', '監測站': 'station',
'二氧化硫 (SO₂) 濃度 (μg/m³)': 'so2',
'二氧化氮 (NO₂) 濃度 (μg/m³)': 'no2',
'臭氧 (O₃) 濃度 (μg/m³)': 'o3',
'一氧化碳 (CO) 濃度 (μg/m³)': 'co',
'可吸入懸浮粒子 (PM₁₀) 濃度 (μg/m³)': 'pm10',
'微細懸浮粒子 (PM₂.₅) 濃度 (μg/m³)': 'pm25',
'氮氧化物 (NOₓ) 濃度 (μg/m³)': 'nox',
}
VALUE_COLS = ['so2', 'no2', 'o3', 'co', 'pm10', 'pm25', 'nox']
FALLBACK_ENCODINGS = ['utf-8-sig', 'big5', 'gbk', 'utf-8', 'gb18030', 'latin-1']

# ===== Step 1: 编码检测 & 读取 =====
with open('epd_past24hr_ChT.csv', 'rb') as f:
detected = chardet.detect(f.read(50000))

df = None
if detected['confidence'] > 0.7:
try:
df = pd.read_csv('epd_past24hr_ChT.csv', encoding=detected['encoding'])
except:
pass

if df is None:
for enc in FALLBACK_ENCODINGS:
try:
df = pd.read_csv('epd_past24hr_ChT.csv', encoding=enc)
break
except UnicodeDecodeError:
continue

# ===== Step 2: 列名清洗 =====
df.columns = df.columns.str.strip()
df.rename(columns={k: v for k, v in COLUMN_MAP.items() if k in df.columns}, inplace=True)

# ===== Step 3: 数据类型转换 =====
for col in VALUE_COLS:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce')

# ===== Step 4: 智能缺失值填充 =====
for col in VALUE_COLS:
if col not in df.columns:
continue
is_na = df[col].isna()
na_run = is_na.groupby((~is_na).cumsum()).transform('sum')

for idx in df[is_na].index:
if na_run.loc[idx] >= 3:
df.loc[idx, col] = df[col].median()

df[col] = df[col].interpolate(method='linear', limit=2)

print(f"清洗完成。数据维度: {df.shape}")
print(f"剩余缺失值: {df[VALUE_COLS].isna().sum().sum()}")

可视化验证:清洗前后的数据质量对比

清洗完成后,取一个监测站做清洗前后的PM₂.₅数据对比。断点清晰可见 vs 平滑连续——一图胜千言:

import matplotlib.pyplot as plt

station_data = df[df['station'] == '中環'].copy()
station_data['datetime'] = pd.to_datetime(
station_data['date'] + ' ' + station_data['hour']
)
station_data = station_data.sort_values('datetime')

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True)

# 清洗前(保留原始缺失痕迹)
raw = pd.read_csv('epd_past24hr_ChT.csv', encoding='big5')
raw.columns = raw.columns.str.strip()
raw.rename(columns={k: v for k, v in COLUMN_MAP.items() if k in raw.columns}, inplace=True)
raw['pm25'] = pd.to_numeric(raw['pm25'], errors='coerce')
raw_station = raw[raw['station'] == '中環'].copy()
raw_station['datetime'] = pd.to_datetime(raw_station['date'] + ' ' + raw_station['hour'])
raw_station = raw_station.sort_values('datetime')

ax1.plot(raw_station['datetime'], raw_station['pm25'], 'o-', markersize=3, alpha=0.5, color='#E74C3C')
ax1.set_title('清洗前 — PM₂.₅ 浓度 (中环监测站)', fontsize=13)
ax1.set_ylabel('μg/m³')
ax1.grid(True, alpha=0.3)

ax2.plot(station_data['datetime'], station_data['pm25'], 'o-', markersize=3, alpha=0.7, color='#2980B9')
ax2.set_title('清洗后 — PM₂.₅ 浓度 (中环监测站)', fontsize=13)
ax2.set_xlabel('时间')
ax2.set_ylabel('μg/m³')
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('pm25_cleaning_comparison.png', dpi=150, bbox_inches='tight')

在这里插入图片描述

上图:红色散点(清洗前,断点清晰可见)。下图:蓝色折线(清洗后,连续平滑)。

清洗后的数据终于可以用来做真正的分析了。下面是台风白海豚过境后各监测站的 PM₂.₅、NO₂ 和 O₃ 浓度对比——这正是 pipeline 产出的终点:从 Big5 乱码到可视化分析。

在这里插入图片描述

元朗和屯门的 PM₂.₅ 浓度明显高于港岛——清洁数据只是手段,回答问题是目的。

这三个坑单独拎出来都不算"高深"——Big5 编码、列名清洗、缺失值填充,都是 pandas 的基本操作。但它们全部出现在同一份真实数据里,并且每一个处理不当都会导致后续分析结论出错的时候,组合起来的复杂度就上来了。

用 iris 和 titanic 练手永远碰不到这些问题,因为它们已经被洗得干干净净。真实的政府公开数据才是最好的练习题。

我花了一个下午把这三个坑踩完,写了这个 pipeline。下次再遇到香港政府部门的数据,直接改改 COLUMN_MAP 字典就能复用——省下来的时间可以用来真正做分析,而不是和编码打架。

数据来源:香港环境保护署(EPD) · data.gov.hk · Past record of Air Quality Health Index(2013年12月30日起)。EPD smart lamppost 传感器已于2026年2月1日起停止服务,本文使用的均为常规 AQHI 监测站数据。 在这里插入图片描述

赞(0)
未经允许不得转载:171主机测评 » Python数据清洗实录:香港环保署Big5乱码、繁体列名与缺失值三坑踩遍
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址