
文章目录
-
- 前言:一切从台风天讲起
- 环境信息
- 第一坑: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 监测站数据。 


