---
title: "Modeling Monthly Soil Surface Water Content"
subtitle: "Random Forest modeling with MODIS and ERA5 precipitation"
author: "Yuliia Shevchuk"
date: "2026-08-20"
format:
html:
toc: true
toc-depth: 3
number-sections: true
code-fold: true
code-summary: "Show code"
code-tools: true
embed-resources: true
theme: cosmo
fig-cap-location: bottom
bibliography: references.bib
link-citations: true
execute:
echo: true
warning: false
message: false
editor: source
---
## Introduction
This notebook documents my solution for the Kaggle competition **Modeling monthly soil surface water content** [@parente2026]. The objective was to predict monthly soil surface water content (`sswc_m`) for MODIS tile `h18v03` from station observations, spatial coordinates, time, and Earth observation covariates.
The final solution used a Random Forest model with MODIS land-surface temperature and vegetation predictors, spatial and seasonal features, ERA5 monthly precipitation, and precipitation from the preceding month. Model performance was evaluated using five-fold spatial cross-validation in which complete monitoring locations, rather than individual rows, were assigned to folds.
The final submission achieved a Kaggle public leaderboard RMSE of **34.36176**.
```{r}
#| label: setup
#| include: false
library(readr)
library(dplyr)
library(ggplot2)
library(knitr)
fold_metrics <- read_csv(
"tables/era5_spatial_fold_metrics.csv",
show_col_types = FALSE
)
oof_metrics <- read_csv(
"tables/era5_spatial_oof_metrics.csv",
show_col_types = FALSE
)
variable_importance <- read_csv(
"tables/era5_variable_importance.csv",
show_col_types = FALSE
)
model_comparison <- read_csv(
"tables/final_model_comparison.csv",
show_col_types = FALSE
)
observed_monthly <- read_csv(
"tables/observed_monthly_summary.csv",
show_col_types = FALSE
)
observed_annual <- read_csv(
"tables/observed_annual_summary.csv",
show_col_types = FALSE
)
map_statistics <- read_csv(
"tables/sswc_monthly_map_statistics_2024.csv",
show_col_types = FALSE
)
```
## Data
### Competition data
The supplied competition data contained:
- **154,594 training observations** from **610 spatial locations**;
- **73,998 test observations** from **305 spatial locations**;
- monthly observations from January 2001 through December 2024;
- a target variable, `sswc_m`, representing monthly soil surface water content in $\mathrm{cm^3/cm^3} \times 1000$.
The gridded MODIS predictors for tile `h18v03` were obtained from the
published MODIS MOD11A2 data archive [@hengl2026modis].
The original predictors were:
| Variable | Description |
|-----------|-------------------------------------------------|
| `x`, `y` | MODIS Sinusoidal coordinates in metres |
| `dlst` | Monthly mean daytime land-surface temperature |
| `dlst.95` | Monthly daytime LST 95th percentile |
| `nlst` | Monthly mean nighttime land-surface temperature |
| `nlst.95` | Monthly nighttime LST 95th percentile |
| `geom` | Monthly geometric-temperature covariate |
| `evi` | Monthly MODIS Enhanced Vegetation Index |
| `YEARMON` | Observation month and year |
The competition data and covariates are described on the competition
page [@parente2026]. The target soil water content observations originate
from the European station dataset described by @schrier2026.
### Additional ERA5 covariates
Monthly total precipitation was obtained from the ERA5 monthly averaged
reanalysis for 2000--2024 [@hersbach2020]. The ERA5 data were supplied in geographic coordinates (WGS 84) at $0.1^\circ$ resolution. Station coordinates were transformed from the MODIS Sinusoidal projection to the ERA5 coordinate reference system, and precipitation was extracted using bilinear interpolation.
The precipitation variable was converted to monthly millimetres and two features were retained by the final model:
- `rain_mm`: precipitation in the current month;
- `rain_lag1`: precipitation in the preceding month.
```{r}
#| label: era5-feature-example
#| eval: false
days_per_month <- lubridate::days_in_month(era5_dates)
for (i in seq_len(terra::nlyr(era5_rain))) {
era5_rain_mm[[i]] <-
era5_rain[[i]] * 1000 * days_per_month[i]
}
rain_long <- rain_long |>
arrange(station_id, date) |>
group_by(station_id) |>
mutate(rain_lag1 = lag(rain_mm, 1)) |>
ungroup()
```
## Exploratory analysis
### Target distribution
The observed response was concentrated around moderate SSWC values, with a smaller number of very high observations.
{fig-alt="Histogram of observed monthly soil surface water content." width="90%"}
### Observed seasonal pattern
The observations showed a pronounced annual cycle. SSWC was generally higher during winter and lower during late spring and summer.
{fig-alt="Observed monthly seasonality of soil surface water content." width="90%"}
### Spatial distribution
Training and test locations were spatially separated. Consequently, a random row-level validation split would overestimate the ability of the model to predict at new locations.
{fig-alt="Map of training and test point locations." width="80%"}
## Feature engineering
The `YEARMON` field was converted to a date. Year, month, cyclic seasonal terms, and temperature contrasts were then derived.
```{r}
#| label: feature-engineering
#| eval: false
prepare_features <- function(data) {
data |>
mutate(
date = lubridate::ymd(paste0(YEARMON, "-01")),
year = lubridate::year(date),
month = lubridate::month(date),
month_sin = sin(2 * pi * month / 12),
month_cos = cos(2 * pi * month / 12),
lst_difference = dlst - nlst,
lst95_difference = dlst.95 - nlst.95,
mean_lst = (dlst + nlst) / 2,
mean_lst95 = (dlst.95 + nlst.95) / 2,
station_id = paste(round(x, 6), round(y, 6), sep = "_")
)
}
```
The final predictor set contained 17 variables:
```{r}
#| label: final-predictors
#| eval: false
final_predictors <- c(
"x", "y",
"dlst", "dlst.95", "nlst", "nlst.95",
"geom", "evi", "year",
"month_sin", "month_cos",
"lst_difference", "lst95_difference",
"mean_lst", "mean_lst95",
"rain_mm", "rain_lag1"
)
```
Missing predictor values were replaced by medians calculated from the training partition only. For the final model, medians were recalculated from all training observations and then applied to the test data.
## Model and validation
### Spatial cross-validation
Unique station locations were randomly assigned to five folds using a fixed seed. All monthly observations belonging to the same station remained in the same fold. This design tests transfer to spatially unseen locations and reduces spatial information leakage.
For observed values $y_i$ and predictions $\hat{y}_i$, RMSE was calculated as
$$\mathrm{RMSE} = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(y_i-\hat{y}_i)^2}.$$
MAE and $R^2$ were reported as complementary metrics, although Kaggle ranking was based on RMSE.
### Random Forest configuration
The selected Random Forest model used the `ranger` implementation in R
[@wright2017], with:
- 1,000 trees for final training;
- `mtry = 5`;
- `min.node.size = 10`;
- variance reduction as the splitting rule;
- permutation importance;
- non-negative final predictions.
```{r}
#| label: final-model
#| eval: false
set.seed(2026)
final_era5_model <- ranger::ranger(
formula = sswc_m ~ .,
data = final_training_table,
num.trees = 1000,
mtry = 5,
min.node.size = 10,
splitrule = "variance",
importance = "permutation",
seed = 2026
)
```
## Validation results
### Overall out-of-fold metrics
```{r}
#| label: oof-metrics-table
oof_metrics |>
mutate(value = round(value, 3)) |>
kable(
col.names = c("Metric", "Spatial OOF value"),
caption = "Pooled spatial out-of-fold performance."
)
```
The pooled spatial out-of-fold evaluation produced RMSE = **36.01**, MAE = **26.72**, and $R^2$ = **0.453**. The model therefore explained approximately 45% of the spatially held-out variation.
### Variation among spatial folds
```{r}
#| label: fold-metrics-table
fold_metrics |>
mutate(
across(c(RMSE, MAE, R_squared), ~ round(.x, 3))
) |>
kable(
col.names = c(
"Fold", "RMSE", "MAE", "R²", "Validation rows"
),
caption = "Performance in each spatial validation fold."
)
```
{fig-alt="Bar chart of RMSE for five spatial validation folds." width="75%"}
The fold RMSE ranged from approximately 30.4 to 38.7, demonstrating that prediction difficulty varied geographically.
### Observed versus predicted values
{fig-alt="Scatter plot of observed versus predicted SSWC." width="80%"}
The model captured the main gradient but showed regression toward the mean: low observations were often overestimated, whereas extreme high values were underestimated. This is an important limitation for interpreting extreme wetness conditions.
### Effect of ERA5 precipitation
```{r}
#| label: model-comparison-table
model_comparison |>
mutate(mean_RMSE = round(mean_RMSE, 2)) |>
kable(
col.names = c("Model", "Mean spatial CV RMSE"),
caption = "Comparison of the best MODIS-only and ERA5-enhanced models."
)
```
{fig-alt="Comparison of cross-validation RMSE with and without ERA5 rainfall." width="75%"}
Adding ERA5 precipitation reduced mean spatial CV RMSE from approximately 36.7 to 35.9, an improvement of about 0.8 RMSE units (approximately 2.2%).
## Variable importance
{fig-alt="Horizontal bar chart showing Random Forest variable importance." width="85%"}
The northing coordinate (`y`) was the most influential predictor, indicating a strong latitudinal moisture gradient within the tile. `geom`, upper daytime temperature (`dlst.95`), easting (`x`), mean daytime temperature, and current-month rainfall were also influential. The previous-month rainfall contributed less than current rainfall but was retained because the combined rainfall feature set improved spatial validation.
Spatial coordinates are useful predictive covariates, but their high importance also indicates that part of the model performance is based on broad spatial gradients rather than purely process-based relationships.
## Continuous monthly mapping
### Raster preparation
Continuous maps were generated from full-grid MODIS rasters for tile `h18v03`. Monthly daytime and nighttime mean and 95th-percentile LST rasters, EVI, and monthly geometric temperature were aligned to the 926.6 m MODIS grid. ERA5 precipitation was projected from WGS 84 to the MODIS Sinusoidal projection using bilinear interpolation.
The final maps used a constant land mask containing **728,588 pixels**, ensuring that monthly summary values were calculated for the same spatial support.
The supplied April geometric-temperature raster contained only 32 valid pixels. Missing April `geom` values were therefore replaced using the training-data median, consistently with the model preprocessing procedure. This limitation should be considered when interpreting the April map.
### December 2024 map
{fig-alt="Continuous raster map of predicted soil surface water content for December 2024." width="80%"}
### Monthly maps for 2024
{fig-alt="Panel of twelve predicted monthly soil surface water content maps for 2024." width="100%"}
### Predicted seasonality
{fig-alt="Line chart showing predicted monthly soil surface water content during 2024." width="90%"}
```{r}
#| label: map-statistics-table
map_statistics |>
select(YEARMON, minimum, maximum, mean, valid_pixels) |>
mutate(
across(c(minimum, maximum, mean), ~ round(.x, 2))
) |>
kable(
col.names = c(
"Month", "Minimum", "Maximum", "Mean", "Valid pixels"
),
caption = "Summary statistics for the 2024 continuous prediction maps."
)
```
The wettest mapped month by spatial mean was January (158.27), closely followed by December (158.02). The lowest spatial mean occurred in May (114.26). The difference between the wettest and driest monthly means was approximately 44.01 units, and the annual mean of monthly spatial means was 136.75.
## Kaggle submission
The final model was trained on all available training observations and used to predict the 73,998 rows of `test.csv`. Predictions were written in the same order as `sample_submission.csv`, checked for missing and negative values, and saved as `submission_era5_candidate.csv`.
```{r}
#| label: submission-example
#| eval: false
submission_era5_candidate <- sample_submission |>
mutate(sswc_m = pmax(final_prediction, 0)) |>
select(ID, sswc_m)
write_csv(
submission_era5_candidate,
"submission_era5_candidate.csv"
)
```
{fig-alt="Screenshot of the successful Kaggle submission and public score." width="90%"}
## Model development and tested configurations
Several model configurations and predictor sets were tested during the
hackathon. The initial baseline was a Random Forest model based on the
original MODIS predictors, spatial coordinates and temporal features.
The original predictors included:
- `x` and `y` spatial coordinates;
- daytime land surface temperature (`dlst`);
- nighttime land surface temperature (`nlst`);
- upper 95% temperature quantiles (`dlst.95` and `nlst.95`);
- geometric temperature (`geom`);
- Enhanced Vegetation Index (`evi`);
- month and year information.
Additional temporal and temperature-derived features were created:
- sine and cosine transformations of the month;
- difference between daytime and nighttime LST;
- difference between the daytime and nighttime 95% LST quantiles;
- mean daytime and nighttime LST;
- mean of the corresponding 95% LST quantiles.
Different Random Forest configurations were evaluated by changing `mtry`
and `min.node.size`. The best MODIS-only configuration produced a mean
spatial cross-validation RMSE of approximately 36.7.
ERA5 monthly total precipitation was then added to the predictor set. Two
rainfall configurations were compared:
1. current-month rainfall and rainfall from the previous month;
2. current-month rainfall, one- and two-month rainfall lags, and the
three-month cumulative rainfall.
Both ERA5 configurations produced a mean spatial cross-validation RMSE of
approximately 35.8. The simpler configuration containing current-month
rainfall and a one-month lag was selected because it achieved similar
performance with fewer predictors.
The final Random Forest configuration used:
- 1,000 trees;
- `mtry = 5`;
- `min.node.size = 10`;
- variance-based splitting;
- permutation variable importance.
The final model was trained using the complete training dataset and was
used to create `submission_era5_candidate.csv`.
## Limitations and possible improvements
Several additional environmental predictors were considered during model
development but were not included in the final Kaggle submission.
Copernicus CLMS soil moisture data were investigated as a potentially
important predictor. However, their temporal coverage, raster structure
and missing values required additional preprocessing and validation.
Because of the limited hackathon time, these data were not included in
the final model.
Catchment area from GEDTM30 was also considered as a static topographic
predictor. Although the raster was prepared and values were extracted,
its contribution was not fully evaluated using the same five-fold spatial
cross-validation procedure. Therefore, it was excluded from the final
submission to avoid adding an unvalidated predictor.
Atmospheric water vapour was suggested as another potentially useful
covariate. It was not tested in the final workflow because of time and
data-processing constraints.
The model has several additional limitations:
- ERA5 precipitation has a coarser spatial resolution than the MODIS
predictors. Resampling ERA5 to the MODIS grid can create visible
block-shaped spatial patterns.
- Spatial coordinates, particularly `y`, had high variable importance.
This suggests that the model partly learned broad geographical
gradients rather than only physical relationships.
- Random Forest predictions tend to move extreme observations towards
the average. Consequently, very high soil water content values were
often underestimated.
- White areas in the prediction maps represent pixels with missing or
invalid predictor data and should not be interpreted as zero soil
moisture.
- The monthly maps were produced for 2024 because it was the most recent
complete year available for all required raster predictors.
- Model performance may differ in regions with limited station coverage.
- The public Kaggle score and spatial cross-validation RMSE were calculated
using different validation observations and are therefore not directly
comparable.
Future improvements could include fully validated CLMS soil moisture,
catchment area, atmospheric water vapour and other terrain or soil
properties. Gradient boosting models or ensembles could also be compared
with Random Forest using the same spatial validation folds.
## Conclusions
This project produced a complete spatial-temporal workflow for monthly soil surface water content prediction across MODIS tile `h18v03`. A Random Forest model combining MODIS temperature and vegetation data, coordinates, seasonal features, and ERA5 rainfall achieved a mean spatial CV RMSE of approximately 35.9 and a Kaggle public RMSE of 34.36176.
ERA5 precipitation produced a modest but consistent improvement over the MODIS-only model. The final continuous maps reproduced a clear seasonal cycle, with higher mean SSWC during winter and lower values in late spring and summer. The results also demonstrate the importance of spatially structured validation and input-raster quality control in Earth observation machine-learning workflows.
## References