Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,41 @@ make verify

| 模型 | MAE | RMSE | MAPE | sMAPE* | 数据集 |
|------|-----|------|------|--------|--------|
| XGBoost | **0.258** | **0.382** | **12.03%** | 39.48% | 全量(230 万行训练 + 尾部 16 天验证) |
| XGBoost | **0.257** | **0.381** | **12.02%** | 39.47% | 全量(230 万行训练 + 尾部 16 天验证) |
| Prophet | — | — | — | — | *(需 pystan 编译工具链;已在 Docker/Linux CI 验证通过)* |
| LSTM | 0.265 | 0.398 | 12.30% | 39.67% | 全量(同上,GPU 训练) |
| Transformer | 0.284 | 0.414 | 12.80% | 40.12% | 全量(同上,GPU 训练) |
| LSTM | 0.269 | 0.399 | 12.71% | 40.66% | 全量(同上,GPU 训练) |
| Transformer | 0.282 | 0.410 | 12.76% | 40.61% | 全量(同上,GPU 训练) |

> LSTM 与 Transformer 均已按三处泄漏修复重跑(验证目标过滤 + 因果油价填充 + oil_lag 按日序列)。修复逻辑本身亦通过 `tests/test_pipeline.py::TestLeakagePrevention` 验证。最新值以 `reports/model_results.json` 为准。

> 以上均为在 **log1p(sales) 空间**评估的真实结果(非预期值)。LSTM/Transformer 在 RTX 4060 上用 PyTorch Lightning 训练(bf16 混合精度,batch=1024,early stopping),指标写入 `reports/model_results.json`。

> **诚实的结论**:在特征工程充分(lag/rolling/节假日/油价等 40+ 维特征)的表格类时序数据上,**XGBoost 略优于 DL 模型**。这是符合预期的——梯度提升对结构化特征利用更高效,而 DL 的优势(自动特征学习、长程依赖)在本数据集已被手工特征覆盖。LSTM 接近 XGBoost(MAE 差 0.007),Transformer 稍逊。改进方向:DL 模型可尝试更长的训练、更大的 d_model、或 N-BEATS/TFT 等专用时序架构。
> **诚实的结论**:在特征工程充分(lag/rolling/节假日/油价等 40+ 维特征)的表格类时序数据上,**XGBoost 略优于 DL 模型**。这是符合预期的——梯度提升对结构化特征利用更高效,而 DL 的优势(自动特征学习、长程依赖)在本数据集已被手工特征覆盖。LSTM 接近 XGBoost(MAE 差 0.012),Transformer 稍逊。改进方向:DL 模型可尝试更长的训练、更大的 d_model、或 N-BEATS/TFT 等专用时序架构。

### 训练速度(230 万样本 / RTX 4060)

早期单线程 DataLoader(`num_workers=0`)让 GPU 长时间空等,是 DL 训练慢的主因。现开启多进程数据加载后单 epoch 显著加速:

| 优化项 | 说明 |
|--------|------|
| `num_workers=4 + persistent_workers` | 4 进程并行 `__getitem__`,worker 只 spawn 一次并跨 epoch 复用(不再每 epoch 重复拷贝数据集) |
| `prefetch_factor=4` | 预取队列保持 GPU 不空载 |
| `pin_memory + non_blocking` | host→device 拷贝与计算重叠 |
| `cudnn.benchmark=True` | 固定 28 步窗口下自动选最快 kernel |
| `bf16-mixed` precision | Ada Tensor Core 加速 |
| `batch_size=1024` | 降低 kernel-launch 开销主导(bs=128 时 72s/epoch → bs=1024 时 38s/epoch) |

> 可通过 CLI 微调:`python scripts/train_lstm.py --num_workers 8 --batch_size 2048`。若长训练被中断,`--resume` 会从 `reports/checkpoints/` 的最新 checkpoint 续训。

### 防泄漏说明(重要)

时序建模中泄漏会系统性高估指标。本管线修正了三处:

- **油价滞后(oil_lag)按日序列计算**:早期版本对长表直接 `shift(1)`,会跨 (store,family) 组边界——第一个序列的滞后是 NaN,后续序列的"滞后"指向上一组同日值,而非"昨日的油价"。现改为在 date-unique 帧上算 `shift(1)` 再 merge 回,保证 `oil_lag_1` 始终是前一日的油价。
- **油价缺失因果填充**:早期版本用 `interpolate(method="linear")`,这是双向插值——某日的油价会被用**未来**油价插值出来,泄漏到验证窗。现改为仅 `ffill().bfill()`(因果前向填充,bfill 只补首段无前值的行),任意一日的油价绝不来自更晚的观测。
- **DL 验证目标过滤**:为给验证集前 28 天提供窗口上下文,`load_and_split` 会在验证帧前拼接 28 天训练尾部作为窗口输入。早期版本未过滤,导致这 28 天的训练期日期被当作 DL 的预测目标混入验证 loss/MAE。现 `TimeSeriesDataset(min_target_date=val_start)` 只发射目标日期 ≥ val_start 的样本,验证集上下文行仅作窗口输入、绝不作预测目标。

> `predict.py` 的 DL 推理路径同步修正:以前传整张 df 给 `TimeSeriesDataset` 再用任意尾部切片对齐,现在只传"上下文 + 验证窗"并按 `min_target_date` 过滤,预测与验证行一一对应。

## 数据说明

Expand Down
11 changes: 8 additions & 3 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,16 @@
VAL_DAYS = 16

# ── Deep learning constants ───────────────────────────────────────
# batch_size raised to 1024: on a 4060 8GB the model uses <1GB even at this
# size, and larger batches cut the per-epoch step count (17.7k → 2.2k) which
# removes the kernel-launch overhead that dominated training time at bs=128
# batch_size=1024: on a 4060 8GB the model uses <1GB even at this size, and
# larger batches cut the per-epoch step count (17.7k → 2.2k), removing the
# kernel-launch overhead that dominated training time at bs=128
# (benchmark: bs=128 → 72s/epoch, bs=1024 → 38s/epoch). lr scaled up to match
# (1e-3 was tuned for bs=128; larger batches tolerate a larger step).
#
# The OTHER big speed lever is DataLoader parallelism (see make_loaders in
# train_common.py): num_workers=4 + persistent_workers cut per-epoch wall time
# further by feeding the GPU from 4 processes instead of starving it on the
# main thread. Tune via --num_workers at the CLI.
BATCH_SIZE = 1024
MAX_EPOCHS = 100
LEARNING_RATE = 3e-3
Expand Down
30 changes: 14 additions & 16 deletions run_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
os.environ.setdefault("PYTHONUTF8", "1")


def run(cmd: str, cwd: Path | None = None):
def run(cmd: list[str], cwd: Path | None = None):
print(f"\n{'=' * 60}")
print(f">>> {cmd}")
print(f">>> {' '.join(cmd)}")
print("=" * 60)
result = subprocess.run(cmd, shell=True, cwd=cwd)
# cmd is a list; no shell=True — avoids shell-injection surface and
# correctly handles paths with spaces.
result = subprocess.run(cmd, cwd=cwd)
if result.returncode != 0:
print(f"WARNING: Command failed with exit code {result.returncode}")
return False
Expand Down Expand Up @@ -59,25 +61,21 @@ def main():
max_epochs = args.max_epochs
patience = args.patience

dl_flags = []
dl_flags: list[str] = []
if max_epochs is not None:
dl_flags.append(f"--max_epochs {max_epochs}")
dl_flags += ["--max_epochs", str(max_epochs)]
if patience is not None:
dl_flags.append(f"--patience {patience}")
dl_suffix = " ".join(dl_flags)
dl_flags += ["--patience", str(patience)]

here = Path(__file__).resolve().parent

steps = [
("Preprocessing", "python scripts/preprocess.py"),
("Feature Engineering", "python scripts/feature_engineering.py"),
("Baseline Training (XGBoost)", "python scripts/train_baseline.py"),
("LSTM Training", f"python scripts/train_lstm.py{dl_suffix and ' ' + dl_suffix}"),
(
"Transformer Training",
f"python scripts/train_transformer.py{dl_suffix and ' ' + dl_suffix}",
),
("Evaluation", "python scripts/evaluate.py"),
("Preprocessing", ["python", "scripts/preprocess.py"]),
("Feature Engineering", ["python", "scripts/feature_engineering.py"]),
("Baseline Training (XGBoost)", ["python", "scripts/train_baseline.py"]),
("LSTM Training", ["python", "scripts/train_lstm.py", *dl_flags]),
("Transformer Training", ["python", "scripts/train_transformer.py", *dl_flags]),
("Evaluation", ["python", "scripts/evaluate.py"]),
]

print("Multivariate Time Series Forecasting - Full Pipeline")
Expand Down
32 changes: 23 additions & 9 deletions scripts/audit_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@


def read_readme_metric(readme_path: Path, metric_name: str) -> float | None:
"""Extract a numeric metric from README.md table rows.
"""Extract the first number following ``metric_name`` in README.md.

Looks for patterns like `| XGBoost | **0.256** | ...`
Handles table rows like ``| XGBoost | **0.257** | ...`` as well as plain
text. Returns ``None`` if no number follows the marker.
"""
text = readme_path.read_text(encoding="utf-8")
pattern = rf"\*\*{re.escape(metric_name)}\*\*\*.*?(\d+\.\d+)"
match = re.search(pattern, text)
# Match the metric name, then the first number that appears after it
# (allowing markdown emphasis like ** and | between them).
pattern = rf"{re.escape(metric_name)}.*?(\d+\.\d+)"
match = re.search(pattern, text, flags=re.DOTALL)
if match:
return float(match.group(1))
return None
Expand Down Expand Up @@ -143,11 +146,22 @@ def ok(cond, msg):

if xgb_entry and xgb_entry.get("mae") is not None:
actual_mae = xgb_entry["mae"]
# README claims XGBoost MAE=0.256
ok(
abs(actual_mae - 0.256) < 0.01,
f"XGBoost MAE: README ~0.256 vs actual {actual_mae:.4f} (diff={abs(actual_mae - 0.256):.4f})",
)
# Cross-reference against the README value rather than a hardcoded
# literal, so this audit fails loudly if README and code drift.
readme_path = root / "README.md"
readme_xgb_mae = read_readme_metric(readme_path, "XGBoost")
if readme_xgb_mae is not None:
ok(
abs(actual_mae - readme_xgb_mae) < 0.01,
f"XGBoost MAE: README {readme_xgb_mae} vs actual {actual_mae:.4f} "
f"(diff={abs(actual_mae - readme_xgb_mae):.4f})",
)
else:
ok(
False,
"Could not parse XGBoost MAE from README.md — add it to the "
"results table so this audit can cross-check.",
)

# ── Check 5: config.py paths are consistent ──
print("\n[5] Config path consistency")
Expand Down
15 changes: 13 additions & 2 deletions scripts/feature_engineering.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,20 @@ def build_features(df: pd.DataFrame) -> pd.DataFrame:
print(" Promo features ...")
df = create_promo_features(df)

# Oil price lag
# Oil price lag. Oil is a SINGLE daily series shared across all
# (store,family) series, so the lag must be computed on a date-unique frame
# and merged back — NOT as a plain shift(1) over the long frame, which would
# shift across (store,family) group boundaries and point at the previous
# group's same-date value instead of "yesterday's oil".
if "dcoilwtico" in df.columns:
df["oil_lag_1"] = df["dcoilwtico"].shift(1)
oil_daily = (
df[["date", "dcoilwtico"]]
.drop_duplicates(subset=["date"])
.sort_values("date")
.reset_index(drop=True)
)
oil_daily["oil_lag_1"] = oil_daily["dcoilwtico"].shift(1)
df = df.merge(oil_daily[["date", "oil_lag_1"]], on="date", how="left")

# Drop rows with too many NaNs (mainly from lags)
print(" Dropping rows with missing features ...")
Expand Down
43 changes: 29 additions & 14 deletions scripts/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,27 @@


class TimeSeriesDataset(Dataset):
"""PyTorch Dataset for sliding-window time series."""
"""PyTorch Dataset for sliding-window time series.

``min_target_date`` (optional): if set, only samples whose PREDICTION TARGET
(the window's last row, ``end_idx``) falls on or after this date are emitted.
This is used by the validation dataset to drop samples whose target date is
actually in the training period — those rows are present only as window
CONTEXT (so the first true-validation targets have enough lookback), not as
prediction targets. Without this filter the validation loss/metrics mix in
train-period targets and overstate DL performance.
"""

def __init__(
self,
df: pd.DataFrame,
seq_length: int = SEQ_LENGTH,
encoder: dict | None = None,
scalers: dict | None = None,
min_target_date: pd.Timestamp | None = None,
):
self.seq_length = seq_length
self.min_target_date = min_target_date
self.df = df.sort_values(["store_nbr", "family", "date"]).reset_index(drop=True)

# Encode categoricals
Expand All @@ -52,7 +63,8 @@ def __init__(
# select_dtypes(include=[np.number]).
_exclude = {"date", "sales", "sales_log", "id", "store_nbr", "family"}
numeric_cols = [
c for c in self.df.columns
c
for c in self.df.columns
if c not in _exclude and pd.api.types.is_numeric_dtype(self.df[c])
]
self.numeric_cols = numeric_cols
Expand All @@ -73,27 +85,30 @@ def __init__(
# is ~50× faster and the single-threaded loader keeps up with the GPU.
self.n_stores = self.df["store_nbr"].nunique()
self.n_families = self.df["family"].nunique()
self.groups_cat = [] # list of (len_i, 2) int64 arrays per series
self.groups_num = [] # list of (len_i, num_numeric) float32 arrays
self.groups_y = [] # list of (len_i,) float32 arrays (sales_log) per series
self.groups_cat = [] # list of (len_i, 2) int64 arrays per series
self.groups_num = [] # list of (len_i, num_numeric) float32 arrays
self.groups_y = [] # list of (len_i,) float32 arrays (sales_log) per series
self.groups_date = [] # list of (len_i,) datetime64 arrays per series
for _, g in self.df.groupby(["store_nbr", "family"], sort=False):
g = g.reset_index(drop=True)
self.groups_cat.append(
g[["store_nbr", "family"]].to_numpy(dtype=np.int64)
)
self.groups_num.append(
g[self.numeric_cols].to_numpy(dtype=np.float32)
)
self.groups_y.append(
g["sales_log"].to_numpy(dtype=np.float32)
)
self.groups_cat.append(g[["store_nbr", "family"]].to_numpy(dtype=np.int64))
self.groups_num.append(g[self.numeric_cols].to_numpy(dtype=np.float32))
self.groups_y.append(g["sales_log"].to_numpy(dtype=np.float32))
self.groups_date.append(g["date"].to_numpy())
# Free the raw frame — all data now lives in the per-series arrays.
self.df = None

self.samples = []
for gid in range(len(self.groups_cat)):
n = len(self.groups_cat[gid])
dates = self.groups_date[gid]
for i in range(seq_length, n):
# If a min_target_date is set (validation set with prepended
# context), drop samples whose target date is before the true
# validation start — those are train-period targets included
# only to give the first validation windows enough lookback.
if min_target_date is not None and pd.Timestamp(dates[i]) < min_target_date:
continue
self.samples.append((gid, i))

def __len__(self):
Expand Down
23 changes: 19 additions & 4 deletions scripts/metrics_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,29 @@


def smape(y_true, y_pred):
"""Symmetric Mean Absolute Percentage Error."""
return 100 * np.mean(2 * np.abs(y_true - y_pred) / (np.abs(y_true) + np.abs(y_pred) + 1e-8))
"""Symmetric Mean Absolute Percentage Error.

The denominator ``|y| + |ŷ|`` can approach zero when both are tiny (common
in log1p-space sales near zero), which inflates the error toward 200% under
the raw formula. We clip the denominator to a small positive floor rather
than just adding an epsilon to the exact-zero case, so near-zero pairs do
not dominate the average.
"""
denom = np.abs(y_true) + np.abs(y_pred)
denom = np.clip(denom, 1e-8, None)
return 100 * np.mean(2 * np.abs(y_true - y_pred) / denom)


def mape(y_true, y_pred):
"""Mean Absolute Percentage Error (skips zeros in true values)."""
"""Mean Absolute Percentage Error (skips zeros in true values).

Returns 0.0 when no true values are nonzero (rather than NaN), so the value
serializes cleanly to JSON and never silently propagates.
"""
mask = y_true != 0
return np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100
if not mask.any():
return 0.0
return float(np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100)


def time_train_val_split(df: pd.DataFrame, val_days: int):
Expand Down
Loading
Loading