# Dự báo nhu cầu: dựng baseline và backtest trước khi chọn model

> Vài dòng code chép lại tuần trước có thể đánh bại model khách sắp deploy, và bạn nên biết điều đó trước khi họ xếp ca theo nó.

Bản gốc: https://fdetimes.net/vi/bach-khoa/du-bao-nhu-cau-baseline-truoc-model-sau-cho-khach-ban-le-logistics/

Thử hình dung tuần đầu của bạn tại một công ty logistics. Giám đốc vận hành mở slide: model dự báo số đơn của kho chỉ sai 2,4 đơn mỗi ngày, và chị muốn dùng nó để xếp ca từ tháng sau.

Bạn hỏi con số đó đo trên dữ liệu nào, và câu trả lời là chính dữ liệu model đã học. Nó chỉ cho biết model nhớ quá khứ tốt đến đâu, chưa nói được gì về tuần tới.

Tháng sau, cả kho sẽ đi làm theo con số đó, nên bạn cần làm rõ nó trước khi ca được chốt. Sai ở đây là thừa người hoặc thiếu xe, không phải lệch một chỉ số trên dashboard, và đừng mặc định đội của khách đã kiểm tra phần này.

## Chép lại quá khứ cũng là một model

Có hai baseline bạn nên dựng trong buổi đầu. Với naïve, mọi dự báo đều bằng giá trị quan sát cuối cùng. Với seasonal naïve, mỗi dự báo bằng giá trị gần nhất của cùng mùa, tức cùng thứ trong tuần trước nếu chu kỳ là một tuần.

Chúng không phải đáp án cuối. Giáo trình *Forecasting: Principles and Practice* (FPP3) dùng các phương pháp đơn giản làm mốc để bảo đảm phương pháp mới thực sự tốt hơn, và giá trị của chúng nằm ở đó: trước khi có baseline, câu "model sai 2,4 đơn" chưa nói được gì.

Giờ tính thử với số liệu giả định. Tuần 100, kho nhận 100, 95, 98, 102, 130, 180, 170 đơn từ thứ Hai đến Chủ nhật. Tuần 101, thực tế là 104, 99, 96, 106, 128, 186, 165, và cần dự báo cả tuần này ngay khi tuần 100 vừa khép lại.

Naïve lấy số của Chủ nhật, nên dự báo 170 đơn cho cả bảy ngày. Tổng sai số tuyệt đối là 338, tức MAE khoảng 48,3 đơn mỗi ngày. Seasonal naïve chép nguyên tuần 100 xuống, tổng sai số chỉ 27, MAE khoảng 3,9.

## Sai số phải đổi được ra người và xe

Khách không xếp ca theo MAE, nên hãy đổi nó thành đơn vị của họ. Giả sử một nhân viên soạn được 20 đơn mỗi ca. Với naïve, sáng thứ Hai dự báo dư 66 đơn, tức gọi thừa hơn ba người; với seasonal naïve, sai số trung bình 3,9 đơn còn chưa bằng một người.

Với xe cũng tính như vậy. Giả sử mỗi xe chở 30 đơn: 66 đơn dư của naïve là hơn hai xe đặt thừa, còn ngày lệch nhiều nhất của seasonal naïve chỉ 6 đơn, bằng một phần năm xe.

Muốn đổi được như vậy, ngay từ đầu bạn phải hỏi người vận hành: dự báo này phục vụ quyết định nào, chốt trước bao lâu, ở cấp nào? Nếu ca được chốt trước 7 ngày thì horizon là 7 ngày, và mọi phép đo phải dùng đúng horizon đó.

FPP3 cũng lưu ý sai số một bước có thể không phù hợp khi cần dự báo nhiều bước, và quy trình đánh giá có thể chỉnh để đo sai số ở bước thứ h. Sai số dự báo trước một ngày không giúp được người xếp ca từ tuần trước.

## Backtest chỉ có một luật cứng

Rob Hyndman mô tả cách đánh giá này bằng hình ảnh điểm gốc của dự báo trượt dần về phía trước theo thời gian. Luật của nó rất gọn: tập huấn luyện chỉ gồm những quan sát xảy ra trước quan sát dùng để kiểm tra. Sai số sau đó được lấy trung bình qua các tập kiểm tra.

Thử với 104 tuần lịch sử và bốn cửa sổ 7 ngày. Phép tính vừa rồi chính là cửa sổ đầu: học đến hết tuần 100 và được chấm trên tuần 101. Cửa sổ cuối học đến tuần 103, được chấm trên tuần 104, nên tập huấn luyện lớn dần qua từng lần, đúng như cách `TimeSeriesSplit` của scikit-learn hoạt động.

Đoạn code chỉ khoảng 20 dòng. Viết tay một lần để thấy rõ chỗ dữ liệu bị cắt, và để hàm dự báo là một tham số, nhờ vậy mọi phương pháp đều được chấm trên cùng những cửa sổ:

```python
import numpy as np
import pandas as pd

def naive(train, h):
    return [train.iloc[-1]] * h

def seasonal_naive(train, h, season=7):
    # giả định h <= season
    return train.iloc[-season:].values[:h]

def backtest(y, forecast_fn, h=7, n_windows=4):
    # y: pd.Series theo ngày, đã sắp theo thời gian, không thiếu ngày
    rows = []
    for w in range(n_windows, 0, -1):
        cutoff = len(y) - w * h
        train = y.iloc[:cutoff]              # chỉ dữ liệu trước điểm gốc
        test = y.iloc[cutoff:cutoff + h]
        forecast = np.asarray(forecast_fn(train, h))
        mae = abs(test.values - forecast).mean()
        rows.append({"bat_dau": y.index[cutoff], "mae": mae})
    return pd.DataFrame(rows)
```

Mỗi lần gọi `backtest` trả về một DataFrame riêng, nên hãy ghép chúng theo ngày bắt đầu để có hai cột so sánh:

```python
so_sanh = backtest(y, naive).merge(
    backtest(y, seasonal_naive),
    on="bat_dau",
    suffixes=("_naive", "_seasonal"),
)
```

Để thử model của khách, hãy gói nó thành một hàm nhận `train` và `h`, huấn luyện lại trên `train` ở mỗi cửa sổ rồi trả về `h` giá trị dự báo. Sau đó ghép thêm kết quả của nó theo cùng cách.

Khi khách có nhiều chuỗi, đừng tự viết tiếp vòng lặp này. Tài liệu StatsForecast của Nixtla mô tả cross-validation là một cửa sổ trượt trên lịch sử, dự báo giai đoạn ngay sau nó, còn `step_size` là khoảng cách giữa các cửa sổ, tức bao lâu chạy dự báo một lần. Hãy đặt nó khớp với nhịp chốt ca của khách.

Amazon Forecast cũng đo theo cách này: tài liệu của dịch vụ dùng các lần backtest để đánh giá độ chính xác của predictor. Bốn cửa sổ trong bảng dưới đây được rút gọn cho dễ theo dõi; với 104 tuần lịch sử, bạn còn đủ dữ liệu để chạy nhiều hơn trước khi kết luận.

| Cửa sổ 7 ngày | MAE seasonal naïve | MAE model của khách |
|---|---|---|
| 1 (tuần 101) | 3,9 | 4,4 |
| 2 (tuần 102) | 4,6 | 4,9 |
| 3 (tuần 103) | 4,2 | 4,1 |
| 4 (tuần 104) | 5,3 | 6,6 |
| Trung bình | 4,5 | 5,0 |

Vẫn là số giả định, nhưng hãy đọc nó như khi đứng trước khách. Model từng khoe 2,4 đơn nay sai trung bình 5,0 đơn khi phải dự báo thật, thua baseline 4,5 và chỉ thắng một cửa sổ trong bốn. Cửa sổ 4 là chỗ nên mở ra xem đầu tiên, vì đó là khoảng cách lớn nhất.

Bốn tuần này có thể quá yên ả, nên đừng chốt vội. Hãy chạy thêm các cửa sổ phủ những tuần cao điểm trong 104 tuần lịch sử, rồi mới đưa ra khuyến nghị.

Nếu kết quả vẫn như vậy, câu nên nói với giám đốc vận hành rất ngắn: "Model hiện chưa thắng việc chép lại tuần trước.

Tuần tới có thể xếp ca bằng baseline, còn model sẽ được đưa vào khi thắng nó trên cùng phép đo này." Bạn không phá dự án của ai, mà đang đưa cho khách một phương án chạy được ngay và một mục tiêu đo được.

Nếu kết quả ngược lại và model thắng ở hầu hết các cửa sổ, hãy đổi phần chênh lệch thành số người, số xe rồi mới bàn chuyện deploy. Khi đó khách biết mình được gì so với cách làm rẻ nhất.

**Điểm mấu chốt:** Model chưa thắng được cách chép lại tuần trước thì chưa có gì để deploy.

## MASE dưới 1 chưa phải chiến thắng

Trong ví dụ này, MASE chia sai số của model cho sai số trung bình của naïve một bước trên dữ liệu huấn luyện. Theo FPP3, kết quả dưới 1 nghĩa là tốt hơn naïve một bước trung bình, trên 1 nghĩa là kém hơn.

Hyndman và Koehler đề xuất MASE làm thước đo chuẩn khi so độ chính xác giữa nhiều chuỗi. Chỉ số này tiện thật, nhưng con số dưới 1 rất dễ khiến người đọc mừng sớm.

Lấy cửa sổ 1, học đến hết tuần 100 và chấm trên tuần 101. Để dễ tính, giả định mức chênh lệch giữa hai ngày liên tiếp trên toàn bộ dữ liệu học đúng bằng mức của tuần 100: sáu chênh lệch cộng lại 100, trung bình khoảng 16,7.

Model sai 4,4 đơn nên đạt MASE khoảng 0,26, nghe rất ấn tượng. Seasonal naïve sai 3,9 đơn và đạt khoảng 0,23, tức vẫn tốt hơn model.

Con số 0,26 không chứng minh model giỏi. Chuỗi này có chu kỳ tuần rõ, các cú nhảy cuối tuần làm mẫu số một bước to lên, nên đạt dưới 1 khá dễ.

Vì thế, luôn báo cáo MASE cạnh con số của seasonal naïve, đừng để nó đứng một mình. Khi đọc số từ công cụ của bạn, cũng nên kiểm tra nó đang dùng mẫu số nào trước khi so với bài này.

## Những lỗi khiến backtest đẹp mà vô dụng

Lỗi đầu tiên là dùng K-fold ngẫu nhiên vì đã quen tay. Tài liệu scikit-learn giải thích vì sao cách này sai với dữ liệu có thứ tự thời gian: model sẽ học từ tương lai rồi được chấm trên quá khứ.

Cắt dữ liệu đúng rồi vẫn có thể rò rỉ qua feature. Trung bình trượt hay chuẩn hóa tính trên toàn chuỗi đã mang tương lai vào quá khứ; lịch khuyến mãi chốt muộn cũng vậy nếu lúc dự báo thật chưa có. Hãy hỏi với từng feature: vào đúng ngày chốt ca, con số này đã tồn tại chưa?

Một cái bẫy khác là chọn cửa sổ quá yên ả. Vài tuần bình thường không nói được model sẽ ra sao vào tuần cao điểm, nên backtest cần phủ cả mùa cao điểm và những giai đoạn vận hành thay đổi.

Đọc kết quả cũng có bẫy riêng. Một cửa sổ duy nhất có thể chỉ là một tuần may mắn, còn điểm trung bình có thể giấu mất cửa sổ tệ nhất, nên hãy xem cả bảng theo từng cửa sổ trước khi kết luận.

Bẫy còn lại nằm trong chính dữ liệu của khách. Doanh số bằng 0 trong ngày hết hàng không có nghĩa là nhu cầu bằng 0, và bạn sẽ dạy model sai nếu không gắn cờ những ngày đó. Hãy đối chiếu với lịch sử tồn kho trước khi tin vào chuỗi bán hàng.

## Làm đúng thứ tự, rồi ghi số thật vào CV

Ở tuần đầu, hãy chốt horizon với người vận hành trước, rồi cho baseline chạy được trước khi ai bắt đầu bàn đến kiến trúc model.

Khi viết CV, đừng chỉ liệt kê tên thuật toán. Hãy mô tả việc đã làm, chẳng hạn "backtest nhiều cửa sổ ở horizon 7 ngày, giảm MAE so với seasonal naïve", kèm con số thật của bạn.

Khi đọc JD có forecasting hay supply chain, hãy hỏi họ đang so model với baseline nào; trong phỏng vấn, tự nêu baseline trước khi được hỏi.

Lần tới có người khoe một con số sai số đẹp, đừng hỏi họ dùng model gì. Hãy hỏi: so với tuần trước thì sao?

**Thử ngay tuần này:**

- Lấy một chuỗi bán hàng theo ngày mà bạn có quyền dùng, chạy backtest 7 ngày với 4 cửa sổ cho naïve và seasonal naïve, rồi viết một câu về số nhân sự hoặc số xe mà sai số đó tương ứng.
- Đưa thêm một model bất kỳ vào đúng các cửa sổ đó và ghi lại nó thắng baseline ở bao nhiêu cửa sổ, không chỉ ở điểm trung bình.

## Nguồn

- [Forecasting: Principles and Practice (3rd ed.), ch. 5 – Some simple forecasting methods](https://otexts.com/fpp3/simple-methods.html)

- [Cross-validation for time series (Hyndsight)](https://robjhyndman.com/hyndsight/tscv/)

- [Forecasting: Principles and Practice (3rd ed.), ch. 5 – Time series cross-validation](https://otexts.com/fpp3/tscv.html)

- [Forecasting: Principles and Practice (3rd ed.), ch. 5 – Evaluating point forecast accuracy](https://otexts.com/fpp3/accuracy.html)

- [Another Look at Measures of Forecast Accuracy](https://econpapers.repec.org/paper/mshebswps/2005-13.htm)

- [Time Series Cross-Validation: Evaluating a Model on Past Data (StatsForecast tutorial)](https://nixtlaverse.nixtla.io/statsforecast/docs/tutorials/crossvalidation.html)

- [TimeSeriesSplit — scikit-learn 1.9.1 documentation](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html)

- [PredictorExecutionDetails – Amazon Forecast](https://docs.aws.amazon.com/forecast/latest/dg/API_PredictorExecutionDetails.html)
