File size: 8,102 Bytes
13fc29d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
"""
Long Chronos-2 LoRA fine-tuning with large context window, tuned for local
CPU/memory. Single training run (Chronos does not support resuming LoRA fit).

Usage (from project root):
  PYTHONPATH=. python scripts/run_chronos_long_training.py [--device cpu] [--num-steps 4000]

Uses: context_days=28, batch_size=16 by default. Set --num-steps for total steps.
"""

from __future__ import annotations

import os
import sys
import threading
from datetime import datetime, timezone
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

# Limit CPU threads to avoid oversubscription (set before importing torch)
if "OMP_NUM_THREADS" not in os.environ:
    try:
        import multiprocessing
        n = multiprocessing.cpu_count()
        os.environ["OMP_NUM_THREADS"] = str(min(n, 10))
    except Exception:
        pass

from config.settings import OUTPUTS_DIR
from src.chronos_forecaster import (
    ChronosForecaster,
    STEPS_PER_DAY,
)
from sklearn.metrics import mean_absolute_error
import numpy as np
import pandas as pd


def _quick_val_mae(forecaster: ChronosForecaster, df: pd.DataFrame, train_ratio: float, n_windows: int = 5) -> float:
    """Compute MAE on first n_windows test windows (daytime-only) for convergence check."""
    sparse = forecaster.load_sparse_data()
    daytime_ts = set(sparse["timestamp_utc"])
    split_idx = int(len(df) * train_ratio)
    test_starts = list(range(split_idx, len(df) - STEPS_PER_DAY, STEPS_PER_DAY))[:n_windows]
    actual_list, pred_list = [], []
    for start_idx in test_starts:
        f = forecaster.forecast_day(df, start_idx, STEPS_PER_DAY, covariate_mode="all")
        actual_slice = df.iloc[start_idx : start_idx + STEPS_PER_DAY]
        daytime_mask = actual_slice["timestamp_utc"].isin(daytime_ts).values[:len(f)]
        if daytime_mask.sum() < 5:
            continue
        actual_list.append(actual_slice["A"].values[:len(f)][daytime_mask])
        pred_list.append(np.clip(f["median"].values[daytime_mask], 0, None))
    if not actual_list:
        return float("nan")
    return float(mean_absolute_error(np.concatenate(actual_list), np.concatenate(pred_list)))


def main() -> None:
    import argparse
    parser = argparse.ArgumentParser(description="Chronos-2 long LoRA training (single run)")
    parser.add_argument("--device", default="cpu", help="torch device (cpu or mps)")
    parser.add_argument("--context-days", type=int, default=28, help="context window in days")
    parser.add_argument("--batch-size", type=int, default=16, help="batch size (safe for 32GB RAM)")
    parser.add_argument("--num-steps", type=int, default=4000, help="total training steps")
    parser.add_argument("--learning-rate", type=float, default=1e-5, help="learning rate")
    parser.add_argument("--progress-minutes", type=int, default=10, help="print timestamp and progress every N minutes")
    parser.add_argument("--output-dir", type=str, default=None, help="output dir for checkpoints")
    args = parser.parse_args()

    output_dir = args.output_dir or str(OUTPUTS_DIR / "chronos_finetuned_long")
    OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)

    print("Loading data...")
    forecaster = ChronosForecaster(device=args.device, context_days=args.context_days)
    df = forecaster.load_data()
    print(f"  Grid: {len(df)} rows, context={args.context_days}d ({forecaster.context_steps} steps)")
    train_ratio = 0.75
    split_idx = int(len(df) * train_ratio)

    print("\nBaseline (zero-shot) validation MAE (5 windows)...")
    baseline_mae = _quick_val_mae(forecaster, df, train_ratio, n_windows=5)
    print(f"  {baseline_mae:.4f}")

    print(f"\nLoRA fine-tuning: {args.num_steps} steps, batch_size={args.batch_size}, lr={args.learning_rate}...")
    stop_event = threading.Event()
    interval_sec = max(1, args.progress_minutes * 60)

    def _progress_reporter():
        while True:
            if stop_event.wait(interval_sec):
                break
            ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
            print(f"[{ts}] Chronos LoRA training still in progress ({args.num_steps} steps total)...", flush=True)

    progress_thread = threading.Thread(target=_progress_reporter, daemon=True)
    progress_thread.start()
    try:
        forecaster.finetune(
            df,
            train_ratio=train_ratio,
            covariate_mode="all",
            num_steps=args.num_steps,
            learning_rate=args.learning_rate,
            batch_size=args.batch_size,
            output_dir=output_dir,
        )
    finally:
        stop_event.set()
        progress_thread.join(timeout=interval_sec + 5)

    print("\nValidation MAE after training (5 windows)...")
    val_mae = _quick_val_mae(forecaster, df, train_ratio, n_windows=5)
    print(f"  {val_mae:.4f} (baseline {baseline_mae:.4f})")

    # Full benchmark with finetuned model (append lora row to CSV)
    print("\nRunning full walk-forward benchmark (finetuned model, mode=all)...")
    sparse = forecaster.load_sparse_data()
    daytime_ts = set(sparse["timestamp_utc"])
    test_starts = list(range(split_idx, len(df) - STEPS_PER_DAY, STEPS_PER_DAY))
    all_actual, all_pred = [], []
    for start_idx in test_starts:
        f = forecaster.forecast_day(df, start_idx, STEPS_PER_DAY, covariate_mode="all")
        actual_slice = df.iloc[start_idx : start_idx + STEPS_PER_DAY]
        daytime_mask = actual_slice["timestamp_utc"].isin(daytime_ts).values[:len(f)]
        if daytime_mask.sum() < 5:
            continue
        all_actual.append(actual_slice["A"].values[:len(f)][daytime_mask])
        all_pred.append(np.clip(f["median"].values[daytime_mask], 0, None))
    lora_mae = None
    if all_actual:
        from sklearn.metrics import mean_squared_error, r2_score
        a = np.concatenate(all_actual)
        p = np.concatenate(all_pred)
        lora_mae = float(mean_absolute_error(a, p))
        lora_rmse = float(np.sqrt(mean_squared_error(a, p)))
        lora_r2 = float(r2_score(a, p))
        print(f"  LoRA / all: MAE={lora_mae:.4f}  RMSE={lora_rmse:.4f}  R²={lora_r2:.4f}  ({len(all_actual)} windows, {len(a)} steps)")

        # Load existing benchmark CSV, append lora row, save
        bench_path = OUTPUTS_DIR / "chronos_benchmark.csv"
        if bench_path.exists():
            existing = pd.read_csv(bench_path)
            lora_row = pd.DataFrame([{
                "mode": "lora / all",
                "MAE": lora_mae,
                "RMSE": lora_rmse,
                "R2": lora_r2,
                "n_windows": len(all_actual),
                "n_steps": len(a),
            }])
            combined = pd.concat([existing, lora_row], ignore_index=True)
            combined.to_csv(bench_path, index=False)
            print(f"  Appended lora row → {bench_path}")
        else:
            pd.DataFrame([{
                "mode": "lora / all", "MAE": lora_mae, "RMSE": lora_rmse, "R2": lora_r2,
                "n_windows": len(all_actual), "n_steps": len(a),
            }]).to_csv(bench_path, index=False)

    # Sample forecast plot
    print("\nGenerating sample forecast plot...")
    forecaster.plot_sample_forecast(df)

    # Summary and next steps
    print("\n" + "=" * 60)
    print("TRAINING COMPLETE — Next steps")
    print("=" * 60)
    print(f"  Checkpoints: {output_dir}")
    print(f"  Benchmark:  {OUTPUTS_DIR / 'chronos_benchmark.csv'} (lora / all row appended)")
    print(f"  Plot:       {OUTPUTS_DIR / 'chronos_forecast_sample.png'} (from finetuned model)")
    print("  • Refresh the Streamlit app: Prediction Accuracy tab will show LoRA / all in the table.")
    print("  • Sample forecast image is from the finetuned model.")
    if lora_mae is not None:
        zs_mae = 3.91  # typical zero-shot 'all' on this eval
        delta = (zs_mae - lora_mae) / zs_mae * 100
        print(f"  • LoRA MAE {lora_mae:.2f} vs zero-shot ~{zs_mae:.2f} ({delta:+.0f}% change).")
    print("=" * 60)
    print("Done.")


if __name__ == "__main__":
    main()