Train, Validation và Test Set cần được chia theo thời gian để mô phỏng thực tế ra sao?
Train, Validation và Test Set
Trong machine learning thông thường, dữ liệu thường được chia ngẫu nhiên thành Train, Validation và Test. Với dữ liệu tài chính, cách làm này rất dễ tạo ra một kết quả đẹp nhưng không phản ánh cách model sẽ hoạt động khi đem ra thị trường. Nguyên nhân nằm ở thứ tự thời gian: khi dự đoán ngày mai, model chỉ có thể học từ quá khứ. Nếu dữ liệu năm 2025 vô tình nằm trong Train còn dữ liệu năm 2022 lại nằm trong Test, model đang được phép học từ tương lai rồi quay ngược lại dự đoán quá khứ. Scikit-learn cũng lưu ý rằng các phương pháp cross-validation thông thường không phù hợp cho dữ liệu có thứ tự thời gian vì có thể dẫn tới việc train trên tương lai và đánh giá trên quá khứ; TimeSeriesSplit được thiết kế để giữ đúng trật tự này.

Một cách chia cơ bản hơn cho Quant Research có thể là:
2015 ───────── 2019 | 2020 ── 2021 | 2022 ───────── 2024
TRAIN VALIDATION TESTTrain dùng để học model. Validation dùng để chọn feature, hyperparameter, threshold hoặc strategy configuration. Test chỉ được mở ra sau khi mọi quyết định đã gần như hoàn tất. Nếu nhìn Test rồi quay lại chỉnh model, Test về bản chất đã biến thành một Validation Set khác và không còn là phép đánh giá độc lập.
1. Random Split có thể làm kết quả tài chính đẹp hơn thực tế như thế nào?
Giả sử có dữ liệu VN30 từ 2015–2025 và muốn dự đoán return 5 ngày tiếp theo bằng Momentum, RSI, Volume Z-score, Beta và Relative Strength. Nếu dùng:
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.2,
random_state=42,
shuffle=True
)các quan sát của năm 2025 có thể lọt vào Train trong khi một số quan sát 2019 lại nằm trong Test. Về mặt thống kê, model vẫn chạy bình thường. Nhưng về mặt trading, bài toán đã khác hoàn toàn thực tế.
Một tình huống còn khó nhìn hơn là khi mỗi observation có các feature rất gần nhau về thời gian. Momentum60D của ngày 10/6 và ngày 11/6 được tính từ phần lớn cùng một tập giá lịch sử; future return 20D của hai ngày này cũng overlap rất mạnh. Nếu một observation vào Train còn observation kế bên vào Test, hai tập không thực sự độc lập như tưởng tượng.
Scikit-learn có ví dụ chính thức cho time-series forecasting cho thấy split ngẫu nhiên có thể tạo ra metric quá lạc quan, trong khi cách đánh giá theo thời gian phản ánh hiệu năng thực tế tốt hơn.
Với một hold-out đơn giản, có thể chia trực tiếp:
train = df.loc["2015-01-01":"2019-12-31"].copy()
valid = df.loc["2020-01-01":"2021-12-31"].copy()
test = df.loc["2022-01-01":"2024-12-31"].copy()Sau đó toàn bộ preprocessing cũng phải tuân theo cùng nguyên tắc. Ví dụ với StandardScaler, sai lầm khá phổ biến là fit scaler trên toàn bộ dữ liệu:
# Sai
scaler.fit(df[features])Mean và standard deviation của giai đoạn Test lúc này đã ảnh hưởng ngược vào Train. Đúng hơn là:
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_train = scaler.fit_transform(train[features])
X_valid = scaler.transform(valid[features])
X_test = scaler.transform(test[features])Tương tự, PCA, feature selection, outlier threshold, winsorization threshold hay bất kỳ phép biến đổi nào “học” tham số từ dữ liệu cũng nên fit trên Train trước rồi mới áp dụng sang Validation và Test.
Ví dụ một Random Forest:
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
model = RandomForestClassifier(
n_estimators=300,
max_depth=5,
min_samples_leaf=30,
random_state=42
)
model.fit(X_train, y_train)
valid_pred = model.predict_proba(X_valid)[:, 1]
valid_auc = roc_auc_score(y_valid, valid_pred)Sau khi thử các cấu hình trên Validation và chốt model:
test_pred = model.predict_proba(X_test)[:, 1]
test_auc = roc_auc_score(y_test, test_pred)Test nên được coi như dữ liệu “tương lai chưa từng thấy”. Đây mới gần với tình huống một model research xong năm 2021 rồi bắt đầu chạy từ năm 2022.
2. Một lần Train–Validation–Test vẫn chưa đủ: nên dùng Walk-Forward
Thị trường không đứng yên. Một model train trên 2015–2019 rồi test đến 2025 đang giả định mối quan hệ giữa feature và return tương đối ổn định trong nhiều năm. Với tài chính, assumption này thường khá mạnh. Bull market, bear market, lãi suất, thanh khoản, cấu trúc nhà đầu tư và leadership của thị trường có thể thay đổi theo thời gian.
Một cách thực tế hơn là walk-forward validation. Ví dụ:
Window 1
Train: 2015–2018 | Validation: 2019
Window 2
Train: 2015–2019 | Validation: 2020
Window 3
Train: 2015–2020 | Validation: 2021
Window 4
Train: 2015–2021 | Validation: 2022Đây là expanding window: dữ liệu Train ngày càng mở rộng và model luôn được đánh giá trên giai đoạn nằm sau Train. TimeSeriesSplit của scikit-learn hoạt động theo logic tương tự: ở mỗi split, Train là các fold phía trước và fold tiếp theo được dùng để đánh giá; các tập Train sau là superset của các tập trước.
Ví dụ:
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(
n_splits=5,
test_size=252,
gap=20
)
for fold, (train_idx, valid_idx) in enumerate(tscv.split(X)):
X_train = X.iloc[train_idx]
y_train = y.iloc[train_idx]
X_valid = X.iloc[valid_idx]
y_valid = y.iloc[valid_idx]
print(
fold,
X_train.index.min(),
X_train.index.max(),
X_valid.index.min(),
X_valid.index.max()
)Ở đây test_size=252 có thể hiểu gần như một năm giao dịch nếu dataset daily có khoảng 252 observations mỗi năm, còn gap=20 tạo khoảng cách giữa Train và Validation. TimeSeriesSplit hiện hỗ trợ trực tiếp các tham số test_size, max_train_size và gap.
gap đặc biệt đáng chú ý khi target sử dụng future return. Giả sử label tại ngày T là:
df["target"] = (
df["close"].shift(-20) /
df["close"] - 1
)Target của observation cuối Train cần giá của 20 phiên phía sau mới được xác định. Nếu Validation bắt đầu ngay ngày kế tiếp, horizon của label Train có thể overlap với thời gian thuộc Validation. Để mô phỏng chặt hơn, có thể đặt gap tương ứng với horizon hoặc sử dụng kỹ thuật purge/embargo khi cấu trúc label phức tạp.
Ngoài expanding window còn có rolling window:
Train: 2015–2019 → Validate 2020
Train: 2016–2020 → Validate 2021
Train: 2017–2021 → Validate 2022
Train: 2018–2022 → Validate 2023Khác biệt nằm ở chỗ dữ liệu cũ nhất bị loại dần. Rolling window hữu ích khi cho rằng dữ liệu rất xa trong quá khứ không còn đại diện cho market regime hiện tại. Expanding window phù hợp hơn khi muốn tận dụng toàn bộ lịch sử và tin rằng relationship vẫn có giá trị dài hạn.
Không có một window tối ưu cho mọi strategy. Momentum, mean reversion, fundamental factor và intraday signal có tốc độ decay khác nhau. Chính window length cũng là một giả định cần được kiểm tra chứ không nên chọn tùy ý.
3. Test Set chỉ có giá trị khi thực sự được giữ “sạch”
Giả sử nghiên cứu 30 model. Model A Test Sharpe = 0.7, model B = 0.9, model C = 1.3. Sau đó chọn model C vì nó có Test Sharpe cao nhất. Về mặt quy trình, Test vừa được dùng để chọn model. Nếu tiếp tục thử hàng trăm configuration rồi giữ configuration có Test performance đẹp nhất, ta đang overfit chính Test Set.
Một workflow rõ ràng hơn là:
TRAIN
Học parameters của model
↓
VALIDATION
Chọn model, feature, threshold, hyperparameter
↓
TEST
Đánh giá cuối cùng
↓
PAPER / LIVE
Kiểm chứng ngoài mẫu thực sựVí dụ đang dự đoán xác suất cổ phiếu outperform VN-Index 20 ngày tới. Train có thể dùng 2015–2020, Validation 2021–2022 và Test 2023–2025. Trên Validation có thể thử:
max_depth = 3, 5, 7
n_estimators = 100, 300, 500
probability threshold = 0.55, 0.60, 0.65Sau khi chốt:
best_params = {
"max_depth": 5,
"n_estimators": 300
}
threshold = 0.60Test được chạy đúng một quy trình cố định. Nếu kết quả Test yếu, không nên chỉnh thêm threshold chỉ để Test đẹp lên rồi vẫn gọi nó là out-of-sample.
Với Quant Research, metric cũng không nên chỉ là Accuracy hay AUC. Sau khi có prediction, nên kiểm tra trực tiếp economic value:
test["prediction"] = test_pred
test["signal"] = test["prediction"] > 0.60
strategy_return = (
test["signal"].shift(1)
* test["next_day_return"]
)Sau đó đo:
Sharpe
Maximum Drawdown
Turnover
Hit Rate
Average Return per Trade
Transaction Cost
Performance theo từng năm
Performance theo market regimeMột model có AUC 0.58 nhưng tạo ranking ổn định ở nhóm top-decile đôi khi có giá trị trading hơn model AUC 0.62 nhưng turnover quá cao hoặc alpha tập trung vào một giai đoạn duy nhất.
Cũng nên nhìn performance của từng walk-forward fold thay vì chỉ trung bình:
2019 Sharpe 1.30
2020 Sharpe 1.05
2021 Sharpe 0.92
2022 Sharpe -0.15
2023 Sharpe 0.88
2024 Sharpe 0.79Con số này cho nhiều thông tin hơn một Average Sharpe = 0.80. Năm 2022 có thể đại diện cho một regime mà model thất bại. Khi đó câu hỏi tiếp theo là model có cần regime filter hay feature relationship đã thay đổi.
Một ví dụ rất dễ gặp là kết quả:
Random Split
Train AUC 0.74
Validation AUC 0.70
Test AUC 0.68sau khi chuyển sang time-based split:
Time Split
Train AUC 0.72
Validation AUC 0.60
Test AUC 0.55Kết quả thứ hai nhìn xấu hơn nhưng đáng tin hơn. Khoảng cách đó chính là phần “ảo tưởng” mà random split có thể che giấu.
Việc chia dữ liệu theo thời gian không làm model tốt hơn. Nó làm bài kiểm tra khó hơn và gần với thực tế hơn. Trong live trading, không ai được train trên dữ liệu năm sau rồi quay lại dự đoán năm trước; model luôn học từ một đoạn lịch sử và đối diện với tương lai chưa biết.
Một pipeline tương đối sạch có thể giữ nguyên thứ tự:
Point-in-Time Data
→ Feature Engineering
→ Time-based Train
→ Validation / Walk-forward
→ Hyperparameter Selection
→ Frozen Model
→ Untouched Test
→ Backtest có phí và slippage
→ Paper Trading
→ Live TradingTrain trả lời model học được gì từ quá khứ. Validation trả lời cấu hình nào đáng được giữ lại. Test trả lời sau tất cả những quyết định đó, model còn hoạt động trên một giai đoạn chưa từng được dùng để lựa chọn hay không.
Nếu cả ba tập được trộn ngẫu nhiên với nhau, ranh giới giữa quá khứ và tương lai gần như biến mất. Với dữ liệu tài chính, đó thường là cách nhanh nhất để tạo ra một model rất đẹp trong notebook nhưng yếu hẳn khi đưa ra thị trường.
