---
title: "Modeling Monthly Soil Surface Water Content"
subtitle: "Random Forest mapping for MODIS tile h18v03 using 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
execute:
echo: true
warning: false
message: false
editor: source
---
## Overview
This notebook documents my solution for the Kaggle competition **Modeling monthly soil surface water content**. 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 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 target and covariate data are described on the [competition page](https://www.kaggle.com/competitions/modeling-monthly-soil-surface-water-content) and in Schrier et al. (2026).
### Additional ERA5 covariates
Monthly total precipitation was obtained from ERA5 monthly averaged reanalysis for 2000--2024. 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 model used the `ranger` implementation 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%"}
## Limitations
1. **Spatial transferability.** Performance differed among spatial folds, and the high importance of `x` and `y` indicates strong reliance on spatial gradients.
2. **Extreme values.** Random Forest predictions were compressed toward the centre of the observed distribution, especially for extremely high SSWC values.
3. **ERA5 resolution.** ERA5 precipitation was substantially coarser than the MODIS grid; therefore, rainfall information introduces broad spatial zones rather than fine local variability.
4. **April geometric temperature.** The April full-grid `geom` layer was incomplete and required median imputation.
5. **Temporal coverage.** Complete maps were restricted to 2001--2024 because EVI and ERA5 inputs ended in December 2024, even though LST rasters extended into 2026.
6. **Uncertainty mapping.** The final products contain point predictions only; pixel-level prediction uncertainty was not estimated.
## 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.