☰
基于土壤数据与机器学习的农作物推荐:随机森林与XGBoost实战
2026/10/10 7:17:06 网站建设 项目流程

简介:面向农业数据分析和机器学习初学者,这份代码资源基于土壤氮、磷、钾等养分含量,实现多种机器学习分类算法,用于农作物种植推荐,可服务于区域种植规划、科学施肥及农业可持续发展相关研究或项目。压缩包共9个文件,其中3个Python脚本承担模型训练与界面逻辑,2个Jupyter Notebook提供分步分析和可视化过程,2个CSV数据集存放训练样本,另有已保存的pkl模型和HTML交互页面,整体仅182KB,覆盖从数据处理、建模到结果展示的完整链路。已有1282人学习浏览,适合希望快速复现农业机器学习流程或开展课设、竞赛实践的开发者和学生。依托下载包中的CSV数据即可重新训练分类模型并导出pkl,HTML页面可直观查看推荐结果;Notebook保留了算法调参与对比的中间过程,便于理解不同特征组合对预测效果的影响。代码结构简单、运行依赖少,稍作修改即可应用于本地土壤数据集,是一份轻量而实用的农业智能推荐参考实现。

1. 农作物推荐不是“看天吃饭”:这份土壤数据+机器学习代码能落到哪一步

农作物推荐听起来像“看天吃饭”的老经验,但当我拿到一份包含pH、有机质、氮磷钾、CEC和上千条地块记录的土壤数据时,靠农艺师手工查表已经排不出优先级——指标一多,规则就开始互相打架。这套基于土壤数据与机器学习算法的农作物推荐代码包,把“这块地适合种什么”做成了标准的监督学习流程:清洗土壤样本、构造作物适宜性标签、用随机森林和XGBoost训练推荐模型,最终输出Top3作物和置信度。适合农业信息化从业者、农学方向研究生,以及想验证“数据能不能替掉一部分经验”的种植决策者。它不解决种子、销路和气候变化问题,但能把一堆化验单变成一张可解释的推荐清单。

2. 从农艺规则到监督学习:为什么这个场景必须上机器学习

2.1 查表式推荐的天花板:多因子交互顾不过来

传统农艺推荐最主流的形态是专家查表:pH在5.5到7.0、有机质大于20g/kg的田块推荐种水稻;pH小于5.0推荐种茶树;速效磷低于10mg/kg则提示先补磷再种需磷作物。每张表只管两三个指标,隐含假设是指标之间相互独立。真实地块几乎都是复合情况:pH偏酸、有机质够但速效磷缺,三条规则同时指向不同作物,冲突最后只能靠人拍板,而且每个专家的口径还不一样。这是规则体系的第一个天花板——组合爆炸。

第二个天花板在数据量。一张规则表只能覆盖有限个典型组合,但土壤数据的连续取值几乎是无穷的。同一pH下,不同质地的缓冲能力不同;同一速效磷含量下,不同作物的敏感度差异很大。这种条件依赖用if-else写到后面根本维护不动,而机器学习天然就是干这个的:把多维输入映射到作物类别的概率分布,不需要人手工枚举全部组合。这也是这份代码包选择监督学习的核心原因——有土壤特征,有打好的作物标签,问题就是一个标准的多分类。

2.2 特征工程:土壤数据到模型输入这一步决定上限

模型的精度上限由特征决定,这句话在这个项目里特别明显。原始土壤检测报告通常包含这些字段,这份代码包按下面的结构读入数据:

字段名含义类型常见范围/单位
ph酸碱度数值3.5~9.5,无量纲
organic_matter有机质数值g/kg,常见0~80
total_n全氮数值g/kg
alkali_hydro_n碱解氮数值mg/kg
available_p速效磷数值mg/kg
available_k速效钾数值mg/kg
cec阳离子交换量数值cmol/kg
sand_pct砂粒含量数值%
clay_pct黏粒含量数值%
field_capacity田间持水量数值%
texture土壤质地类别砂土/壤土/黏土等
field_id地块编号分组ID用来防数据泄漏

光有原始字段还不够。以我做这类数据的习惯,至少会加三个衍生特征:一是氮磷比,碱解氮除以速效磷,很多作物对氮磷比敏感,单一指标正常但比值失衡的情况很常见;二是有机质与CEC的比值,反映土壤的保肥结构;三是pH与速效磷的交互项,酸性土壤里磷容易被固定,单独看速效磷数值会误判。衍生特征加不加,在随机森林上差别不一定巨大,但对XGBoost这类能捕捉交互的模型,收益是实打实的。

2.3 候选算法横向对比:随机森林和XGBoost为什么是首选

把问题定义清楚后,接下来要回答的是选什么算法。我在动手前会把常见候选摆出来过一遍:

算法非线性能力特征重要性中小样本表现调参成本判断
逻辑回归弱系数可解释尚可低只适合线性基线
KNN弱无不稳低特征尺度敏感
SVM(RBF)强无小样本尚可高超参数敏感
决策树强有易过拟合低单树不稳
随机森林强有好中首选基线
XGBoost强有好中高最终主力

两个最终留下的原因:一是这两者在千条级别的样本量上表现稳定,不需要像深度学习那样攒大数据;二是都能直接输出特征重要性,农业场景里领导和农技员都会问“凭什么推荐这个”,有可解释性才好交代。神经网络不是不行,但这份资源的目标是快速落地,数据量也不支持它发挥。

顺带说一句:农作物推荐不能用协同过滤。协同过滤依赖“用户-物品”评分矩阵,靠相似用户或相似物品来推荐,但土壤数据里根本没有用户行为评分,每块地是一条特征记录,属于典型的冷启动场景。所以不要一提到推荐算法就默认是协同过滤,在这类数据上它从一开始就不成立。

3. 数据清洗、编码与标签构造:复现这份代码的第一步

3.1 土壤样本数据结构与清洗规则

拿到原始数据后不要直接建模,第一件事是把数据变成“模型能信”的样子。这份代码包里我按以下步骤处理,你可以在notebook里逐段复现。

import pandas as pd import numpy as np df = pd.read_csv("soil_samples.csv") print("原始规模:", df.shape) print(df.dtypes) # 1) 空行与完全重复行的去重 df = df.drop_duplicates().dropna(how="all") # 2) 范围校验:超出物理意义的值直接过滤 df = df[(df["ph"].between(3.5, 9.5)) & (df["available_p"].between(0, 200)) & (df["organic_matter"].between(0, 80))] # 3) 地块ID非空才保留,否则后面没法做分组验证 df = df[df["field_id"].notna()].reset_index(drop=True) print("清洗后规模:", df.shape)

这段代码的意图有三个:去掉重复记录,避免同一批化验数据被重复计算;对pH、速效磷、有机质做物理范围过滤,化验单偶发抄错位或传感器漂移产生的极端值会被排除;强制保留field_id,因为第4章分组划分要靠它。注意这里的范围基于常见农田监测口径,如果你手里的数据来自大棚基质或盐碱地,上限要按实际情况调整。

3.2 缺失值填充:均值填充省事,但不一定对

土壤化验数据常见的缺失集中在速效磷和CEC,因为这两项需要单独的前处理流程,有时实验室没做完就出报告了。早期版本图省事用全局均值填,后来发现模型在酸性土壤上的推荐明显漂移,原因是pH在农田里往往呈双峰分布,均值落在一个实际很少出现的中间值上。我现在的做法是按土壤质地分组填充中位数。

# 按质地分组填充缺失值,中位数比均值更抗离群点 for col in ["available_p", "cec"]: df[col] = df.groupby("texture")[col].transform( lambda x: x.fillna(x.median()) ) # 仍然缺失的行用全局中位数兜底 for col in ["available_p", "cec"]: df[col] = df[col].fillna(df[col].median())

分组填充的道理是:同一质地下的化学行为更接近,黏土和砂土的CEC、磷吸附特性差异很大,用同一个全局分布填充会抹平这种差异。如果某个字段缺失率超过30%,填充就不可靠了,这种字段我会直接删除而不是硬填。

3.3 类别特征编码、衍生特征与标准化

土壤质地是文本类别,不能直接进模型。用LabelEncoder简单粗暴但有隐患——它会给出0、1、2的顺序关系,而质地本身没有大小顺序。更稳的是用OrdinalEncoder配合categories参数显式指定顺序。

from sklearn.preprocessing import OrdinalEncoder # 按实际颗粒粗细指定顺序:砂土=0,壤土=1,黏土=2 texture_order = [["砂土", "壤土", "黏土"]] enc = OrdinalEncoder(categories=texture_order) df["texture_enc"] = enc.fit_transform(df[["texture"]]) # 三个衍生特征,对应第2章提到的交互信息 df["np_ratio"] = df["alkali_hydro_n"] / (df["available_p"] + 1e-6) df["om_cec_ratio"] = df["organic_matter"] / (df["cec"] + 1e-6) df["ph_p"] = df["ph"] * df["available_p"]

加1e-6是为了避免除以零,磷含量极低的地块在化验单上是真实存在的。数值特征这边,随机森林和XGBoost本身对量纲不敏感,但如果你后面想对比逻辑回归基线,或者用SHAP做解释时希望量纲统一,再做标准化。我一般只对送入线性模型的副本做标准化,树模型那份保持原始值,这样特征重要性解读时能直接对应到mg/kg、g/kg这些农学单位。

3.4 作物适宜性标签:产量分位还是农艺规则

机器学习要学的是“特征→作物是否适宜”,所以先把作物适宜性转成监督标签。两种常见打法:一是专家规则打分,按土壤指标落在适宜区间时计分,总分偏高的一组作为推荐类;二是以历史产量数据为依据,把每个地块产量最高的作物标为正类。第一种适合没有历史产量的新地块,第二种适合有多年种植档案的地块。

# 方式一:规则打分示例(只列三条核心规则) def suit_score(row): s = 0 if 5.5 <= row["ph"] <= 7.0: s += 2 if row["organic_matter"] >= 20: s += 2 if row["available_p"] >= 15: s += 1 return s # 方式二:多年产量比较,取最高产作物作为标签 yield_cols = ["yield_rice", "yield_wheat", "yield_corn", "yield_peanut"] df["crop_label"] = df[yield_cols].idxmax(axis=1).map( lambda x: x.replace("yield_", "") )

标签构造是决定模型上限的一步,也是最容易埋坑的一步。用产量划分标签时,必须确认产量是同一地块、同一施肥水平下的相对比较,而不是不同管理水平的绝对比较。这一点在第5章会专门讲,它直接影响你后面所有指标的成色。

4. 随机森林与XGBoost训练全流程:可直接抄的代码与调参路径

4.1 训练集与测试集划分:地块分组比随机切分更可信

如果直接用train_test_split随机切,同一地块的多条采样记录会同时出现在训练集和测试集里,模型相当于已经“见过”测试地块的信息,验证分数会虚高。正确做法是按field_id分组划分,让同一地块的所有样本只在一边出现。这在sklearn里叫分组划分,用GroupShuffleSplit实现。

from sklearn.model_selection import GroupShuffleSplit feature_cols = [ "ph", "organic_matter", "total_n", "alkali_hydro_n", "available_p", "available_k", "cec", "sand_pct", "clay_pct", "field_capacity", "texture_enc", "np_ratio", "om_cec_ratio", "ph_p" ] X = df[feature_cols] y = df["crop_label"] groups = df["field_id"] gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(gss.split(X, y, groups=groups)) X_train, X_test = X.iloc[train_idx], X.iloc[test_idx] y_train, y_test = y.iloc[train_idx], y.iloc[test_idx] print("训练地块数:", len(set(groups.iloc[train_idx]))) print("测试地块数:", len(set(groups.iloc[test_idx])))

n_splits=1表示只切一次,test_size=0.2表示留出20%地块做最终测试,random_state固定是为了可复现。打印地块数是必须的习惯:确认两边都有足够的地块,而不是恰好把某个乡镇的样地全切到一边去了,否则测试分数没有代表性。

4.2 随机森林基线模型:参数含义与调参顺序

随机森林在这个场景是当基线用的:默认参数就能跑出可用的准确率,而且oob_score可以白拿一个袋外估计,不需要额外划分验证集就能监控过拟合。

from sklearn.ensemble import RandomForestClassifier rf = RandomForestClassifier( n_estimators=300, max_depth=12, min_samples_split=8, min_samples_leaf=4, max_features="sqrt", class_weight="balanced", random_state=42, n_jobs=-1, oob_score=True, ) rf.fit(X_train, y_train) print("OOB准确率:", rf.oob_score_) print("测试集准确率:", rf.score(X_test, y_test))

参数逐个说:n_estimators=300,树够多结果才稳定,再往上对精度提升很小但训练时间线性增长;max_depth=12限制单棵树深度,防过拟合;min_samples_split=8和min_samples_leaf=4约束叶节点最小样本量,对噪声多的土壤数据有效;max_features="sqrt"是分类任务常用设定,避免树之间相关性过高;class_weight="balanced"对类别不平衡做惩罚,防止推荐结果全偏向大宗作物。调参顺序我的习惯是:先定max_depth和min_samples_leaf,再动n_estimators,最后才碰max_features,每次只动一个变量,好定位是哪个改动带来的提升。

4.3 XGBoost进阶:早停、交叉验证与特征重要性

随机森林稳定但精度往往不如梯度提升树。XGBoost在这个项目里专门用来提升少数类作物的召回率,核心是两件事:早停防止过拟合,交叉验证找最优树数。

import xgboost as xgb model = xgb.XGBClassifier( n_estimators=1000, learning_rate=0.05, max_depth=5, subsample=0.8, colsample_bytree=0.8, min_child_weight=3, reg_lambda=1.0, tree_method="hist", eval_metric="mlogloss", random_state=42, ) model.fit( X_train, y_train, eval_set=[(X_test, y_test)], early_stopping_rounds=50, verbose=False, ) print("最优迭代轮数:", model.best_iteration) print("测试集准确率:", model.score(X_test, y_test))

learning_rate=0.05配上限1000棵树,是保守稳妥的组合;早停设50轮,验证集损失连续50轮不降就停,best_iteration记录最优轮数。subsample和colsample_bytree都是0.8,每棵树只用80%的样本和80%的特征,降低树间相关性。min_child_weight=3继续卡叶节点样本量。tree_method="hist"在更大样本量下提速明显,这里习惯性开着。eval_set严格来说应该用从训练集里再切出来的验证集,而不是测试集,样本量超过1500时可以再多切一块,这里为演示直接用了测试集,实际项目里我会拆三路。

提示:不要一上来就全参数GridSearchCV,组合爆炸会跑死机器。我一般先用默认参数跑一遍记录基线,然后固定learning_rate,调max_depth和min_child_weight,再调subsample和colsample_bytree,最后把最优树数反馈给n_estimators。每一步网格里只放一到两个参数。

特征重要性输出是XGBoost在农业场景最值钱的特性之一。训练完把重要性打印出来,告诉农技人员“有机质在推荐里贡献最大”,比给一个黑匣子更容易被接受:

importance = model.feature_importances_ for name, imp in sorted( zip(feature_cols, importance), key=lambda x: -x[1] ): print(f"{name}: {imp:.4f}")

注意feature_importances_默认是weight口径,按分裂次数计数,对连续数值类特征会偏高;要更贴近实际贡献,用permutation importance或SHAP,这个放到第6章。

5. 复现避坑指南:四个让模型“看起来很好用却翻车”的血泪记录

这一章是整份代码包里最值钱的部分。下面四类问题我都真实踩过,现象各有不同,根子都在数据处理或验证策略上。按“现象→原因→解决”逐条写,每条都可以直接对号入座。

5.1 准确率97%但下地就没法用:警惕标签泄漏

现象:测试集准确率97%,把同一套代码放到相邻地块的化验数据上,推荐结果和农艺师判断对不上,种水稻的地被推荐种玉米。

原因:同一地块多点取样后被随机切分,训练集和测试集里都含同一田块的强相关样本,模型学到的是“田块编号级别的记忆”,不是真正的土壤-作物关系。测试分数被数据泄漏注水了,换一个地块立刻现原形。

解决:划分时用GroupShuffleSplit按field_id分组,保证同一地块样本只出现在一侧。还有一个土办法判断有没有泄漏:把field_id当作特征塞进模型,如果准确率暴涨,说明模型在走捷径,特征清单里不能有任何能直接定位到具体地块的编号类字段。

5.2 特征重要性和农学常识冲突:共线性和类不平衡在捣鬼

现象:随机森林输出的重要性里,速效磷垫底,可田间试验明确显示缺磷地块减产明显,模型解释没法向农技员交代。

原因:速效磷与全磷、碱解氮之间存在共线性,贡献被分摊掉了;另外基尼重要性在多分类里偏向样本量大的类别,大宗作物样本多,它的关键特征容易被抬高。

解决:不要只看默认feature_importances_,改用permutation importance或SHAP重要性,训练时开class_weight="balanced"再看排序。如果两种重要性排序差异仍然巨大,优先信农学常识,回头排查特征相关性,而不是硬调模型去迎合排序。

5.3 pH缺失值全局均值填充后推荐整体偏移

现象:用全局均值填充pH后,模型对酸性土壤地块的推荐明显偏向中性土壤作物(小麦、玉米),茶树、马铃薯这类喜酸作物几乎不被推荐。

原因:农田pH分布往往是双峰甚至三峰的,全局均值大约落在6附近,把酸性样本的pH强行拉到中性,模型学到的“酸地块信号”被稀释了,酸性作物的决策边界整体偏移。

解决:按土壤质地分组填充中位数,或直接用KNNImputer利用其他特征估算pH。注意填充后重画一次pH分布直方图,对比填充前后的分布形状,不要只盯均值。分布形状对树模型的影响远大于均值。

5.4 推荐列表永远是大宗作物:类别不平衡没处理

现象:水稻、玉米的召回率都在0.9以上,红薯、花生这类小众作物召回率不到0.2,Top3推荐里永远是大宗作物,小众作物就算地块条件完全适宜也排不进去。

原因:数据集里大宗作物样本占了大头,模型学出的先验概率偏向多数类,少数类即使有明确的适宜信号也压不过先验。

解决:分类器加class_weight="balanced",网格搜索评分用macro F1而不是accuracy。如果少数类样本真的太少(少于100条),SMOTE这类合成方法要慎用,特征维度低时合成样本很容易过拟合;更稳的做法是回去补采少数类地块的历史记录,而不是在现有数据里硬造。

6. 进阶:SHAP解释与一键推理输出,把推荐结果说给农户听

6.1 用SHAP看到每个地块的推荐理由

模型给出推荐还不够,一线用的人会问“为什么是水稻而不是玉米”。用SHAP可以从模型内部拿出每个特征的贡献值,生成单个样本的解释:

import shap explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_test_sample) # 多分类时 shap_values 是列表,取目标作物对应的类别 shap.summary_plot( shap_values[target_index], X_test_sample, feature_names=feature_cols, )

SHAP值有正有负,正的推动模型往该类作物倾斜,负的则相反。我在实际项目里会把SHAP图直接附在推荐报告后面,农户看到“这块地有机质偏低、pH偏酸,所以茶树得分高”这类解释,接受度明显比单纯一个模型结论高。

6.2 把模型封装成推荐函数

训练好的模型不值得放在notebook里吃灰,我习惯用joblib导出,再包一个推理函数,输入一行化验数据就输出Top3作物和置信度:

import joblib joblib.dump(model, "crop_recommender.pkl") def recommend(soil_row, top_k=3): proba = model.predict_proba(soil_row)[0] top_idx = np.argsort(proba)[::-1][:top_k] return [ (model.classes_[i], round(float(proba[i]), 4)) for i in top_idx ]

调用时把一行化验数据按feature_cols顺序拼成数组喂进去,返回的就是带概率的推荐清单。这套代码包里的notebook、预处理函数和模型导出脚本我都整理好了,照着第3章到第6章的顺序跑一遍,你手上任何一份新的土壤化验单都能走完同一条链路。从那以后,我每次接农业ML项目都强制自己先画field_id分布、先查标签来源,再决定划分策略和特征清单,这套流程至少帮我挡掉了三次“准确率好看、落地没法用”的返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询