农业数据挖掘中的特征工程:从原始气象数据到可建模特征
原始气象与土壤数据往往带着缺失、量纲混乱和季节性偏差,直接丢给模型效果很差。本文记录从数据清洗到特征构造的完整流程,以及一次让模型 R² 提升 0.14 的关键改造。
- 特征工程
- 数据挖掘
- scikit-learn
- 农业数据
这是一个作物产量预测的项目。数据来自公开的气象与土壤数据集,目标是预测单位面积产量。模型从 SVM 换成 MLP 又换成梯度提升,R² 始终卡在 0.6 附近——问题不在模型,而在喂进去的特征。
问题:原始数据的三个陷阱
缺失不是随机的
气象站的数据缺失有明显的季节性。生长季(4 月到 9 月)设备维护频繁,缺失率反而更高:
import pandas as pd
df = pd.read_csv("data/weather_raw.csv", parse_dates=["date"])
missing = df.isna().mean().sort_values(ascending=False)
print(missing[missing > 0])
# 生长季(4-9 月)的缺失率远高于非生长季
df["is_growing"] = df["date"].dt.month.between(4, 9)
print(df.groupby("is_growing")["precipitation"].apply(lambda s: s.isna().mean()))如果直接用全局均值填充,等于把生长季的信息抹平——而生长季恰好是影响产量最关键的时段。
量纲不统一
温度用摄氏度、降水用毫米、日照用小时、土壤 pH 无量纲。距离类模型(SVM、KNN)对量纲极其敏感,一个 02000 的降水特征会把 01 的 pH 特征完全压住。
累计量与时点量混在一起
降水是累计量(一段时间内的总和),温度是时点量(某个时刻的观测值)。两者不能用同一套统计方式处理。
解决方案
第一步:按生长季分组填充
def impute_by_season(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:
"""
对气象列按「生长季 / 非生长季」分组,用组内中位数填充。
相比全局均值,这样能保留季节性差异。
"""
df = df.copy()
df["is_growing"] = df["date"].dt.month.between(4, 9)
for col in cols:
# 先按站点 + 生长季分组填充,最贴近该站点当季的典型值
grouped = df.groupby(["station_id", "is_growing"])[col]
df[col] = df[col].fillna(grouped.transform("median"))
# 站点内依然全空的列,再退回全局中位数兜底
df[col] = df[col].fillna(df[col].median())
return df
weather_cols = ["temperature", "precipitation", "sunshine_hours", "humidity"]
df = impute_by_season(df, weather_cols)第二步:构造有物理意义的衍生特征
这是提升最大的一步。原始数据只有「日平均温度」,但作物真正关心的是积温——生长期内有效温度的总和:
import numpy as np
def add_agronomic_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy().sort_values(["station_id", "date"])
# 有效积温:日平均温度高于基温(10℃)的部分累加
BASE_TEMP = 10
df["effective_temp"] = (df["temperature"] - BASE_TEMP).clip(lower=0)
group = df.groupby(["station_id", "year"])
df["gdd"] = group["effective_temp"].cumsum() # 生长度日
df["cum_precip"] = group["precipitation"].cumsum() # 累计降水
# 极端天气计数:高温日、暴雨日
df["heat_days"] = group["temperature"].transform(lambda s: (s > 35).cumsum())
df["heavy_rain_days"] = group["precipitation"].transform(
lambda s: (s > 50).cumsum()
)
# 水分平衡:降水与蒸散的差值,反映水分是否充足
df["water_balance"] = df["cum_precip"] - df["potential_et"]
return df第三步:按生长阶段聚合
把日频数据聚合成几个关键生育期的统计量,比把 200 天的原始序列直接丢给模型有效得多:
STAGES = {
"sowing": (4, 5), # 播种期
"tillering": (6, 6), # 分蘖期
"jointing": (7, 7), # 拔节期
"filling": (8, 9), # 灌浆期
}
def aggregate_by_stage(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
rows = []
for (station, year), sub in df.groupby(["station_id", "year"]):
record = {"station_id": station, "year": year}
for stage, (start, end) in STAGES.items():
stage_df = sub[sub["date"].dt.month.between(start, end)]
if stage_df.empty:
continue
# 每个生育期取均值与极值,既反映水平也反映波动
record[f"{stage}_gdd"] = stage_df["gdd"].max()
record[f"{stage}_precip"] = stage_df["precipitation"].sum()
record[f"{stage}_temp_mean"] = stage_df["temperature"].mean()
record[f"{stage}_temp_max"] = stage_df["temperature"].max()
record[f"{stage}_sunshine"] = stage_df["sunshine_hours"].mean()
rows.append(record)
return pd.DataFrame(rows)第四步:流水线封装
把标准化和建模串成同一个 Pipeline,避免「先划分数据集再标准化」这类数据泄漏:
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import cross_val_score
numeric_features = X.select_dtypes(include=np.number).columns.tolist()
preprocess = ColumnTransformer([
("num", Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
]), numeric_features),
])
model = Pipeline([
("preprocess", preprocess),
("regressor", GradientBoostingRegressor(
n_estimators=400,
learning_rate=0.05,
max_depth=3,
random_state=42,
)),
])
# 交叉验证:标准化只在训练折上拟合,测试折不参与,杜绝数据泄漏
scores = cross_val_score(model, X, y, cv=5, scoring="r2")
print(f"R² = {scores.mean():.3f} ± {scores.std():.3f}")实验:每一步带来多少提升
我在同一份数据、同一个模型上逐步加入特征,观察交叉验证 R² 的变化:
| 特征集合 | 特征数 | 交叉验证 R² |
|---|---|---|
| 原始气象 + 全局均值填充 | 6 | 0.61 |
| 分季节填充 | 6 | 0.64 |
| + 积温 / 累计降水 | 10 | 0.71 |
| + 极端天气计数 | 12 | 0.73 |
| + 生育期聚合(完整方案) | 26 | 0.75 |
提升最大的两步是「分季节填充」(+0.03)和「积温特征」(+0.07)。后者不需要更多数据,只需要领域知识。
踩坑记录
- 标准化顺序错误。最初我在划分训练集之前就对整个数据集做了
fit_transform,交叉验证分数虚高到 0.85,上线后直接跌到 0.6。标准化必须放在 Pipeline 里,让它在每一折上独立拟合。 - 区域特征泄漏。把
station_id当数值特征放进了模型,而训练集和测试集是按年份划分的、站点有重叠——模型实际上在背站点,不是在学气象规律。 - 降水做对数变换前要加一。有大量 0 值,直接取对数会产生
-inf。
总结
在那个项目里,把模型从 SVM 换成梯度提升只带来约 0.02 的提升,而分季节填充 + 积温特征带来了 0.14。特征工程的上限远高于换模型,尤其在有明确领域知识的场景里。下一步我准备把生育期的划分从固定月份改成按积温阈值动态判定,这应该更符合作物的实际生长节奏。