☰
GEDI与Sentinel-2结合随机森林:森林地上生物量密度制图全流程
2026/9/29 15:12:41 网站建设 项目流程

简介:针对遥感与机器学习交叉应用场景,这份PDF指南演示了如何整合GEDI、Sentinel-2与随机森林模型实现地上生物量密度(AGBD)建模。以Mafungautsi森林保护区为测试区,内容覆盖Google Earth Engine账户初始化与认证、Sentinel-2合成影像创建、光谱指数计算、SRTM高程和坡度数据加载、训练/测试样本准备、随机森林回归训练、模型性能检查以及最后的结果预测与可视化。除了逐步代码实现,还系统讲解了GEDI L4A数据集的生成原理、RH指标含义及各步骤背后的技术逻辑,并专门讨论过拟合现象和提升泛化能力的策略。适合具备一定遥感与Python基础、关注森林碳储量和生产力评估的研究人员与工程师。压缩包共1个文件,为PDF文档,大小141KB,结构完整,可边读边练。目前已有101人学习下载,是快速上手多源遥感数据生物量建模的简洁参考。

1. GEDI、Sentinel-2与随机森林:把森林碳储变成一块能“查”的密度图

把 GEDI、Sentinel-2 和随机森林这三个词放在一起,通常是这样一个场景:手里有 GEDI 激光雷达点测的冠层高度波形,有 Sentinel-2 光学影像的连续覆盖,却缺一张逐像元的地上生物量密度(AGBD,单位 Mg/ha)分布图。GEDI 的脚印直径约 25 米,沿轨道采样,密度高但不成面;Sentinel-2 有 10 米到 20 米的空间分辨率,但光学信号在茂密森林里容易饱和。随机森林在这里扮演的是“融合器”:把 GEDI 的垂直结构、Sentinel-2 的光谱与纹理、地形辅助数据一起回归到生物量密度,再用训练好的模型外推到整个影像覆盖区。这套流程适合做碳汇监测、林业二类调查预判、生态学研究生实验,也适合给没有机载 LiDAR 的团队提供一条低成本替代路径。本文用 Python 从数据准备讲到最后出图,并把你最可能翻车的几个点一次说透。

2. 数据准备:让 GEDI 足迹和 Sentinel-2 像元对齐,一步一坑

2.1 用合成样本先跑通:结构与真实 GEDI 对齐的最小数据集

新手拿到 GEDI L2A 的真实 HDF5 文件后,第一关就卡在“数据怎么读、单位是什么、字段怎么对”。我的建议是先别碰真实数据,用合成数据把“足迹级特征 → 随机森林 → 密度图”这条链路跑通,确认代码逻辑没问题,再回头处理真实文件的格式问题。

下面的脚本合成 2000 个 GEDI 足迹级别的样本,包含冠层高度 rh98、坡度、Sentinel-2 波段特征以及作为训练标签的 AGBD。字段命名和真实数据保持一致,方便之后直接替换数据源。

import numpy as np import pandas as pd rng = np.random.default_rng(42) n = 2000 # GEDI L2A 的 rh98:冠层高度第98百分位数,单位米,真实范围约 0~45 rh98 = rng.beta(2, 5, n) * 45 # 模拟 Sentinel-2 20m 格网上提取的波段均值 # 冠层越密,红光越低,近红外越高,这是植被光谱的基本趋势 b4 = 0.04 - 0.0002 * rh98 + rng.normal(0, 0.005, n) b8 = 0.30 + 0.001 * rh98 + rng.normal(0, 0.01, n) ndvi = (b8 - b4) / (b8 + b4 + 1e-6) # GEDI 脚印范围内的平均坡度,单位度 slope = rng.uniform(0, 35, n) # 地上生物量密度标签,单位 Mg/ha,参考值域约 20~350 agbd = 11.5 * rh98**0.62 + 0.4 * slope + rng.normal(0, 8, n) # 模拟轨道编号:每 400 个足迹属于同一条轨道,后面 GroupKFold 要用 track_id = np.arange(n) // 400 df = pd.DataFrame({ "rh98": rh98, "b4": b4, "b8": b8, "ndvi": ndvi, "slope": slope, "agbd": agbd, "track_id": track_id, }) print(df.describe())

代码里的rh98**0.62是故意模拟的生物量异速生长关系:冠层高度与生物量是亚线性关系,30 米高的林子对应约 100~120 Mg/ha,这与温带森林的常见量级一致。b4和b8的合成公式用了“冠层越密、红光吸收越强、近红外反射越高”的物理趋势,这样随机森林能从中学到真实光谱与结构的相关性,而不是纯噪声。

需要注意,真实 GEDI 数据里一个足迹对应一条记录,Sentinel-2 特征是从足迹所在像元及其邻域聚合出来的,而不是单像素取值。合成数据阶段可以不管空间关系,但真实数据处理时这一点是必修课,后面 2.3 节专门讲。

2.2 真实 GEDI L2A 读取:HDF5 字段、单位与质量筛选

真实 GEDI L2A 产品是 HDF5 格式,打开后你会看到BEAM0000到BEAM1011这一组 beam 组名。注意并非所有 beam 都有有效数据,还需要通过质量标志筛选。

import h5py with h5py.File("GEDI02_A_20200723154000_XXXXX_XXXXX_XXXX_XX002000T.H5", "r") as f: beams = [k for k in f.keys() if k.startswith("BEAM")] print(beams)

筛选之后逐个 beam 读取。以下脚本读取经纬度、rh98、质量标志和灵敏度,返回一个干净的 DataFrame:

def read_gedi_beam(path, beam="BEAM0000"): with h5py.File(path, "r") as f: g = f[beam] # latitude_ms / longitude_ms 单位是 10 毫角秒并不是毫秒 # 换算:1 度 = 3,600,000 毫角秒 lat = g["geolocation/latitude_ms"][:] / 3600000.0 lon = g["geolocation/longitude_ms"][:] / 3600000.0 # rh 字段形状为 (shot_count, 100),第 99 个槽位对应 rh98 # 不要取 rh100,波形尾部噪声会把高度抬得很高 rh98 = g["rh"][..., 98] qf = g["quality_flag"][:] degrade = g["degrade_flag"][:] sens = g["sensitivity"][:] df = pd.DataFrame({ "lon": lon, "lat": lat, "rh98": rh98, "quality_flag": qf, "degrade_flag": degrade, "sensitivity": sens, }) # 质量筛选:flag=1, 非降级, 灵敏度高于 0.95 df = df[(df["quality_flag"] == 1) & (df["degrade_flag"] == 0) & (df["sensitivity"] > 0.95)] return df.drop(columns=["quality_flag", "degrade_flag", "sensitivity"])

这里有几个字段容易读错。rh是形状为(shot, 100)的波形分位数数组,rh[..., 98]是第 98 个分位数的冠层高度,通常作为森林冠层高度的代表值,而不是直接用rh[..., 100]或rh[..., -1]。latitude_ms的ms是 milliarcsecond,不是 millisecond,单位换算错误会让坐标偏出几百度。这些字段的取值和单位在 GEDI L2A 用户指南里都有明确说明,处理前最好对照一遍。

另外,GEDI 原始坐标是 WGS84 经纬度。之后和 Sentinel-2 对齐时,最好把足迹投影到与影像一致的 UTM 坐标系,不要在经纬度下直接做缓冲区分析,否则不同纬度上的距离变形会直接影响 3×3 窗口的聚合结果。

2.3 Sentinel-2 预处理:20m 重采样、云掩膜与特征聚合

Sentinel-2 的 10 米波段(B2、B3、B4、B8)和 20 米波段(B5、B6、B7、B8A、B11、B12)分辨率不同。GEDI 脚印直径约 25 米,和 20 米像元尺度更匹配,所以常见的做法是把所有波段统一重采样到 20 米。

下面这段代码用 rasterio 把 10 米波段降采样成 20 米:

import rasterio from rasterio.enums import Resampling src_path = "S2_B8_10m.tif" dst_path = "S2_B8_20m.tif" with rasterio.open(src_path) as src: data = src.read( 1, out_shape=(int(src.height // 2), int(src.width // 2)), resampling=Resampling.average, ) profile = src.profile profile.update( height=data.shape[0], width=data.shape[1], transform=src.transform * src.transform.scale(2, 2), ) with rasterio.open(dst_path, "w", **profile) as dst: dst.write(data, 1)

out_shape的高宽除以 2,对应 10 米到 20 米的降采样倍数,transform.scale(2, 2)同时把像元尺寸变为原来的两倍。重采样方法选average,因为生物量密度是连续量,取均值比最近邻更平滑,也比双线性插值更抗噪声。如果源数据是 10 米和 20 米混合输入,建议先把所有波段统一到 20 米再合成特征栈,避免 RF 在特征间学到分辨率差异造成的伪影。

云掩膜是整个流程里最容易被低估的一步。我常用的做法是基于 Scene Classification Layer(SCL)做白名单过滤,只保留 SCL 值等于 4(植被)和 5(非植被覆盖)的像元。这个规则偏保守,但能最大程度避免云、云影和薄雾污染训练样本。掩膜之后的统计数据可以作为特征矩阵的一部分,比如“足迹周围 3×3 窗口内有效像元比例”,低于 70% 的足迹直接丢弃。

足迹与影像对齐时,建议先建特征栈,再按坐标提取。以下代码演示真实场景下的提取逻辑:

import geopandas as gpd import rasterio stack_path = "s2_20m_stack.tif" gdf = gpd.read_file("gedi_footprints.gpkg") with rasterio.open(stack_path) as src: gdf = gdf.to_crs(src.crs) rows, cols = rasterio.transform.rowcol( src.transform, gdf.geometry.x.values, gdf.geometry.y.values, ) band_data = src.read() # 假设波段顺序是 B4, B8, B11, NDVI b4_vals = band_data[0, rows, cols] b8_vals = band_data[1, rows, cols]

这里rowcol返回的是整数行列号,对应每一个足迹中心所在的像元。如果要做 3×3 邻域聚合,需要把rows、cols扩展成邻域坐标再取均值,实际项目中特征向量通常包含“中心像元值 + 邻域均值 + 邻域标准差”三组,这样既有空间代表性又不会让特征维度爆炸。聚合时重点关注窗口是否越界,靠影像边缘的足迹要么补边缘值,要么直接剔除,否则模型在边缘附近会出现条带预测。

3. 随机森林建模:从光谱饱和到垂直结构,特征怎么设计才靠谱

3.1 特征设计:光学波段在茂密森林里为什么会饱和

Sentinel-2 的 NDVI 在低生物量区域(<50 Mg/ha)与 AGBD 有较好的线性关系,但冠层覆盖度接近 100% 之后,NDVI 的变化开始趋平,这就是常说的“光学遥感饱和问题”。这时候 GEDI 的垂直结构指标反而成为主导变量:rh98 直接描述冠层高度,rh50、rh75 等分位数描述冠层内部垂直分布,这些信息与生物量的机械关系比光谱更强。

因此特征矩阵至少要包含三类:

特征类别具体字段作用
GEDI 垂直结构rh50、rh75、rh98打破光学饱和,提供冠层高度与垂直分布
Sentinel-2 光谱B4、B8、B11、B12 均值与标准差提供树种、郁闭度、水分差异
衍生指数与地形NDVI、NDMI、坡度、坡向校正地形阴影和水分对反射率的影响

特征不是越多越好。遥感随机森林模型里,特征数翻倍往往带来的是训练时间翻倍和泛化能力下降,而不是精度提升。我一般控制在 10~15 个特征以内。地形数据如果没有现成的 DEM,用elevation的坡度提取替代,不要为了凑特征硬加相关性低的纹理变量。

在特征重要性解释上,随机森林给出的feature_importances_有一定随机性,不要把它当成绝对结论。如果你发现某一次运行 B4 的重要性突然超过 rh98,先检查是不是云掩膜没做干净,而不是急着调整模型。

3.2 随机森林回归的三个必调参数:从默认参数到可用模型

随机森林回归算法在 scikit-learn(sklearn)里的实现非常成熟,默认参数能跑通,但直接用在遥感生物量建模上往往会出现“测试集 R² 高、实际出图花斑”的问题。需要重点调三个参数:

from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import train_test_split features = ["rh98", "rh50", "b4", "b8", "b11", "ndvi", "slope"] X = df[features] y = df["agbd"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) rf = RandomForestRegressor( n_estimators=500, max_depth=25, min_samples_leaf=2, oob_score=True, n_jobs=-1, random_state=42, ) rf.fit(X_train, y_train) print("train R2:", rf.score(X_train, y_train)) print("test R2:", rf.score(X_test, y_test)) print("OOB R2:", rf.oob_score_)

n_estimators取 500 是性价比拐点:400 棵以下时精度随树数增加明显,800 棵以上的提升通常不足 0.01。max_depth设为 25 左右,限制单棵树深度可以防止树对空间噪声过拟合,纯遥感数据上 15~30 都合理,具体取决于样本量。min_samples_leaf这个参数最容易被忽略,它控制叶子节点最少样本数,设为 2~5 可以显著减少预测图中的盐椒噪声。如果预测值经常出现极端值,优先加大min_samples_leaf,而不是调低。

oob_score打开后可以拿到袋外估计 R²,它比测试集 R² 更接近真实泛化表现,因为每个样本的预测来自没参与该样本训练的树。输出里 train R² 接近 1 是正常的,不用紧张;真正要看的是 test R² 和 OOB R² 之间的落差。如果 test 0.85、OOB 0.45,说明数据划分有问题,或者特征里有和标签直接相关的泄漏字段。

3.3 交叉验证不要随机打乱:按轨道分组才是真实预测评估

这是遥感随机森林中最容易被忽视、也最容易让人产生“模型很准”错觉的环节。GEDI 数据沿轨道采集,同一轨道上相邻足迹之间的生物量高度自相关。如果直接train_test_split随机划分,训练集和测试集里各有一半来自同一个轨道,模型实际上在预测“隔壁邻居”,R² 虚高。

正确的做法是用GroupKFold按轨道分组,让同一条轨道的足迹只出现在同一折里:

from sklearn.model_selection import GroupKFold, cross_val_score # 用 2.1 节合成的 track_id 作为分组依据 groups = df["track_id"] cv = GroupKFold(n_splits=5) scores = cross_val_score(rf, X, y, cv=cv, groups=groups, scoring="r2") print("GroupKFold R2: %.3f +/- %.3f" % (scores.mean(), scores.std()))

真实数据里如果拿不到track_id,可以用shot_number // 400近似,因为一条轨道通常有几百个连续 shot。实际踩坑经验是:随机打乱的 5 折交叉验证 R² 可能到 0.83,而按轨道分组后直接掉到 0.57,这个落差才是模型真实泛化水平的镜面。遥感机器学习入门者最容易在这里踩坑——不是模型不行,而是评估方式给了你虚假的信心。

评估时还要注意scoring="r2"对不同生态区的适用性。生物量密度范围越宽,R² 天然越高;范围窄的老龄林即便模型预测误差很小,R² 也可能只有 0.3,这时候补充 RMSE 和 MAE 一起看。如果分组后 RMSE 仍然小于 30 Mg/ha,模型在实际应用中是有价值的,不必只盯 R²。

4. 避坑:GEDI 和 Sentinel-2 生物量建模的 5 个翻车现场

4.1 翻车:rh98 和 AGBD 单位没换算,预测值直接到五位数

现象:训练时各项指标正常,出图后预测值动不动上万 Mg/ha,明显超出物理可能。

原因:GEDI 的rh98单位是米,Sentinel-2 地表反射率有的产品是 0~10000 的整型值。某个步骤漏了反射率除以 10000,或者把 100 个波形分位数当成一个特征向量直接喂进去,RF 就会从错误的量纲关系里学到虚假的放大系数。

解决:建模前先打印df.describe()看各字段值域。Sentinel-2 的反射率如果是uint16范围 0~10000,统一除以 10000;AGBD 标签要确认单位是 Mg/ha 而不是 g/m²。预测完成后对比rf.predict(X_train)的最大值与y.max(),如果预测上限超出训练标签 50% 以上,回去查特征值域。

4.2 翻车:云没掩膜干净,云影区域被认成低生物量沙漠

现象:密度图上大面积出现零值空洞或条纹状低值,且位置与影像上的云影位置重合。

原因:SCL 白名单设置太宽松,把 SCL=8、9 的云像元混进了特征矩阵,或者薄云下垫面的反射率被当成真实地表信号。RF 学到“高亮度像元 → 低生物量”的假规则。

解决:SCL 只保留 4 和 5,云影检测再加一道 NDVI 下限过滤,NDVI 低于 0.15 的植被足迹直接剔除。更稳的做法是统计足迹 3×3 窗口内的有效像元比例,低于 70% 就丢弃。别心疼样本量,被云污染的特征对模型的伤害远大于少几百个训练样本。

4.3 翻车:交叉验证 R² 0.85,换一个地区直接变负数

现象:同一景影像内部验证精度很好,模型搬到相邻区域或另一个生态区后 R² 直接为负。

原因:训练和验证足迹来自同一轨道,空间自相关导致信息泄漏;同时随机森林外推能力很差,新区域的特征值落在训练范围外时,预测会收敛到树节点均值附近,而不是合理外推。

解决:交叉验证用GroupKFold按轨道分组,别用随机KFold。模型外推前,先检查目标区域每个特征的取值范围是否落在训练集范围内,尤其是rh98和波段反射率。如果目标区存在训练集没见过的高度等级或坡度,建议补充采样再训练,或者至少在成果图上标注模型的适用范围。

4.4 翻车:预测图出现负生物量

现象:青壮林区域预测为 -30 Mg/ha 或更低,明显违反物理常识。

原因:min_samples_leaf太小,某些叶子节点的样本均值被极端低值样本拉为负值。当rh98很小且坡度很大时,树拟合出的叶节点预测值可能低于零。这属于过拟合的显性表现。

解决:先加一道“后悔药”式的预测裁剪:

import numpy as np pred = np.clip(rf.predict(X_all), 0, 500)

但裁剪只是止血。检查验证集中负值样本比例,如果超过 1%,把min_samples_leaf从 1 调到 3 或 5 重新训练,再配合n_estimators=500一起观察 OOB 误差。负值比例降到 0.1% 以下才算正常。

4.5 翻车:按图幅分块建模,拼接处出现明显接缝

现象:输出大面积密度图时按 1000×1000 像元分块训练,各块单独训练模型,拼接后块与块之间出现台阶状突变。

原因:每块的数据分布、样本量不同,随机森林学到了不同的特征-标签关系;更隐蔽的一个原因是特征聚合时窗口跨越了分块边界,边界像元的邻域统计量比内部像元少,特征分布不一致。

解决:整个研究区只训练一个全局模型。推理时按块加载预测没问题,但训练、特征聚合必须全局统一。如果内存确实是瓶颈,合理的顺序是:全量采样 → 训练保存 → 分块预测 → 拼接写盘。分块预测的每个块都要使用同一套特征提取函数,尤其是 3×3 邻域窗口在块边界处的填充方式,要保持一致。

5. 验证与落地:用蒙特卡洛方差输出一张带不确定性的 AGBD 图

模型训练完成之后,最常被问的问题是:这张密度图的可靠性是不是只能用一个 R² 表达?其实随机森林自带一个轻量化的不确定性估计工具——森林内部分歧。每棵树的预测结果相当于从不同子空间采样的估计,预测值之间的标准差可以当作逐像元的不确定性指标。

from joblib import load import numpy as np import rasterio model = load("agbd_rf.joblib") with rasterio.open("s2_20m_stack.tif") as src: cube = src.read() h, w = src.height, src.width profile = src.profile X_tile = cube.reshape(cube.shape[0], -1).T # 取前 50 棵树做不确定性估计,不需要全部 500 棵 tree_preds = np.stack([ t.predict(X_tile) for t in model.estimators_[:50] ], axis=0) pred_mean = np.clip(tree_preds.mean(axis=0), 0, 500) pred_std = np.clip(tree_preds.std(axis=0), 0, 120) profile.update(count=1, dtype="float32", compress="lzw") with rasterio.open("agbd_mean.tif", "w", **profile) as dst: dst.write(pred_mean.reshape(h, w), 1) with rasterio.open("agbd_std.tif", "w", **profile) as dst: dst.write(pred_std.reshape(h, w), 1)

pred_std不是严格意义上的统计置信区间,它反映的是模型内部对同一输入的不同判断。标准差高的区域通常对应训练样本稀疏、地形复杂或光谱与结构模式不明确的地方。抽查这些高不确定区域,比单纯报一个总体 R² 更能说明模型的适用边界。

整幅影像直接reshape预测只适合内存充足的场景。我处理的第一个真实项目是 1 亿像元的全幅数据,这样直接展开就把 32GB 内存吃满了,后来改成按block_windows逐块读取、先写大数组再落盘。另一个习惯是:出图后不要只看均值图,把pred_std和云掩膜叠加检查一遍,高不确定性区域正好落在云影或地块边界上,说明特征聚合窗口有偏差,回去修正比以后再补一次标签省事得多。这套流程从合成数据到真实数据、从单点预测到全图出值,每一步都留了校验口,希望帮到你。

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

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

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

立即咨询