Danh sách bài viết

Bài 39: Ứng dụng LSTM cho dự đoán chuỗi thời gian

Time series forecasting là bài toán dự đoán giá trị tương lai \( x_{t+1}, \ldots, x_{t+k} \) từ quá khứ \( x_1, \ldots, x_t \). LSTM (B37) phù hợp khi chuỗi có dependency dài và phi tuyến — vd điện năng tiêu thụ, sensor IoT, demand bán lẻ. Bài này hướng dẫn pipeline đầy đủ: chia chronological (không random), sliding window, normalize chỉ trên train, custom Dataset, model LSTMForecaster, training MSE / Adam (không shuffle hoặc shuffle window đã cắt), walk-forward validation, multi-step (direct vs recursive), multivariate, evaluation MSE / RMSE / MAPE / SMAPE và bắt buộc so sánh với baseline naive \( \hat{x}_{t+1} = x_t \). Demo end-to-end trên sin wave + noise, liệt kê common pitfall (data leak, normalize trên test) và modern alternatives (Informer, TFT, N-BEATS, Chronos).

24/05/2026
14 phút đọc
1 lượt xem
1

Mục tiêu bài học

Sau bài học, bạn sẽ:

  • Phân biệt được one-step, multi-step (direct vs recursive), univariate vs multivariate forecast.
  • Cắt chuỗi thành mẫu supervised bằng sliding window và build Dataset / DataLoader đúng.
  • Split chronological train / val / test và fit normalize statistics chỉ trên train.
  • Viết một LSTMForecaster bằng PyTorch và train trên loss MSE.
  • Áp dụng walk-forward validation để có ước lượng generalization realistic.
  • Tính MSE, RMSE, MAPE, SMAPE và so sánh với baseline naive / moving average / ARIMA.
  • Nhận diện và tránh các pitfall hay gặp: data leak từ future, normalize trên test, báo cáo không có baseline.

Bài này dựa trên LSTM (B37) và GRU (B38). Bài B40 — Seq2Seq intuition sẽ mở rộng sang kiến trúc encoder–decoder cho multi-step forecast dài và chuỗi khác độ dài.

2

Time series forecasting là gì

Time series là chuỗi giá trị quan sát theo thứ tự thời gian: \( x_1, x_2, \ldots, x_T \) với \( x_t \) là giá trị tại thời điểm \( t \). Forecasting là dự đoán \( x_{t+1}, \ldots, x_{t+k} \) khi đã biết toàn bộ quá khứ tới \( t \).

Hai đặc điểm phân biệt time series với dữ liệu bảng thông thường:

  • Thứ tự có ý nghĩa: hoán vị mẫu phá vỡ thông tin. Không random shuffle khi chia tập.
  • Phụ thuộc tạm thời (temporal dependency): giá trị tương lai chịu ảnh hưởng quá khứ — đôi khi rất xa (mùa vụ năm trước).

LSTM phù hợp khi chuỗi:

  • Có dependency phi tuyến và dài hơn cửa sổ AR(p) hợp lý.
  • Có nhiều biến (multivariate) tương tác với nhau.
  • Lượng dữ liệu đủ lớn (vài nghìn step trở lên cho 1 chuỗi, hoặc nhiều chuỗi).

Khi chuỗi ngắn, tuyến tính rõ ràng, hoặc chủ yếu mang trend + mùa vụ, model thống kê (ARIMA, ETS, Prophet) thường đủ tốt và dễ giải thích hơn.

3

Ví dụ ứng dụng thực tế

  • Giá tài sản tài chính: cổ phiếu, FX, crypto. Lưu ý: thị trường hiệu quả nên rất khó beat baseline naive; nhiều paper claim accuracy cao trên stock thực ra thiếu so sánh baseline đúng.
  • Khí tượng: nhiệt độ, lượng mưa, độ ẩm, gió. Có chu kỳ ngày / mùa rõ rệt, multivariate (nhiều station).
  • Điện năng tiêu thụ: dự báo phụ tải lưới điện theo giờ / ngày. Có yếu tố ngày trong tuần, mùa, thời tiết.
  • Bán hàng / cầu sản phẩm: forecast cho supply chain, quản lý kho. Thường nhiều chuỗi song song (mỗi SKU một chuỗi).
  • Sensor IoT: vibration, áp suất, dòng điện — anomaly detection và predictive maintenance.
  • Lưu lượng web / traffic: dự đoán PV, request/giây để autoscale.

Mỗi domain có đặc thù riêng: thị trường tài chính có noise lớn so với signal; nhu cầu bán hàng có outlier (khuyến mãi); sensor IoT có dữ liệu tần suất cao và missing value. Pipeline trong bài là khung chung, biến thể tuỳ domain.

4

Phân loại task forecast

Phân theo horizon (số step dự đoán) và số biến:

Loại Mô tả Ví dụ
One-step Dự đoán \( x_{t+1} \) từ \( x_{1:t} \). Giá đóng cửa ngày mai từ 30 ngày gần nhất.
Multi-step Dự đoán \( x_{t+1}, \ldots, x_{t+k} \) cùng lúc. Dự báo phụ tải 24 giờ tới.
Univariate \( x_t \in \mathbb{R} \) — 1 biến duy nhất. Nhiệt độ trung bình ngày.
Multivariate \( x_t \in \mathbb{R}^d \) — \( d \) biến đồng thời. {temp, humidity, pressure, wind} forecast temp.

Có thể kết hợp: multivariate multi-step là task khó nhất, vd "từ 48 giờ thời tiết qua, dự đoán 24 giờ nhiệt độ tới". Bài này cover cả 4 trường hợp.

5

Sliding window — biến chuỗi thành supervised

LSTM cần input dạng \( (B, T, d) \) — batch các chuỗi cùng độ dài. Từ một chuỗi dài, cắt cửa sổ trượt độ dài \( w \) và target là điểm kế tiếp:

\[ X^{(i)} = [x_{i}, x_{i+1}, \ldots, x_{i+w-1}], \qquad y^{(i)} = x_{i+w} \]

Với chuỗi độ dài \( N \), số mẫu thu được là \( N - w \). Window size \( w \) là hyperparameter:

  • \( w \) quá nhỏ: model thiếu context, không học được dependency dài.
  • \( w \) quá lớn: chuỗi training ngắn lại (\( N - w \)), gradient lan qua nhiều step → vanishing dù LSTM.
  • Quy tắc thực dụng: chọn \( w \) bằng vài chu kỳ tự nhiên (vd dữ liệu hourly có chu kỳ ngày → \( w = 48 \) cho 2 ngày).

Visual minh hoạ với \( w = 4 \):

Chuỗi:   x_0  x_1  x_2  x_3  x_4  x_5  x_6  x_7

Mẫu 0:  [x_0  x_1  x_2  x_3]  →  x_4
Mẫu 1:  [x_1  x_2  x_3  x_4]  →  x_5
Mẫu 2:  [x_2  x_3  x_4  x_5]  →  x_6
Mẫu 3:  [x_3  x_4  x_5  x_6]  →  x_7

Các cửa sổ chồng lấp nhau — đây là điểm quan trọng khi split: chia chuỗi gốc rồi mới cắt window, không cắt window xong shuffle rồi split.

6

Chronological split — không random

Cách chia chuẩn:

  • Train: 70% đầu chuỗi (\( x_1 \) đến \( x_{0.7N} \)).
  • Val: 15% kế tiếp.
  • Test: 15% cuối.

Vì sao không random split:

  • Random split trộn quá khứ và tương lai → khi cắt window, một mẫu val có thể chứa thông tin từ một mẫu train sau nó về thời gian → data leak.
  • Test phải giống tình huống production: model deploy hôm nay chỉ thấy quá khứ, phải predict tương lai.
  • Random split cho metric đẹp giả tạo, deploy thực tế tụt nhiều.

Một số biến thể nâng cao:

  • Expanding window: train tăng dần theo thời gian, val luôn là k step ngay sau.
  • Rolling window: train cố định độ dài, trượt theo thời gian (walk-forward, mục 11).
  • TimeSeriesSplit của sklearn cài sẵn 2 biến thể trên.
7

Preprocessing

Các bước phổ biến trước khi đưa vào LSTM:

  • Normalize / standardize: chuẩn hoá scale (Series 2 — Feature Engineering). LSTM hội tụ nhanh hơn khi input quanh 0 và \( \sigma \approx 1 \). Tính mean / std chỉ trên train, áp công thức đó cho val + test.
  • Detrend (tuỳ chọn): trừ trend tuyến tính hoặc rolling mean. Giúp model tập trung vào dao động.
  • Deseasonalize (tuỳ chọn): trừ seasonal component (STL decomposition).
  • Log transform: với chuỗi tăng trưởng nhân tính (giá, doanh thu) — \( \log(1 + x) \) cho phân phối ổn định hơn.
  • Handle missing value: forward fill, linear interp, hoặc mark missing rồi cho model tự học (input thêm flag).
import numpy as np

train = series[:int(0.7 * N)]
val   = series[int(0.7 * N):int(0.85 * N)]
test  = series[int(0.85 * N):]

# Normalize: fit trên TRAIN, transform mọi tập
mu, sigma = train.mean(), train.std()
train_n = (train - mu) / sigma
val_n   = (val   - mu) / sigma
test_n  = (test  - mu) / sigma

Sai lầm cực phổ biến: mu = series.mean() — fit trên toàn bộ chuỗi → val và test đã rò rỉ vào mu. Phải fit riêng trên train.

8

Custom Dataset cho time series

Mỗi __getitem__ trả về 1 cửa sổ và target. Lazy build — không vật chất hoá toàn bộ tensor mẫu, tiết kiệm RAM:

import torch
from torch.utils.data import Dataset

class TimeSeriesDataset(Dataset):
    def __init__(self, series, window=30, horizon=1):
        # series: 1D numpy array hoặc torch tensor (univariate)
        self.series = torch.as_tensor(series, dtype=torch.float32)
        self.window = window
        self.horizon = horizon

    def __len__(self):
        return len(self.series) - self.window - self.horizon + 1

    def __getitem__(self, idx):
        x = self.series[idx : idx + self.window]            # (window,)
        y = self.series[idx + self.window : idx + self.window + self.horizon]
        return x.unsqueeze(-1), y                            # x: (window, 1)

Note:

  • Unsqueeze (-1) để có shape \( (T, \text{features}) \) — convention batch_first của nn.LSTM.
  • horizon=1 cho one-step; tăng để multi-step direct.
  • Cho multivariate, series là 2D shape \( (N, d) \); x đã có shape \( (window, d) \), bỏ unsqueeze.
9

Model LSTMForecaster

import torch.nn as nn

class LSTMForecaster(nn.Module):
    def __init__(self, input_size=1, hidden_size=64,
                 num_layers=2, horizon=1, dropout=0.2):
        super().__init__()
        self.lstm = nn.LSTM(
            input_size=input_size,
            hidden_size=hidden_size,
            num_layers=num_layers,
            batch_first=True,
            dropout=dropout if num_layers > 1 else 0.0,
        )
        self.fc = nn.Linear(hidden_size, horizon)

    def forward(self, x):
        # x: (B, T, input_size)
        output, (h_n, c_n) = self.lstm(x)
        # Lấy hidden state của step cuối ở layer trên cùng
        last = output[:, -1, :]            # (B, hidden_size)
        return self.fc(last)               # (B, horizon)

Quan sát:

  • Dùng output[:, -1, :] tương đương h_n[-1] (layer trên cùng) khi unidirectional. Cách trên tường minh hơn.
  • horizon=1: one-step forecast. horizon=k: direct multi-step (mục 13).
  • Dropout chỉ áp giữa các LSTM layer, không áp time-step. Cho dropout time, dùng variational dropout / locked_dropout ngoài.
  • Cho multivariate, đổi input_size; output vẫn có thể là 1 biến mục tiêu hoặc \( d \) biến nếu predict tất cả.
10

Training loop

Loss MSE cho regression, optimizer Adam. Cân nhắc shuffle trong DataLoader:

  • Window đã cắt sau khi split chronological → mỗi window là 1 mẫu độc lập, shuffle các window OK và thường giúp Adam hội tụ tốt hơn (giảm bias mini-batch theo thời gian).
  • Nếu train theo chuỗi liên tục (TBPTT, B35), không shuffle vì hidden state truyền giữa các batch.
  • Val và test luôn giữ shuffle=False để tính metric đúng thứ tự.
from torch.utils.data import DataLoader
import torch

device = "cuda" if torch.cuda.is_available() else "cpu"

train_ds = TimeSeriesDataset(train_n, window=30, horizon=1)
val_ds   = TimeSeriesDataset(val_n,   window=30, horizon=1)

train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
val_loader   = DataLoader(val_ds,   batch_size=64, shuffle=False)

model = LSTMForecaster(input_size=1, hidden_size=64, num_layers=2).to(device)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.MSELoss()

for epoch in range(50):
    model.train()
    for x, y in train_loader:
        x, y = x.to(device), y.to(device)
        pred = model(x)
        loss = loss_fn(pred, y)
        opt.zero_grad(); loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()

    model.eval()
    with torch.no_grad():
        val_losses = [loss_fn(model(x.to(device)), y.to(device)).item()
                      for x, y in val_loader]
        val_loss = sum(val_losses) / len(val_losses)
    print(f"epoch {epoch:3d}  val_mse = {val_loss:.4f}")

Thêm early stopping theo val_loss (B22) và lưu checkpoint khi val_loss đạt min.

11

Walk-forward validation

Walk-forward (a.k.a. rolling-origin) mô phỏng tình huống production:

  1. Train trên data tới thời điểm \( t \).
  2. Predict \( x_{t+1} \).
  3. Khi \( x_{t+1} \) thật về (sau 1 step), thêm vào tập train.
  4. Re-train (full hoặc fine-tune) và predict \( x_{t+2} \).
  5. Lặp tới hết test set.

Sơ đồ:

Vòng 1: train [-----------|         ]  predict t+1
Vòng 2: train [------------|        ]  predict t+2
Vòng 3: train [-------------|       ]  predict t+3
...

Lợi ích:

  • Mỗi prediction được tạo ra với đúng thông tin có sẵn tại thời điểm đó.
  • Metric tổng hợp trên nhiều vòng → ước lượng generalization vững hơn 1 lần split.
  • Phát hiện concept drift: nếu performance tụt theo thời gian, model cần re-train định kỳ.

Chi phí: \( O(n) \) lần train (n = số step test). Trong thực tế:

  • Re-train định kỳ (vd mỗi 30 step) thay vì mỗi step.
  • Hoặc fine-tune từ checkpoint, không train lại từ scratch.
12

Multivariate time series

Khi mỗi step có \( d \) biến, input shape là \( (B, T, d) \). LSTM xử lý đồng thời, hidden state nén thông tin từ tất cả biến.

Ví dụ: dự đoán nhiệt độ từ {temperature, humidity, pressure, wind_speed}:

model = LSTMForecaster(input_size=4, hidden_size=128,
                       num_layers=2, horizon=1)

# x: (B, T, 4) — 4 biến tại mỗi step
# y: (B, 1) — chỉ predict temperature

Quy ước normalize: fit mỗi biến riêng với mean/std của chính nó trên train.

mu = train_multivar.mean(axis=0)       # shape (4,)
sigma = train_multivar.std(axis=0)
train_n = (train_multivar - mu) / sigma

Tip:

  • Thêm feature thời gian (giờ trong ngày, ngày trong tuần) dạng cyclical \( (\sin, \cos) \) — giúp model nắm seasonality.
  • Khi target chỉ là 1 biến nhưng có thêm 10 biến phụ, vẫn cần fit normalize từng biến: tránh biến scale lớn áp đảo.
  • Nếu một biến gần như là noise, thử ablation: remove biến đó, so val MSE.
13

Multi-step forecasting

Ba chiến lược chính khi cần predict \( k \) step tới:

Chiến lược Cách làm Ưu / nhược
Direct Output layer có \( k \) neuron, predict \( k \) giá trị 1 lần. Không tích luỹ error giữa các step; cần data nhiều để học mapping cho mọi horizon. Mất cấu trúc tuần tự ở output.
Recursive (iterative) Predict 1 step, feed prediction làm input cho lần kế tiếp. Đơn giản, dùng cùng model one-step. Tích luỹ error: prediction càng xa càng lệch (error compounding).
Seq2Seq Encoder–decoder: encoder đọc quá khứ, decoder sinh chuỗi tương lai (B40). Linh hoạt với horizon dài và khác độ dài đầu vào / đầu ra. Phức tạp hơn để train.

Code Direct (chỉ đổi horizon):

model = LSTMForecaster(input_size=1, hidden_size=64,
                       num_layers=2, horizon=10)
# pred shape: (B, 10)

Code Recursive:

def recursive_forecast(model, x_init, k=10):
    # x_init: (1, window, 1)
    model.eval()
    preds = []
    x = x_init.clone()
    with torch.no_grad():
        for _ in range(k):
            y_hat = model(x)                      # (1, 1)
            preds.append(y_hat.item())
            # Trượt cửa sổ: bỏ step đầu, thêm prediction vào cuối
            x = torch.cat([x[:, 1:, :], y_hat.unsqueeze(-1)], dim=1)
    return preds

Quy tắc thực dụng: với horizon ngắn (≤ 5), recursive thường đủ; với horizon dài (24, 48), direct hoặc seq2seq tốt hơn.

14

Evaluation metrics

  • MSE: \( \frac{1}{n} \sum (y_i - \hat{y}_i)^2 \). Đơn vị bình phương, phạt outlier mạnh.
  • RMSE: \( \sqrt{\text{MSE}} \). Cùng đơn vị với \( y \), dễ giải thích.
  • MAPE: \( \frac{100}{n} \sum \left|\frac{y_i - \hat{y}_i}{y_i}\right| \). %, dễ truyền đạt với business. Bất ổn khi \( y_i \) gần 0.
  • SMAPE: \( \frac{100}{n} \sum \frac{|y_i - \hat{y}_i|}{(|y_i| + |\hat{y}_i|)/2} \). Symmetric, ổn định hơn MAPE.
  • Quantile loss / Pinball loss: cho prediction interval, vd train phân vị 0.1, 0.5, 0.9 → khoảng tin cậy.
import numpy as np

def mape(y, y_hat, eps=1e-8):
    return np.mean(np.abs((y - y_hat) / (np.abs(y) + eps))) * 100

def smape(y, y_hat):
    return np.mean(2 * np.abs(y - y_hat) / (np.abs(y) + np.abs(y_hat) + 1e-8)) * 100

Chọn metric theo task:

  • RMSE cho báo cáo kỹ thuật, có outlier nhỏ.
  • MAE nếu robust với outlier.
  • SMAPE cho cross-domain comparison (vd benchmark M4, M5).
  • Quantile loss khi cần biết risk (vd phụ tải peak).
15

So sánh với baseline

Yêu cầu bắt buộc: mọi báo cáo time series phải kèm baseline. Nếu LSTM không beat baseline thì model có vấn đề (hyperparameter, data leak ngược, lỗi pipeline).

  • Naive (persistence): \( \hat{x}_{t+1} = x_t \). Surprising mạnh trên thị trường tài chính.
  • Seasonal naive: \( \hat{x}_{t+1} = x_{t+1-s} \) với \( s \) là chu kỳ (24h, 7 ngày).
  • Moving average: \( \hat{x}_{t+1} = \frac{1}{k} \sum_{i=0}^{k-1} x_{t-i} \).
  • Linear regression trên window: AR(p).
  • ARIMA (Box–Jenkins): statistical model classical, fit với statsmodels.
  • Exponential smoothing (ETS): phù hợp khi có trend + seasonality rõ.
def naive_baseline(series, h=1):
    # predict x_{t+h} = x_t
    return series[:-h]

# RMSE naive trên test
y_true = test_n[1:]
y_pred = test_n[:-1]
rmse_naive = np.sqrt(np.mean((y_true - y_pred) ** 2))
print(f"naive RMSE = {rmse_naive:.4f}")

Bài học từ competition M4 / M5: combinations of statistical methods + ML thường beat từng method riêng lẻ. Một LSTM đơn thuần không tự động tốt hơn ARIMA tuned kỹ. Quy tắc: bắt đầu bằng baseline, có metric chuẩn, rồi mới so DL.

16

Common pitfall

  • Data leak từ future: shuffle trước khi split, hoặc normalize trên toàn chuỗi. Hệ quả: val/test đẹp giả tạo, production tụt.
  • Normalize statistics dùng cả test: fit StandardScaler trên train + val + test rồi mới split. Mean / std mang thông tin tương lai. Phải fit chỉ trên train.
  • Quên detach hidden state nếu train streaming (TBPTT) → gradient lan vô hạn, OOM hoặc nan.
  • Overfit dù LSTM nhỏ: chuỗi tài chính ngắn + LSTM 2 layer hidden 128 vẫn dễ memorize. Theo dõi val_loss và early stop.
  • Báo cáo metric mà không có baseline: claim "model đạt RMSE 0.5" vô nghĩa nếu naive đạt 0.4.
  • Inverse transform sai: train normalize, predict trên scale chuẩn hoá, nhưng quên \( \hat{y} \cdot \sigma + \mu \) khi báo cáo MAPE → metric sai vài bậc.
  • Target leakage trong feature: tính rolling mean window chứa cả \( x_t \) khi predict \( x_t \).
  • Look-ahead bias trong feature engineering: tính z-score với mean toàn chuỗi (bao gồm tương lai) làm feature.
17

Stationarity

Chuỗi stationary (yếu): mean, variance, autocovariance không đổi qua thời gian. Định nghĩa chính xác đòi hỏi mọi moment, thực tế dùng weak stationarity là đủ.

Vì sao quan trọng:

  • Model thống kê cổ điển (ARIMA) giả định stationary để consistency.
  • LSTM không bắt buộc stationary, nhưng học dễ hơn rất nhiều khi statistics ổn định.
  • Non-stationary (trend, seasonality, structural break) → model "trượt" theo thời gian.

Test: ADF (Augmented Dickey-Fuller) trong statsmodels.tsa.stattools.adfuller.

from statsmodels.tsa.stattools import adfuller

result = adfuller(series)
print(f"ADF stat = {result[0]:.4f}, p-value = {result[1]:.4f}")
# p < 0.05 → reject H0 (non-stationary) → chuỗi stationary

Cách "stationarize":

  • Differencing: \( \Delta x_t = x_t - x_{t-1} \). Loại trend.
  • Seasonal differencing: \( \Delta_s x_t = x_t - x_{t-s} \). Loại seasonality.
  • Log transform: \( \log(x_t) \). Ổn định variance khi tăng trưởng nhân tính.
  • Box-Cox: generalize log.

Khi train LSTM trên chuỗi đã differencing, nhớ inverse để báo cáo metric trên scale gốc.

18

Modern alternatives

LSTM không còn là SOTA cho time series từ khoảng 2020. Một số họ model nổi bật:

  • Transformer cho time series: Informer (Zhou và cộng sự, 2021), Autoformer (Wu và cộng sự, 2021), FEDformer — sửa attention cho chuỗi dài.
  • TFT (Temporal Fusion Transformer) (Lim và cộng sự, 2021): kết hợp LSTM encoder + multi-head attention + variable selection, hỗ trợ static covariate.
  • N-BEATS (Oreshkin và cộng sự, 2020): pure MLP với basis function — kết quả mạnh trên M4.
  • N-HiTS (Challu và cộng sự, 2023): mở rộng N-BEATS với hierarchical interpolation cho horizon dài.
  • PatchTST (Nie và cộng sự, 2023): chia chuỗi thành patch như ViT, attention trên patch.
  • Foundation model: Chronos (Amazon, 2024), TimeGPT (Nixtla), Moirai (Salesforce), TimesFM (Google). Pre-train trên hàng tỷ time series, zero-shot forecast.

Bài học thực dụng:

  • Trên dataset nhỏ và domain-specific, LSTM hoặc statistical baseline thường cạnh tranh.
  • Trên dataset lớn, có nhiều chuỗi song song (vd M5), Transformer-based và foundation model có lợi thế.
  • Chronos / TimeGPT cho phép zero-shot — không cần training, gọi API là có forecast. Đáng thử trước khi tự train.
19

Library chuyên dụng

Khi vượt qua giai đoạn học và muốn deploy thực tế, không nên tự viết lại pipeline mỗi lần:

  • Darts (unit8co): API fit/predict kiểu sklearn, bao trọn ARIMA, ETS, LSTM, N-BEATS, TFT. Cross-validation và backtesting built-in.
  • PyTorch Forecasting: tập trung TFT, DeepAR, N-BEATS. Tích hợp PyTorch Lightning.
  • statsmodels: classical (ARIMA, SARIMAX, ETS) — bắt buộc cho baseline thống kê.
  • Prophet (Meta): trend + seasonality + holiday, dùng tốt với analyst non-ML.
  • Nixtla / statsforecast / neuralforecast: fast statistical + neural baseline.
  • GluonTS (Amazon): DeepAR, MQ-RNN, foundation model Chronos.

Quy tắc lựa chọn: bắt đầu với statsmodels + Darts; chỉ tự viết PyTorch khi cần kiến trúc custom mà thư viện không cover.

20

Demo end-to-end trên sin wave

Toy task: sin wave + noise nhẹ. LSTM phải học chu kỳ và lọc noise; baseline naive sẽ kẹt vì sin có gradient cao tại zero-crossing.

import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
import matplotlib.pyplot as plt

# 1) Sinh dữ liệu: sin + noise
torch.manual_seed(0); np.random.seed(0)
N = 2000
t = np.arange(N)
series = np.sin(2 * np.pi * t / 50) + 0.1 * np.random.randn(N)

# 2) Split chronological
n_train = int(0.7 * N)
n_val   = int(0.15 * N)
train   = series[:n_train]
val     = series[n_train:n_train + n_val]
test    = series[n_train + n_val:]

# 3) Normalize trên TRAIN
mu, sigma = train.mean(), train.std()
train_n = (train - mu) / sigma
val_n   = (val   - mu) / sigma
test_n  = (test  - mu) / sigma

# 4) Dataset / DataLoader
class TSDataset(Dataset):
    def __init__(self, series, window=30):
        self.s = torch.as_tensor(series, dtype=torch.float32)
        self.w = window
    def __len__(self): return len(self.s) - self.w
    def __getitem__(self, i):
        return self.s[i:i+self.w].unsqueeze(-1), self.s[i+self.w:i+self.w+1]

WINDOW = 30
train_loader = DataLoader(TSDataset(train_n, WINDOW), batch_size=64, shuffle=True)
val_loader   = DataLoader(TSDataset(val_n,   WINDOW), batch_size=64, shuffle=False)
test_loader  = DataLoader(TSDataset(test_n,  WINDOW), batch_size=64, shuffle=False)

# 5) Model
class LSTMForecaster(nn.Module):
    def __init__(self, hidden=64, layers=2):
        super().__init__()
        self.lstm = nn.LSTM(1, hidden, layers, batch_first=True, dropout=0.1)
        self.fc = nn.Linear(hidden, 1)
    def forward(self, x):
        out, _ = self.lstm(x)
        return self.fc(out[:, -1, :])

device = "cuda" if torch.cuda.is_available() else "cpu"
model = LSTMForecaster().to(device)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.MSELoss()

# 6) Train
for epoch in range(20):
    model.train()
    for x, y in train_loader:
        x, y = x.to(device), y.to(device)
        loss = loss_fn(model(x), y)
        opt.zero_grad(); loss.backward(); opt.step()

    model.eval()
    with torch.no_grad():
        v = np.mean([loss_fn(model(x.to(device)), y.to(device)).item()
                     for x, y in val_loader])
    print(f"epoch {epoch:2d}  val_mse = {v:.4f}")

# 7) Predict trên test + so naive
model.eval()
preds, truths = [], []
with torch.no_grad():
    for x, y in test_loader:
        preds.append(model(x.to(device)).cpu().numpy())
        truths.append(y.numpy())
preds  = np.concatenate(preds).ravel() * sigma + mu     # inverse norm
truths = np.concatenate(truths).ravel() * sigma + mu

# Naive: predict x_{t+1} = x_t (trên scale gốc)
naive_pred = test[WINDOW-1:-1]
naive_true = test[WINDOW:]

def rmse(a, b): return np.sqrt(np.mean((a - b) ** 2))
print(f"LSTM  RMSE = {rmse(preds, truths):.4f}")
print(f"Naive RMSE = {rmse(naive_pred, naive_true):.4f}")

# 8) Visualize
plt.figure(figsize=(10, 4))
plt.plot(truths, label="true")
plt.plot(preds,  label="LSTM")
plt.legend(); plt.title("Sin + noise forecast"); plt.show()

Kết quả tham khảo trên seed mặc định: LSTM RMSE ≈ 0.12–0.15, naive RMSE ≈ 0.18–0.22 — LSTM beat naive vì pattern sin có thể học được. Trên chuỗi noise thuần (không sin), kỳ vọng LSTM không beat naive — đó là một test sanity quan trọng.

21

Bài tập

  1. Chạy demo mục 20 với 3 giá trị WINDOW = 10, 30, 100. Vẽ val_loss qua epoch cho từng giá trị. Window nào hội tụ nhanh nhất? Window nào val_loss thấp nhất? Vì sao?
  2. Thay sin wave bằng random walk \( x_t = x_{t-1} + \varepsilon_t, \varepsilon_t \sim \mathcal{N}(0, 1) \). Train LSTM và so naive. Bạn có beat naive không? Giải thích bằng lý thuyết martingale.
  3. Đổi horizon=10 trong LSTMForecaster để làm direct multi-step. So sánh RMSE từng step (step 1 vs step 10) — kỳ vọng tăng theo horizon.
  4. Cài đặt recursive_forecast ở mục 13 trên cùng model one-step (horizon=1). So sánh RMSE từng step với direct ở câu 3.
  5. Áp walk-forward validation: chia test thành 10 vòng, mỗi vòng re-train (hoặc fine-tune 5 epoch) trên dữ liệu tới thời điểm đó. Tính RMSE trung bình qua 10 vòng và so với RMSE 1-shot.
  6. Tạo dataset multivariate: x = sin(t) + noise, y = cos(t) + noise, predict x từ \( (x, y) \) trong cửa sổ 30 step. So với LSTM univariate (chỉ dùng x). Có cải thiện không? Tại sao?
  7. Cài đặt ARIMA baseline bằng statsmodels.tsa.arima.model.ARIMA với order \( (5, 1, 0) \) trên chuỗi sin của mục 20. So RMSE với LSTM. Nhận xét.
  8. Tính SMAPE cho LSTM và naive trên test. Vẽ residual \( y - \hat{y} \) theo thời gian — nếu residual có pattern (vd autocorrelation), model còn dư signal chưa khai thác.
Gợi ý đáp án ngắn
  1. Window quá nhỏ thiếu chu kỳ (sin chu kỳ 50 step → window 10 không thấy chu kỳ); window 100 lan gradient dài hơn → chậm hội tụ nhưng có thể val thấp hơn nếu data đủ. Sweet spot quanh 30–50.
  2. Random walk là martingale → optimal forecast là chính \( x_t \). LSTM không thể beat naive về kỳ vọng; nếu beat tức là data leak hoặc overfit pattern noise giả.
  3. RMSE thường tăng monotone theo horizon vì uncertainty tích luỹ.
  4. Recursive thường có RMSE step 1 ≈ direct, nhưng step 10 lệch nhiều hơn do error compounding.
  5. Walk-forward RMSE thường cao hơn 1-shot vì có vòng "khó", nhưng phản ánh production sát hơn.
  6. Nếu \( y \) thật sự độc lập với \( x \) (chỉ noise), multivariate không cải thiện. Nếu \( y \) lagged-correlate với \( x \), multivariate giúp.
  7. Trên sin + noise tuần hoàn, ARIMA(5,1,0) tốt nhưng LSTM thường beat vì capture được non-linearity.
  8. Residual có autocorrelation rõ → cần model phức tạp hơn hoặc feature thêm; trắng nhiễu (Ljung-Box p > 0.05) → model đã capture hết signal.
22

Tóm tắt

  • Time series forecasting biến \( x_1, \ldots, x_t \) thành dự đoán \( x_{t+1}, \ldots, x_{t+k} \); phân loại theo horizon (one-step / multi-step) và số biến (uni / multivariate).
  • Sliding window cắt chuỗi dài thành mẫu supervised \( (X^{(i)}, y^{(i)}) \), window size \( w \) là hyperparameter chính.
  • Chronological split (70 / 15 / 15) bắt buộc — random shuffle gây data leak. Normalize statistics fit chỉ trên train.
  • TimeSeriesDataset + DataLoader(shuffle=True) với mẫu đã cắt window là pattern chuẩn cho PyTorch.
  • LSTMForecaster: nn.LSTM với batch_first=True + linear head trên output[:, -1, :]; loss MSE, optimizer Adam, clip gradient norm.
  • Walk-forward validation simulate production: train tới \( t \), predict \( t+1 \), thêm \( x_{t+1} \) thật vào train, lặp. Metric vững hơn 1-shot split.
  • Multivariate: input shape \( (B, T, d) \), normalize từng biến riêng trên train.
  • Multi-step: direct (output \( k \) neuron), recursive (feed prediction về input — error compounding), seq2seq (B40).
  • Metric chính: MSE, RMSE, MAPE, SMAPE; quantile loss cho prediction interval.
  • Mọi báo cáo bắt buộc kèm baseline naive / seasonal naive / ARIMA — nếu LSTM không beat thì pipeline có vấn đề.
  • Pitfall hay gặp: data leak (shuffle, normalize toàn chuỗi), inverse transform sai, target leakage, overfit, báo cáo không có baseline.
  • Stationarity ảnh hưởng độ khó: differencing, log, seasonal differencing để stationarize trước khi model.
  • Modern alternatives: Informer, Autoformer, TFT, N-BEATS / N-HiTS, PatchTST; foundation model Chronos / TimeGPT / Moirai / TimesFM cho zero-shot.
  • Library: Darts, PyTorch Forecasting, statsmodels, GluonTS, Nixtla — dùng cho production thay vì tự viết pipeline.