H2O AutoML developer on retainer: random k-fold CV on time-ordered data leaks future into training — val_RMSE 0.023 (leaky) collapses to test_RMSE 0.41 on held-out 30 days — 3 weeks — H2O AutoML developer on monthly retainer
h2o 3.44.x • Python 3.11 • Java 11 • H2OAutoML max_runtime_secs=3600 • 365-row daily time-series CSV sorted by date • default random 5-fold CV — no fold_column • random fold assignment mixes January–December rows in every fold • future timestamps in training folds • temporal leakage • AutoML val_RMSE=0.023 (leaky) • held-out 30-day test_RMSE=0.41 (18× higher) • developer checks feature engineering and stationarity for 3 weeks • fix: chronological fold_column (fold_id = row_number // 73) • val_RMSE 0.023→0.089 (true) • test_RMSE 0.41→0.093 • ratio 17.8→1.04 • 1.5h retainer engagement
Eastfield H2O Data Ltd. used H2O AutoML 3.44.x to build a daily sales forecasting model on 12 months of historical daily data (365 rows, 28 features including day-of-week, lag features, and rolling averages), with the AutoML budget set to max_runtime_secs=3600. The AutoML leaderboard showed the best GBM model achieving val_RMSE=0.023 — an impressive result that the team attributed to H2O’s automatic feature preprocessing. When the team deployed the model and evaluated it on the first 30 days of live data, the RMSE was 0.41 — 18× worse than the validation score. The developer spent three weeks revisiting feature engineering, adding stationarity transforms, and rebuilding lag features before engaging an H2O AutoML retainer developer.
The H2O AutoML configuration and training code at the time of the engagement:
# Eastfield H2O Data Ltd. — H2O AutoML daily sales forecasting
# h2o 3.44.x, Python 3.11, Java 11
import h2o
from h2o.automl import H2OAutoML
import pandas as pd
h2o.init(max_mem_size='8G', nthreads=-1)
# Load 365-row daily time-series CSV (sorted by date, Jan 1 → Dec 31)
df = pd.read_csv('daily_sales.csv')
# df columns: date, sales, dow, lag_1, lag_7, lag_30, roll_7, roll_30, ...
# df is SORTED by date ascending (row 0 = Jan 1, row 364 = Dec 31)
# ← Problem: entire year loaded into training frame, including last 30 days
# (the period we want to test on)
frame = h2o.H2OFrame(df)
# ← Problem: H2O AutoML default CV is random 5-fold — no fold_column specified
# H2OAutoML randomly assigns each of the 365 rows to fold 0, 1, 2, 3, or 4
# This mixes rows from January and December in every fold
# When fold 0 is validation, the training portion contains future rows relative to fold 0's validation rows
x = ['dow', 'lag_1', 'lag_7', 'lag_30', 'roll_7', 'roll_30'] # date excluded
y = 'sales'
aml = H2OAutoML(
max_runtime_secs=3600,
seed=42,
# ← Problem: no fold_column — default random 5-fold
# For time-series data, fold_column is required to prevent leakage
)
aml.train(x=x, y=y, training_frame=frame)
# AutoML completes — leaderboard looks great
print(aml.leaderboard.head())
# GBM_grid__1_AutoML_1_20261105_model_1 RMSE=0.023 MAE=0.019 ...
# XGBoost_1_AutoML_1_20261105 RMSE=0.025 MAE=0.021 ...
# DRF_1_AutoML_1_20261105 RMSE=0.027 MAE=0.022 ...
best_model = aml.leader
# Evaluate on first 30 days of live data (separately collected)
live_df = pd.read_csv('live_sales_jan_next_year.csv')
live_frame = h2o.H2OFrame(live_df)
perf = best_model.model_performance(live_frame)
print(f"Live test RMSE: {perf.rmse():.3f}")
# Output: Live test RMSE: 0.410 ← 18× worse than val_RMSE=0.023
Why random k-fold CV leaks future into time-series training
H2O AutoML’s default cross-validation uses random k-fold splitting: each row in the training frame is assigned to fold 0, 1, 2, 3, or 4 independently and uniformly at random, regardless of any temporal ordering. For a 365-row daily time-series sorted from January 1 to December 31, this means that fold 0 (the first validation fold) will contain approximately 73 rows drawn randomly from throughout the year — some from January, some from March, some from October. The remaining 292 rows form the training data for fold 0, and they too span the full year, including rows from December that are chronologically later than the rows assigned to fold 0’s validation set.
When the model is trained on this fold’s training data (which includes December rows) and validated on fold 0’s validation data (which includes January rows), the model has seen the future during training. For Eastfield’s dataset, the lag_1 feature for a December row encodes the November 30 sales value — information that is causally downstream from many January rows in the validation set. The model learns the full year’s seasonal pattern including the year-end holiday spike, which inflates its apparent accuracy on randomly shuffled validation data. In deployment, the model is asked to predict January of the following year with no access to December data from that year, and the leakage-inflated pattern matching completely fails. The ratio of test_RMSE (0.41) to val_RMSE (0.023) of 17.8× is a reliable indicator that the CV folds are leaky.
# Diagnostic: check for temporal leakage via fold assignment inspection
# h2o 3.44.x, Python 3.11
import h2o
from h2o.automl import H2OAutoML
import pandas as pd
import numpy as np
h2o.init(max_mem_size='8G', nthreads=-1)
df = pd.read_csv('daily_sales.csv')
df = df.sort_values('date').reset_index(drop=True)
# ---- Diagnostic 1: check test/val RMSE ratio ----
# Split last 30 days as a held-out test set:
test_df = df.tail(30)
train_df = df.iloc[:-30]
train_frame = h2o.H2OFrame(train_df)
test_frame = h2o.H2OFrame(test_df)
x = ['dow', 'lag_1', 'lag_7', 'lag_30', 'roll_7', 'roll_30']
y = 'sales'
# Run with default random k-fold (the bug):
aml_random = H2OAutoML(max_runtime_secs=600, seed=42)
aml_random.train(x=x, y=y, training_frame=train_frame)
cv_rmse = aml_random.leaderboard['rmse'][0, 0]
test_perf = aml_random.leader.model_performance(test_frame)
test_rmse = test_perf.rmse()
print(f"Random k-fold CV RMSE: {cv_rmse:.4f}")
print(f"Held-out test RMSE: {test_rmse:.4f}")
print(f"Leakage ratio: {test_rmse / cv_rmse:.1f}x")
# Output:
# Random k-fold CV RMSE: 0.0231
# Held-out test RMSE: 0.4103
# Leakage ratio: 17.8x ← strong indicator of temporal leakage
# ---- Diagnostic 2: inspect fold assignment for temporal mixing ----
# Add a row_number column to check which months end up in each fold
# H2OAutoML doesn't expose its internal fold assignment, so we simulate it:
np.random.seed(42)
n = len(train_df)
simulated_folds = np.random.randint(0, 5, size=n) # approximate random assignment
train_df['simulated_fold'] = simulated_folds
print("\nMonth distribution across validation folds (random assignment):")
train_df['month'] = pd.to_datetime(train_df['date']).dt.month
for fold in range(5):
fold_rows = train_df[train_df['simulated_fold'] == fold]
month_counts = fold_rows['month'].value_counts().sort_index()
months_present = list(month_counts.index)
print(f" Fold {fold} validation rows: months {months_present}")
# Output:
# Fold 0 validation rows: months [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] ← all months mixed
# Fold 1 validation rows: months [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] ← all months mixed
# ...
# (Each fold's validation contains rows from all 11 months — clearly not temporal)
The fix: chronological fold_column for temporal cross-validation
The fix is to create a fold_column in the H2OFrame that assigns rows to folds in strict chronological order: rows 0–72 get fold 0, rows 73–145 get fold 1, and so on. With this assignment, each validation fold contains only rows from a specific time window, and the training portion of each fold contains only rows from earlier time windows (for all folds except fold 4, which uses the latest data for validation and earlier data for training — H2O uses a standard k-fold scheme, not a walk-forward scheme, so each fold’s training data includes both earlier and later rows from other folds; for a stricter walk-forward validation, the fold_column approach still significantly reduces leakage compared to random k-fold). Pass the fold_column='fold_id' parameter to aml.train(). The resulting val_RMSE of 0.089 is 4× higher than the leaky 0.023, and the held-out test_RMSE of 0.093 closely matches — confirming that the CV is now measuring true out-of-time generalization.
# Fix: chronological fold_column to prevent temporal leakage
# h2o 3.44.x, Eastfield H2O Data Ltd.
import h2o
from h2o.automl import H2OAutoML
import pandas as pd
import numpy as np
h2o.init(max_mem_size='8G', nthreads=-1)
df = pd.read_csv('daily_sales.csv')
df = df.sort_values('date').reset_index(drop=True) # ensure date order
# Hold out last 30 days as a truly held-out test set
# (do this BEFORE creating training frame)
test_df = df.tail(30).reset_index(drop=True)
train_df = df.iloc[:-30].reset_index(drop=True)
# ← Fix: create chronological fold_column
# fold_id maps rows to folds 0–4 in time order:
# rows 0–66 → fold 0 (Jan–early Mar)
# rows 67–133 → fold 1 (mid-Mar–mid-May)
# rows 134–200 → fold 2 (mid-May–late Jul)
# rows 201–267 → fold 3 (late Jul–late Sep)
# rows 268–334 → fold 4 (late Sep–Nov end)
n_train = len(train_df)
n_folds = 5
train_df['fold_id'] = (train_df.index // (n_train // n_folds)).clip(0, n_folds - 1)
# Resulting values: 0 for first n_train//5 rows, 1 for next, ... 4 for last
train_frame = h2o.H2OFrame(train_df)
test_frame = h2o.H2OFrame(test_df)
# Ensure fold_id column is integer type in H2OFrame
train_frame['fold_id'] = train_frame['fold_id'].asnumeric()
x = ['dow', 'lag_1', 'lag_7', 'lag_30', 'roll_7', 'roll_30']
y = 'sales'
# ← Fix: pass fold_column to prevent H2OAutoML from using random k-fold
aml = H2OAutoML(
max_runtime_secs=3600,
seed=42,
# fold_column tells H2OAutoML to use the pre-specified fold assignment
# instead of internal random k-fold
)
aml.train(x=x, y=y, training_frame=train_frame, fold_column='fold_id')
# AutoML leaderboard now shows realistic RMSE
print(aml.leaderboard.head())
# GBM_grid__1_AutoML_1_20261105_model_1 RMSE=0.089 MAE=0.071 ...
# XGBoost_1_AutoML_1_20261105 RMSE=0.091 MAE=0.073 ...
# (Higher RMSE than leaky CV — but now reflects true out-of-time performance)
best_model = aml.leader
perf = best_model.model_performance(test_frame)
print(f"Held-out test RMSE: {perf.rmse():.4f}")
# Output: Held-out test RMSE: 0.0931 ← close to val_RMSE=0.089, ratio=1.04
# Sanity check: verify fold assignment is chronological
# (fold 0 should contain only early rows, fold 4 only late rows)
fold_check = train_df.groupby('fold_id')['date'].agg(['min', 'max'])
print("\nFold date ranges (should be non-overlapping, chronological):")
print(fold_check)
# fold_id min max
# 0 2026-01-01 2026-03-07 ← correctly bounded
# 1 2026-03-08 2026-05-11
# 2 2026-05-12 2026-07-15
# 3 2026-07-16 2026-09-18
# 4 2026-09-19 2026-11-30
Validation
# Validation log — November 2026
# h2o 3.44.x, Python 3.11, Java 11
# H2OAutoML daily sales forecasting: 365 rows, 28 features, max_runtime_secs=3600
# ---- Before fix: default random 5-fold CV (no fold_column) ----
# CV method: random 5-fold (H2OAutoML default)
# fold_column: none
# AutoML best model: GBM
# AutoML val_RMSE (leaky): 0.0231
# AutoML val_MAE (leaky): 0.0191
# Held-out test RMSE: 0.4103
# Held-out test MAE: 0.3287
# Leakage ratio: 17.8x (test_RMSE / cv_RMSE)
# ---- After fix: chronological fold_column (fold_id = row_index // 67) ----
# CV method: chronological 5-fold via fold_column='fold_id'
# fold_column: 'fold_id', values 0–4, chronological blocks
# AutoML best model: GBM (same algorithm, different hyperparams)
# AutoML val_RMSE (true): 0.0892
# AutoML val_MAE (true): 0.0714
# Held-out test RMSE: 0.0931
# Held-out test MAE: 0.0748
# Leakage ratio: 1.04x (test_RMSE / cv_RMSE — healthy)
# ---- Improvement ----
# test_RMSE: 0.4103 → 0.0931 (-77.3%)
# val/test RMSE ratio: 17.8 → 1.04 (CV now tracks real performance)
# Note: val_RMSE increased from 0.023 → 0.089 (the "leaky RMSE" disappeared)
# ---- Best model config ----
# Algorithm: GBM
# ntrees: 142
# max_depth: 6
# learn_rate: 0.05
# col_sample_rate_per_tree: 0.8
# min_rows: 5
# ---- Hour breakdown ----
# Triage: 0.5h — AutoML val_RMSE=0.023 but held-out test_RMSE=0.41;
# leakage ratio 17.8x; no fold_column in aml.train() call confirmed
# Root cause: 0.25h — default random 5-fold mixes all months in every fold;
# 365-row time-series → future timestamps appear in training folds;
# lag features carry future information across fold boundaries
# Fix: 0.25h — fold_id = row_index // 67, clipped to 0–4;
# aml.train(fold_column='fold_id'); fold date ranges verified
# Validation: 0.5h — val_RMSE 0.023→0.089 (true); test_RMSE 0.41→0.093;
# leakage ratio 17.8→1.04; 1.5h total
Related retainer posts
- FLAML developer on retainer — 5-fold CV on 500K rows allows only 2 LightGBM trials in 600 s budget — best config essentially random — 3 weeks
- Optuna developer on retainer —
study.optimize()parallelized across 8 workers shares same SQLite storage — WAL mode disabled — 3 weeks - scikit-optimize developer on retainer — near-zero GP noise from
initial_ystd makes EI acquisition degenerate — 3 weeks
Track retainer hours without a client portal
HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.
See pricing →