☰
GEDI+Sentinel-2+随机森林的森林地上生物量估算Python实践
2026/10/11 1:05:47 网站建设 项目流程

简介:面向遥感与机器学习研究者的Python实操指南,聚焦GEDI、Sentinel-2与SRTM等多源遥感数据,结合随机森林算法完成地上生物量密度(AGBD)建模,并以Mafungautsi森林保护区作为案例,演示从数据准备到结果可视化的完整技术路线。指南覆盖Google Earth Engine账户初始化、Sentinel-2合成影像创建、光谱指数计算、SRTM海拔与坡度数据加载,以及训练与测试数据划分、随机森林模型运行、模型性能评估和AGBD预测可视化等关键环节,步骤完整且原理与代码并重。包体为1个PDF文件,压缩包仅141KB,适合有遥感或机器学习基础、关注森林碳储量与生产力评估的研究人员和工程师。目前已有101人学习下载。教程不仅提供可复现的Python代码,还对GEDI L4A数据生成原理、光谱指数选择、随机森林在环境科学中的应用,以及过拟合与泛化问题作了深入剖析,并给出参数调整和跨区域外推的实用建议,能够帮助读者快速搭建并复用这类多源遥感机器学习工作流。

1. GEDI + Sentinel-2 + 随机森林估算地上生物量密度:这条 Python 流水线能直接落地

把 GEDI 激光雷达的脚印级 AGBD 估值和 Sentinel-2 影像的栅格特征放进同一个随机森林回归模型里,是当前林业遥感里性价比很高的一种做法:激光数据提供真实条带的采样,光学影像提供连续覆盖,机器学习负责拟合两者之间的非线性映射。下文就是这套方案从数据到成图的完整 Python 实现记录,覆盖 GEDI L4A 的 HDF5 提取、Sentinel-2 特征工程、随机森林调参、避坑和空间化预测。它的价值不在算法本身,而在把两个数据源在坐标、尺度、时间上对齐的工程细节——适合正在做生物量或碳汇课题的研究生,也适合遥感技术服务团队用于森林资源调查。跟随下面章节推进,就能用公开数据跑出一张可用的 AGBD 空间分布图。

2. GEDI 脚印提取与 Sentinel-2 预处理:数据口径对齐的三个关键动作

GEDI 与 Sentinel-2 的配对,本质上是让一组“激光样点”和一组“面状光谱”在空间上共用同一套坐标网格。这个环节如果处理不干净,后续模型再漂亮也白搭。

2.1 从 HDF5 里筛出可用脚印:quality_flag、degrade_flag、sensitivity 一个都不能少

GEDI L4A 的 HDF5 文件结构并不复杂,核心字段就那几项:lat_lowestmode、lon_lowestmode是脚印中心坐标,agbd是地上生物量密度估值,单位 Mg/ha,agbd_se是标准误差。真正容易踩坑的是筛选逻辑。

GEDI 脚印不是每一发都能拿来当标签。受云层遮挡、地形坡度、激光能量衰减影响,部分脚印要么没打透冠层、要么信噪比过低。官方质量建议是同时满足quality_flag == 1和degrade_flag == 0,但这两项只是兜底,实际建模时一般还会加两道筛选:sensitivity >= 0.95防止低灵敏度样本混入,agbd_se < 20控制反演不确定性的上限。不同研究区这块可以微调,比如地形起伏大的区域把 sensitivity 阈值降到 0.90,虽然样本量上来了,但噪声也相应变大。

import h5py import numpy as np import pandas as pd def load_gedi_agbd(h5_path, se_threshold=20.0, sensitivity_threshold=0.95): """从 GEDI L4A 提取有效脚印并筛选质量合格的样本。""" with h5py.File(h5_path, 'r') as f: lat = f['lat_lowestmode'][:] lon = f['lon_lowestmode'][:] agbd = f['agbd'][:] agbd_se = f['agbd_se'][:] quality = f['quality_flag'][:] degrade = f['degrade_flag'][:] sens = f['sensitivity'][:] mask = ( (quality == 1) & (degrade == 0) & (sens >= sensitivity_threshold) & (agbd_se > 0) & (agbd_se < se_threshold) ) df = pd.DataFrame({ 'lon': lon[mask], 'lat': lat[mask], 'agbd': agbd[mask], 'agbd_se': agbd_se[mask], 'sensitivity': sens[mask] }) return df df = load_gedi_agbd('GEDI04_A_20211015_xxx.h5') print(df.shape, df.agbd.describe())

代码里有几个细节值得说明。agbd_se > 0是为了排除缺测记录,因为 HDF5 里无效值经常写成 -9999 或 0,直接影响统计。sensitivity阈值是浮点,不同地形要尝试调整。返回值保留agbd_se和sensitivity两份中间信息,方便后续按不同阈值做敏感性分析,不会因为做了一次筛选就丢弃原始信息。

关于时间,GEDI 自带的delta_time需要结合轨道历元换算成 UTC 日期,这一步通常在产品说明文档里有公式。与 Sentinel-2 影像匹配时,把时间差控制在一个月以内,跨物候期的样本混进同一次训练里,后面会提到这是最常见的误差源之一。

2.2 Sentinel-2 波段重采样与裁剪:10m 和 20m 分辨率混叠时的处理

Sentinel-2 L2A 的波段是分层的:B2(蓝)、B3(绿)、B4(红)、B8(近红外)是 10 m,B5、B6、B7、B8A 以及 B11、B12 是 20 m。生物量建模通常希望特征全部对齐到同一网格,否则 30 m 级别的脚印会跨越不同粒度的像元组合,引入不必要的空间口径差异。

我的习惯是把 10 m 做基准,20 m 波段通过双线性插值重采样到 10 m。重采样的顺序必须放在云掩膜之后:先对 L2A 的 SCL 分类结果做去云去影,把非植被和影子像元标记为无效,再进入波段堆叠,这样才能保证插值只在有效区域里传播。对大范围研究区,再用 GEDI 脚印外包矩形外加 2 km 缓冲裁剪,比较省内存。

Sentinel-2 波段中心波长(nm)原始分辨率(m)重采样为
B2 / B3 / B4490 / 560 / 6651010
B88421010
B5 / B6 / B7 / B8A705 / 740 / 783 / 8652010
B11 / B121610 / 21902010

重采样 20 m 波段的核心代码如下:

import rasterio from rasterio.warp import reproject, Resampling def resample_20m_to_10m(src_path, dst_path, ref_band): """以 10m 波段为空间基准,把 20m 波段重采样到同一网格。""" with rasterio.open(ref_band) as ref: profile = ref.profile.copy() profile.update(driver='GTiff', count=1, dtype='float32') with rasterio.open(src_path) as src: with rasterio.open(dst_path, 'w', **profile) as dst: reproject( source=src.read(1), destination=dst.read(1), src_transform=src.transform, src_crs=src.crs, dst_transform=ref.transform, dst_crs=ref.crs, resampling=Resampling.bilinear)

这段代码的可复用点是ref_band:只要先打开一幅已经就绪的 10m GeoTIFF,后续每个 20m 波段都沿用它的 transform 和 crs,便能保证输出栅格完美切入同一网格。如果你用习惯了 ENVI 做 Sentinel-2 预处理,也可以在 ENVI 里先做 Gram-Schmidt 或 bilinear 重采样再导出,效果等价,只是批量处理时脚本化会更省事。另外 L2A 的 SCL 类别里,3(云影)、8(云)、9(卷云)要直接置为无效值,不然这些像元的光谱值会以极高的反射率干扰后续特征提取。

2.3 脚印坐标与影像像元对齐:WGS84 到 UTM 的转换

GEDI L4A 的脚印坐标是 WGS84 经纬度,而 Sentinel-2 L2A 默认是 UTM 投影,EPSG 编号要看所在带区。直接用经纬度去索引 UTM 栅格必出错,这几乎是我见过最多人翻车的第一步。

常见做法是先把 GEDI 点转成 GeoDataFrame,投影到与影像一致的 UTM 坐标系,再提取对应像元:

import geopandas as gpd from shapely.geometry import Point def align_footprints(df, target_epsg): """把 WGS84 坐标转到 Sentinel-2 影像的 UTM 坐标系。""" gdf = gpd.GeoDataFrame( df, geometry=[Point(lon, lat) for lon, lat in zip(df.lon, df.lat)], crs='EPSG:4326') gdf_proj = gdf.to_crs(f'EPSG:{target_epsg}') gdf_proj['proj_x'] = gdf_proj.geometry.x gdf_proj['proj_y'] = gdf_proj.geometry.y return gdf_proj gdf_proj = align_footprints(df, 32650)

target_epsg取值按研究区所在 UTM 带,北半球中纬度一般以 326 开头,后面带号。更稳妥的做法是直接读影像的src.crs.to_epsg()再传给函数,避免手写带号出错。对齐完成后,提取像元值建议用rasterio.sample或rasterio.mask,它们内部会处理精确坐标和像元边界,比手动算行列号靠谱。

到这一步,GEDI 脚印和 Sentinel-2 影像已经在同一空间口径上了,下一步就可以做特征矩阵。

3. 特征工程与样本集构建:把激光脚印变成监督学习数据

这一章的任务是用 Sentinel-2 的光谱特征,为 GEDI 的每一个脚印配一套特征向量,再加上 agbd 作为标签,整理成 sklearn 标准输入格式。

3.1 光谱指数与波段特征:NDVI、EVI 之外还要留什么

原始波段直接当特征用是有效的,但光学影像里的植被覆盖度和叶面积变化,往往在指数上更敏感。常用的组合是:NDVI 反映植被绿度,EVI 削弱土壤背景和大气噪声,NDWI 对植被水分状况敏感,以及 B8A/B4 比值在冠层密集地区比 NDVI 更不容易饱和。在高郁闭度森林里,NDVI 饱和是真实存在的问题,所以一定要保留 EVI 和波段比值型特征。

def compute_indices(b2, b3, b4, b8, b8a, b11): """输入为反射率数组,0-1 或 0-10000 都兼容,但常量需要相应调整。""" eps = 1e-10 ndvi = (b8 - b4) / (b8 + b4 + eps) evi = 2.5 * (b8 - b4) / (b8 + 6.0 * b4 - 7.5 * b2 + 1.0 + eps) ndwi = (b3 - b11) / (b3 + b11 + eps) ratio = b8a / (b4 + eps) return np.stack([ndvi, evi, ndwi, ratio], axis=-1)

这里的参数说明很重要。分母的 eps 是数值稳定项,避免绿度为零时除出 inf。EVI 公式里的常量 1.0 是按反射率 0-1 设计的,如果你直接用 L2A 的 0-10000 整数 DN 值,必须把 1.0 改成 10000,否则 EVI 尺度会漂移,模型训练出来的特征重要性排行也会变。我自己的习惯是统一先换算成 0-1 浮点反射率再做指数计算,这样后续换研究区、换卫星时,特征值的物理意义不会乱。

3.2 邻域统计与地形辅助特征:用多尺度窗口补充信息

单像元的 NDVI 只能反映脚印中心处的一点信息,而生物量在空间上具有明显的尺度效应。我的做法是围绕脚印做 90m 和 270m 的圆形缓冲区,分别提取 NDVI 的均值、标准差和 10 分位数。90m 大致对应 GEDI 脚印尺度,270m 则代表周边森林结构的异质性。加入这些统计量之后,模型的岭部误差通常能压下一个明显的档位。

from rasterstats import zonal_stats import geopandas as gpd import pandas as pd def extract_window_features(gdf_proj, ndvi_path, radii=(90, 270)): """对每个脚印的圆形缓冲区分半径提取 NDVI 统计特征。""" frames = [] for r in radii: buf = gpd.GeoDataFrame( gdf_proj[['footprint_id']], geometry=gdf_proj.geometry.buffer(r), crs=gdf_proj.crs) stats = zonal_stats(buf, ndvi_path, stats=('mean', 'std', 'max', 'p10'), nodata=None) stat_df = pd.DataFrame(stats) stat_df.columns = [f'ndvi_{r}_{c}' for c in stat_df.columns] stat_df['footprint_id'] = gdf_proj['footprint_id'] frames.append(stat_df) merged = gdf_proj.reset_index(drop=True) for f in frames: merged = merged.merge(f, on='footprint_id', how='left') return merged

p10分位数是一个经常被忽略但很有效的特征,它能捕捉缓冲区里林窗或林隙的存在——如果 90m 范围内 p10 明显低于均值,说明这片林分里有空隙,生物量密度通常会偏低。std则反映林分的空间异质性。如果研究区有 SRTM 30m DEM,把高程、坡度也放进特征里,在山区场景下,高程往往能排进特征重要性前三,它和生物量的关系在宏观尺度上非常稳定。

3.3 样本去重与划分:训练集/验证集/测试集怎么分

样本构建的最后一个动作是去重。不能让空间上重叠的脚印同时进训练集和测试集,否则会在估计模型精度时造成信息泄漏。可行的办法是设置 30m 最近邻间距,把间距过近的点随机保留一个,或者直接用 30m 网格对样本做空间约束。

划分训练测试时,我强烈建议按地理空间做分块,而不是纯随机切分。原因是生物量存在空间自相关,相邻地上的 GEDI 脚印高度相关,随机切分会让验证集里混入训练集邻近样本的信息,R² 虚高得离谱:

from sklearn.cluster import KMeans from sklearn.model_selection import train_test_split import numpy as np def spatial_split(points_xy, test_ratio=0.2, n_clusters=10, seed=42): """按地理空间聚簇划分训练/测试集,避免空间自相关引起的信息泄漏。""" km = KMeans(n_clusters=n_clusters, random_state=seed, n_init=10).fit(points_xy) cluster = km.labels_ train_idx, test_idx = train_test_split( np.arange(len(points_xy)), test_size=test_ratio, stratify=cluster, random_state=seed) return train_idx, test_idx

KMeans 聚出来的簇在地理上天然组团,用 cluster 做分层切分,相当于把空间分块逻辑引入了样本划分。这样训练集和验证集各自覆盖不同空间区域,模型在验证集上的得分才更接近真实外推能力。更严谨的做法是第 6 章要讲的 5km 网格空间交叉验证。

4. 随机森林回归建模与超参数调优:从默认值到稳定的遥感反演模型

4.1 随机森林在生物量建模里的优势与超参数含义

随机森林不是唯一选择,但从落地角度讲它有几点突出优势。第一,对特征分布的假设很少,不需要像线性回归那样做严格的正态变换;第二,能处理特征之间的多重共线性,十几个波段和指数同时进模型也不会崩;第三,自带特征重要性,对论文审稿和项目验收都很关键。

在生物量场景里,样本量通常在一万以内、特征在十五到五十这个范围,随机森林训练很快。一个五千样本、三百棵树的数据集,在 8 核机器上几十秒跑完,正常。真正费时间的是网格搜索来回试探。n_estimators 在数百这个量级,树数量没有超大数据集时不会带来质变,但训练时间会线性增长,所以不要盲目堆到几千棵。

参数默认值实际含义生物量场景建议
n_estimators100树的数量200~500,靠时间成本控制
max_depthNone每棵树最大深度10~20,限制深度防过拟合
min_samples_leaf1叶节点最小样本2~5,平滑噪声
max_features1.0每次分裂的特征子集比例sqrt 或 0.7,减少对强特征的依赖

4.2 网格搜索调参与评估指标:R²、RMSE、MAE 的读数

from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import GridSearchCV from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error param_grid = { 'n_estimators': [200, 300, 500], 'max_depth': [10, 15, 20], 'min_samples_leaf': [2, 4, 6], 'max_features': [0.6, 0.8, 'sqrt'] } rf = RandomForestRegressor(random_state=42, n_jobs=-1) grid = GridSearchCV(rf, param_grid, scoring='neg_mean_squared_error', cv=5, n_jobs=-1, verbose=1) grid.fit(X_train, y_train) model = grid.best_estimator_ y_pred = model.predict(X_test) print(f"R² = {r2_score(y_test, y_pred):.3f}") print(f"RMSE = {mean_squared_error(y_test, y_pred) ** 0.5:.3f} Mg/ha") print(f"MAE = {mean_absolute_error(y_test, y_pred):.3f} Mg/ha")

评估指标里,RMSE 是生物量论文最常报的指标,单位 Mg/ha,它把大误差的权重放大了,所以对极端值敏感;MAE 更稳健,反映平均绝对偏差。在森林碳汇研究中,RMSE 控制在 20~40 Mg/ha 通常已接近可用水平,具体要看区域平均生物量基数。比如平均生物量只有 80 Mg/ha 的破碎林区,RMSE 30 就意味着相对误差接近 40%,这时候只报 R² 会掩盖问题。

网格搜索的评分用neg_mean_squared_error,是因为 GridSearchCV 要求评分越大越好,取负号就能兼容。粗调的技巧是先跑n_estimators: [100, 300]、max_depth: [12, 18]这样的大颗粒组合,找到趋势后再细化,直接上全组合网格会在无效区域浪费大量时间。

4.3 模型保存与特征重要性分析

import joblib import pandas as pd joblib.dump(model, 'agbd_rf_model.joblib') loaded_model = joblib.load('agbd_rf_model.joblib') imp = pd.DataFrame({ 'feature': X_train.columns, 'importance': model.feature_importances_ }).sort_values('importance', ascending=False) print(imp.head(15))

模型保存用 joblib,它对 sklearn 对象的序列化支持比 pickle 稳定。特征重要性排序可以直接输出,但一个常见误区是:重要性低不代表特征无用。随机森林在多个强相关特征之间会分摊重要性,比如 EVI、NDVI、B8A/B4 这三个高度相关,重要性可能被摊薄。筛选特征时不能只看重要性排名就砍掉一半,还要看后续验证精度是否下降。我那会儿犯过这个错,砍掉“不重要”的 NDVI 后模型精度掉了,加回来才好。

5. 避坑指南:遥感机器学习里最常见的五个翻车现场

5.1 脚印重叠与样本自相关,R² 虚高得离谱

现象:随机划分训练测试后,R² 显示 0.87,自信满满把模型部署到整幅影像上,结果空间分布图在林地边缘误差明显放大,完全拿不出手。

原因:GEDI 脚印之间最小间隔较短,部分脚印落在彼此缓冲区里,空间上高度相关。随机拆分时,相邻脚印各进一个集合,相当于验证集里混进了训练样本的近邻信息,这叫空间信息泄漏。

解决:用第 3.3 节的空间聚簇划分,更严格的做法按 5km 网格做分块验证,每次留下一整块做验证、其余训练。如果空间交叉验证的 R² 比随机划分低了 0.15 以上,说明原模型存在明显过拟合。

5.2 影像和 GEDI 时间不匹配,打了错位的标

现象:样本里既有 3 月也有 8 月的 GEDI 脚印,却用同一年度 7 月的一景 Sentinel-2 影像做特征,结果模型 RMSE 高出同区正常水平 50% 以上。

原因:落叶林和农田的光谱随物候期大幅起伏,秋季影像上的“变黄”会被模型错读成生物量差异。

解决:把 GEDI 与 Sentinel-2 的观测时间差限制在 30 天以内,或者分物候期建模。做碳汇长时序研究时,最好每年单独建模,不要把所有年份的样本混进一个模型里硬套。

5.3 整幅影像预测时内存爆了,进程直接被 kill

现象:predict 跑了几分钟后终端打印Killed,或者 numpy 直接报 MemoryError。5000×5000×40 的 float32 特征矩阵约 4 GB,随机森林做推理时还会产生中间矩阵,16 GB 内存的机器很容易被压垮。

原因:一次把整幅 Sentinel-2 读进内存再预测,完全没有必要。

解决:按行块分块预测,边读边写,让特征矩阵始终保持在低内存占用状态:

import rasterio import numpy as np def predict_full_raster(model, raster_path, out_path, block_rows=1024): with rasterio.open(raster_path) as src: profile = src.profile.copy() profile.update(count=1, dtype='float32', compress='lzw') with rasterio.open(out_path, 'w', **profile) as dst: for row0 in range(0, src.height, block_rows): rows = min(block_rows, src.height - row0) window = rasterio.window.Window(0, row0, src.width, rows) arr = src.read(window=window).astype('float32') bands, h, w = arr.shape feat = arr.transpose(1, 2, 0).reshape(h * w, bands) pred = model.predict(feat) dst.write(pred.reshape(h, w).astype('float32'), 1, window=window)

核心思路是把影像当作特征仓库,逐块读入预测再写回。block_rows 按内存调整,16 GB 机器上 1024 行、波段数 20 左右的特征矩阵只有几十 MB,非常稳。换到更大测试区时,把 block_rows 降到 512 就行了。

5.4 样本分布偏移:森林区样本多,低值区样本少

现象:模型在森林区验证不错,但一到农田边缘或城市绿地就预测出极高的生物量,图上明显违和。查特征分布才发现,训练样本的 NDVI 大多高于 0.5,低植被覆盖区域根本没喂过多少样本。

原因:GEDI 脚印经过云层和地形筛选后,剩下的可用样本通常集中在森林覆盖区,草地和农田样本极度缺乏,模型在这些区域只能外推。

解决:按 5km 网格做空间分层采样,限制每个网格的样本数量上限,让训练样本的空间分布尽量均匀。如果研究区跨度大,可以考虑把样本按生态区拆分后分别建模,效果通常比统一模型更好。

5.5 坐标漂移导致特征错位:差一个像元,精度掉一截

现象:前后两次跑同一份代码,提取到的特征有细微差异,模型预测结果出现跳动,排查很久找不到原因。

原因:提取栅格值时,个别函数用 round 把 UTM 坐标硬转成行列号,忽略了栅格的原点是左上角那一格,而不是坐标零点。边缘处差一个像元,NDVI 等特征就不同。

解决:统一用rasterio.sample提取点位值,它内部处理坐标和像元边界。自己写行列换算时,先从栅格对象的 transform 里取原点坐标,不能默认原点在图幅左下角。

6. 模型验证与空间制图:把预测结果铺回整个研究区

6.1 空间交叉验证:分数要能过业务和论文审稿

单次 R² 只能说明模型在你这批样本上拟合得好,生物量估算更看重预测陌生位置时准不准。我推荐按 5km×5km 网格做空间交叉验证:把研究区切成方格,每个方格所属样本整体划入训练或验证集,跑 5 折,得到预测值和实测值的散点图。看散点是否围绕 1:1 线,如果系统性偏低或偏高,说明模型存在区域偏差。空间交叉验证的 R² 比随机划分低 0.15 以上时,优先回去检查时间匹配和样本分层,而不是继续调超参数。

6.2 整幅预测与波段顺序检查

预测前必须检查特征顺序。栅格波段读出顺序和训练时特征矩阵的列顺序必须完全一致,否则模型可能算出看似正常但实际错位的图。

import joblib import rasterio import numpy as np model = joblib.load('agbd_rf_model.joblib') with rasterio.open('features.tif') as src: profile = src.profile.copy() profile.update(count=1, dtype='float32') with rasterio.open('agbd_predict.tif', 'w', **profile) as dst: for row0 in range(0, src.height, 1024): window = rasterio.window.Window( 0, row0, src.width, min(1024, src.height - row0)) arr = src.read(window=window).astype('float32') nbands, h, w = arr.shape X_flat = arr.transpose(1, 2, 0).reshape(h * w, nbands) y_pred = model.predict(X_flat) dst.write(y_pred.astype('float32').reshape(h, w), 1, window=window)

输出栅格的单位仍是 Mg/ha。成图后还需要做一道残差空间检查:把预测值和实测值相减,按脚印位置画点,如果残差在河谷或高海拔区成片出现,说明地形特征没抓够,回到第 3 章补 DEM 特征再重训一次。空间交叉验证的散点图和残差分布图,是我判断模型能不能出成果的两道关。

从那以后,我每次换研究区都会先用 5km 空间交叉验证把模型过一遍,再决定是否补特征或重新分层样本,这个习惯帮我挡住了不少无效实验。希望这些操作细节帮到你。

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

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

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

立即咨询