3. 数据清洗与预处理实战

作物产量预测模型的效果,很大程度上在数据进入模型之前就已经被决定了。田间采集的产量数据往往来自多个来源:气象站逐日记录、土壤传感器定时上报、人工测产记录、卫星遥感反演的植被指数。这些数据在时间粒度、空间范围、缺失模式和异常表现上各不相同。本节围绕四个核心环节展开:缺失值处理、异常值检测、重复数据去除与时间序列对齐,并给出可直接运行的 Python 实现思路。

先明确一个贯穿全节的参照约定:所有时间序列统一以“日期”为索引,按自然日升序排列,时间戳表示当日 00:00;产量与气象特征均视为“当日观测值”,不做跨日平移。后续对齐与插值都基于这一约定,避免出现错位。

一、缺失值:先判断机制,再选择策略

缺失不是简单的“补一个数”。首先要区分缺失机制:气象站设备故障造成的连续多日缺失,属于随机缺失但成块出现;传感器电量耗尽导致的夜间缺失,属于与时间相关的系统性缺失;而某些地块从未安装土壤传感器,则是结构性缺失。不同机制对应不同处理方式。

对于短时随机缺失,线性插值或时间加权插值通常足够;对于成块缺失,直接插值会制造虚假的平滑趋势,更稳妥的做法是标记缺失指示变量,或改用同期多年均值填充。Pandas 提供了便捷入口:

import pandas as pd
import numpy as np

df = pd.read_csv("crop_daily.csv", parse_dates=["date"])
df = df.sort_values(["plot_id", "date"]).reset_index(drop=True)

# 按地块分组,对气象特征做时间线性插值,限制最大连续填补长度
df["temp"] = df.groupby("plot_id")["temp"].transform(
    lambda s: s.interpolate(method="time", limit=3, limit_area="inside")
)

# 对缺失比例过高的列,生成缺失指示变量而非强行填充
df["soil_moisture_missing"] = df["soil_moisture"].isna().astype(int)
df["soil_moisture"] = df.groupby("plot_id")["soil_moisture"].transform(
    lambda s: s.fillna(s.rolling(7, min_periods=3).mean())
)

这里 limit=3 表示最多连续填补 3 天,超过则保留缺失,交由后续模型或缺失指示变量处理。判断依据是:连续缺失超过 3 天时,线性插值的可信度快速下降,宁可保留缺失信息也不应伪造趋势。

二、异常值:结合农学常识做双重判定

统计方法能发现“离群”,但未必是“错误”。例如某地块产量突然翻倍,可能是统计口径变化,也可能是真实高产。因此异常检测应采用“统计规则 + 业务阈值”双重判定。常用统计规则包括基于四分位距的 IQR 法和基于标准差的 Z 分数法;业务阈值则来自农学常识,如日均温超出 -10℃~45℃、单日降水量为负、产量超过历史最高值的 1.5 倍等。

def flag_outliers(s, k=1.5):
    q1, q3 = s.quantile([0.25, 0.75])
    iqr = q3 - q1
    low, high = q1 - k * iqr, q3 + k * iqr
    return (s < low) | (s > high)

df["yield_outlier"] = df.groupby("plot_id")["yield"].transform(flag_outliers)
df["temp_outlier"] = (df["temp"] < -10) | (df["temp"] > 45)
df["rain_outlier"] = df["rain"] < 0

检测到异常后不建议直接删除,而是先标记、再人工或规则复核。对确认错误的记录,可用同地块相邻日均值替换;对无法确认的,保留原值但增加异常标记列,让模型自行学习其影响。

三、重复数据:按业务主键去重

重复往往源于多次导出或传感器重传。去重不能简单调用 drop_duplicates(),而要先定义业务主键。对日尺度数据,主键通常是“地块编号 + 日期”;若同一天存在多条记录,应保留采集时间最新的一条,而不是任意第一条。

df = df.sort_values("collected_at")
df = df.drop_duplicates(subset=["plot_id", "date"], keep="last")

去重后应核对行数变化,并与预期天数比对。例如某地块应有 180 天记录,去重后若只剩 150 行,说明存在真实缺失而非重复,需要回到缺失值环节处理。

四、时间序列对齐:以统一日历为骨架

多源数据的时间粒度不同,对齐是最后也是最关键的一步。做法是先构造完整日期骨架,再让各数据源左连接到骨架上,最后按需重采样。下图展示了这一对齐过程的结构关系:

多源数据 → 统一日历骨架 → 对齐结果 气象站(逐日) temp / rain / 日期 土壤传感器(6h) soil_moisture / 时间戳 人工测产(收获期) yield / 地块编号 完整日期骨架 plot_id × 日期 2023-04-01 2023-04-02 … 2023-09-30 共 183 天 按地块展开 对齐后宽表 date temp soil yield 04-01 18.2 0.31 — 04-02 19.0 0.29 — … 09-30 14.5 0.22 6.8 收获期前 yield 为空,属正常 传感器 6h 数据先聚合为日均

实现上,先用 pd.date_range 生成骨架,再逐源合并。传感器 6 小时数据需先按日聚合为日均值,再参与对齐;人工测产只在收获期有值,对齐后其余日期为缺失,这是正常现象,不应填充。

plots = df["plot_id"].unique()
skeleton = pd.MultiIndex.from_product(
    [plots, pd.date_range("2023-04-01", "2023-09-30", freq="D")],
    names=["plot_id", "date"]
).to_frame(index=False)

soil_daily = (soil.groupby(["plot_id", soil["ts"].dt.date])
                  .agg(soil_moisture=("soil_moisture", "mean"))
                  .reset_index().rename(columns={"ts": "date"}))
soil_daily["date"] = pd.to_datetime(soil_daily["date"])

aligned = (skeleton
           .merge(weather, on=["plot_id", "date"], how="left")
           .merge(soil_daily, on=["plot_id", "date"], how="left")
           .merge(yield_df, on=["plot_id", "date"], how="left"))

对齐完成后应做三项检查:行数是否等于“地块数 × 天数”;关键气象列的缺失率是否在可接受范围(例如低于 5%);收获期产量是否恰好落在骨架日期内。若行数偏多,通常是主键未去重;若偏少,则是骨架日期范围与数据实际范围不一致。完成这些检查后,数据才具备进入特征工程与建模阶段的基本条件。