CodeDevX commited on
Commit
017950b
·
verified ·
1 Parent(s): 4ab75b3

Add unified multi-task model (single checkpoint, 7 domains)

Browse files
Files changed (9) hide show
  1. README.md +120 -70
  2. config_weather.json +18 -0
  3. data.py +1 -4
  4. model.py +38 -0
  5. model_weather.pt +3 -0
  6. predict.py +77 -8
  7. train_unified.py +187 -0
  8. unified_config.json +74 -0
  9. unified_model.pt +3 -0
README.md CHANGED
@@ -1,89 +1,139 @@
1
- ---
2
- license: mit
3
- library_name: pytorch
4
- tags:
5
- - time-series
6
- - forecasting
7
- - lstm
8
- - finance
9
- - weather
10
- - multi-domain
11
- language:
12
- - en
13
- metrics:
14
- - mae
15
- - mape
16
- pipeline_tag: time-series-forecasting
17
- ---
18
-
19
- # Future Prediction Models (Multi-Domain LSTM)
20
-
21
- An end-to-end multi-domain forecasting system. For each of **7 topics** it automatically fetches a real dataset, trains a 2-layer LSTM, evaluates on a held-out test set (no leakage), and generates ChatGPT-style future predictions.
22
-
23
- ## Topics & validated accuracy
24
-
25
- | Topic | Asset | Source | H1 MAPE | vs naive |
26
- |---|---|---|---|---|
27
- | AI | NVIDIA daily close | Yahoo Finance | 1.86% | ~naive |
28
- | Economy | S&P 500 daily close | Yahoo Finance | 0.64% | beats naive 1.8% |
29
- | Energy | WTI crude oil close | Yahoo Finance | 2.83% | ~naive |
30
- | Finance | Bitcoin BTC-USD | Yahoo Finance | 1.52% | ~naive |
31
- | Programming | `react` npm downloads | npm registry | 7.12% | **beats naive 77.5%** |
32
- | Sports | ATP world #1 Elo | Hugging Face tennis | 0.06% | ~naive |
33
- | Weather | Daily mean temperature | Open-Meteo | 0.18% | beats naive 1.9% |
34
-
35
- > Markets behave near a random walk, so 100% accuracy is impossible — these are honest, validated numbers.
36
-
37
- ## Quick start
38
-
39
- ```bash
40
- pip install -r requirements.txt
41
- python data.py --topic finance --refresh # fetch real data
42
- python train.py --topic finance # train the LSTM
43
- python ask.py "what will bitcoin do next week?"
44
- python ask.py "compare all topics"
45
  ```
46
 
47
- ## Architecture
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  ```
50
  user question -> intent detection (response.py)
51
  -> prediction_engine.py (structured metrics, prints nothing)
52
- -> response_formatter.py (natural summary + Markdown)
53
- -> ask.py (chat CLI, stdout)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  ```
55
 
56
- The engine returns clearly separated metrics — never conflating them:
57
- - `net_change_pct` (first → last forecast along the path)
58
- - `forecast_range_pct` (max − min of the forecast path)
59
- - `current_to_forecast_pct` (final forecast vs latest observed value)
60
- - `direction` (rising / falling / sideways, configurable threshold)
61
 
62
- Only the final assistant response reaches the user; internal logs go to stderr and only when `FORECAST_DEBUG=1`.
 
63
 
64
- ## Files
65
 
66
- - `model_<topic>.pt` — trained PyTorch LSTM checkpoint per topic
67
- - `config_<topic>.json` — hyperparameters + validation metrics per topic
68
- - `data.py`, `train.py`, `predict.py`, `model.py` — data + training + inference core
69
- - `ask.py`, `response.py`, `response_formatter.py`, `prediction_engine.py`, `debug_logger.py` — the chat/response layer
70
- - `weather_cities.py` — city registry for location-specific weather models
 
 
 
 
 
 
 
 
71
 
72
  ## Weather for any city
73
 
74
- ```bash
75
- export WEATHER_NAME='Chennai, India' WEATHER_LAT=13.0827 WEATHER_LON=80.2707
76
- python data.py --topic weather --refresh
77
- python train.py --topic weather
78
- python ask.py "predict weather in chennai tomorrow"
79
  ```
80
 
81
- ## Add a new domain
82
 
83
- 1. Add an entry to `TOPICS` in `data.py` (label, unit, asset)
84
- 2. Add a fetcher returning `{source, series}` to `FETCHERS`
85
- 3. Run `python train.py --topic <name>` — everything else is automatic
 
 
 
86
 
87
- ## License
88
 
89
- MIT
 
 
 
 
 
1
+ # Future Prediction Models (Global: 7 topics)
2
+
3
+ An end-to-end multi-domain AI forecasting system. Real datasets, two model modes, ChatGPT-style predictions.
4
+
5
+ ## Models: separate vs unified
6
+
7
+ - **Separate** (`model_<topic>.pt`) — 7 dedicated 2-layer LSTMs, one per topic
8
+ - **Unified** (`unified_model.pt`) — ONE combined model for all 7 domains: shared LSTM backbone + per-domain embedding + per-domain heads, trained jointly on 31,735 windows from every topic
9
+
10
+ ```powershell
11
+ python train_unified.py # train the single combined model
12
+ python predict.py --topic finance --mode unified # query it for any topic
13
+ python ask.py "compare all topics" # chat uses unified automatically when available
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  ```
15
 
16
+ ## Topics & datasets (all real, fetched automatically)
17
+
18
+ | Topic | Asset | Source | Points |
19
+ |---|---|---|---|
20
+ | **AI** | NVIDIA daily close | Yahoo Finance | 6,933 |
21
+ | **Programming** | Daily `react` npm downloads | npm registry API | 547 |
22
+ | **Finance** | Bitcoin BTC-USD close | Yahoo Finance (Hugging Face fallback) | 4,247 |
23
+ | **Sports** | ATP world #1 Elo rating | Hugging Face tennis (93,028 matches, Elo computed from results) | 10,944 |
24
+ | **Weather** | Daily mean temperature (any city) | Open-Meteo archive | 4,248 |
25
+ | **Economy** | S&P 500 daily close | Yahoo Finance | 6,694 |
26
+ | **Energy** | WTI crude oil close | Yahoo Finance | 6,524 |
27
+
28
+ - Weather city is configurable: set env vars `WEATHER_NAME` / `WEATHER_LAT` / `WEATHER_LON`, or add cities to `weather_cities.py` (Chennai, Mumbai, Delhi, London, Tokyo, ... included).
29
+ - Every topic falls back gracefully: Hugging Face search -> API -> synthetic data if fully offline.
30
+
31
+ ## Validated accuracy (held-out test set, no data leakage)
32
+
33
+ Separate per-topic models:
34
+
35
+ | Topic | MAE | H1 MAPE | vs naive baseline |
36
+ |---|---|---|---|
37
+ | AI (NVIDIA) | $6.73 | 1.86% | ~naive |
38
+ | Economy (S&P 500) | $85.51 | 0.64% | beats naive by 1.8% |
39
+ | Energy (WTI) | $4.78 | 2.83% | ~naive |
40
+ | Finance (Bitcoin) | $1,982.95 | 1.52% | ~naive |
41
+ | Programming (react) | 1.17M downloads | 7.12% | **beats naive by 77.5%** |
42
+ | Sports (ATP #1 Elo) | 3.40 Elo | 0.06% | ~naive |
43
+ | Weather (Chennai temp) | 0.99 K | 0.18% | beats naive by 1.9% |
44
+
45
+ Unified single model (trained jointly, one checkpoint for everything):
46
+
47
+ | Topic | MAE | H1 MAPE |
48
+ |---|---|---|
49
+ | AI | $3.40 | 2.36% |
50
+ | Economy | $72.21 | 0.75% |
51
+ | Energy | $2.93 | 1.90% |
52
+ | Finance | $2,863.45 | 1.74% |
53
+ | Programming | 1.14M downloads | 5.42% |
54
+ | Sports | 2.75 Elo | 0.04% |
55
+ | Weather | 0.94 K | 0.18% |
56
+
57
+ > Markets behave near a random walk, so 100% accuracy is impossible — these are honest, validated numbers. No model can guarantee the future.
58
+
59
+ ## Architecture (normal mode shows only the final response)
60
 
61
  ```
62
  user question -> intent detection (response.py)
63
  -> prediction_engine.py (structured metrics, prints nothing)
64
+ -> response_formatter.py (natural summary + Markdown, no internal details)
65
+ -> ask.py (chat_output, stdout)
66
+ ```
67
+
68
+ - `ask.py` — chat CLI; only the final assistant response reaches the user
69
+ - `response.py` — intent detection + orchestration (topic, location, horizon, style)
70
+ - `prediction_engine.py` — runs the LSTM, returns distinct metrics:
71
+ `net_change_pct` (path change), `forecast_range_pct` (path min-max range),
72
+ `current_to_forecast_pct` (vs latest observed value), `direction`
73
+ (rising/falling/sideways, configurable threshold), `primary_forecast`
74
+ (end-of-horizon value)
75
+ - `response_formatter.py` — presents metrics naturally; opens with a one-sentence
76
+ ChatGPT-style summary; never invents confidence, probability, or values
77
+ - `debug_logger.py` — internal logs go to stderr only when `FORECAST_DEBUG=1` (or `--debug`)
78
+ - `data.py` — multi-topic data pipeline (fetchers + caching to `data/<topic>.csv`)
79
+ - `weather_cities.py` — city registry for location-specific weather models
80
+ - `model.py` / `train.py` — 2-layer LSTM, Huber loss, AdamW, early stopping, temporal split
81
+ - `predict.py` — per-topic model loader, rollout forecast, chart + text report
82
+ - `multi.py` — trains + evaluates all topics; quiet in normal mode
83
+
84
+ ## Ask it anything (ChatGPT-style)
85
+
86
+ ```powershell
87
+ .\.venv\Scripts\python.exe ask.py # interactive chat
88
+ .\.venv\Scripts\python.exe ask.py "what will bitcoin do next week?"
89
+ .\.venv\Scripts\python.exe ask.py "compare all topics"
90
+ .\.venv\Scripts\python.exe ask.py --detailed "predict programming for 14 days"
91
+ .\.venv\Scripts\python.exe ask.py --debug "predict ai for 3 days" # internal logs on stderr
92
  ```
93
 
94
+ Example response:
 
 
 
 
95
 
96
+ ```
97
+ In short, the model expects Bitcoin to stay roughly stable next week, hovering near $63,228.
98
 
99
+ Bitcoin Forecast
100
 
101
+ August 18-24, 2026
102
+
103
+ The model forecasts relatively sideways movement for Bitcoin next week, with an
104
+ estimated level around **$63,228**.
105
+
106
+ Outlook: Sideways
107
+ Predicted level (2026-08-24): ~$63,228
108
+ Expected change: <0.01%
109
+ Forecast range: <0.01%
110
+ From latest observed value ($63,229, 2026-08-17): <0.01%
111
+
112
+ This is a model-generated forecast, and actual market behavior may differ.
113
+ ```
114
 
115
  ## Weather for any city
116
 
117
+ ```powershell
118
+ $env:WEATHER_NAME='Chennai, India'; $env:WEATHER_LAT='13.0827'; $env:WEATHER_LON='80.2707'
119
+ .\.venv\Scripts\python.exe data.py --topic weather --refresh
120
+ .\.venv\Scripts\python.exe train.py --topic weather --epochs 150
121
+ .\.venv\Scripts\python.exe ask.py "predict weather in chennai tomorrow"
122
  ```
123
 
124
+ ## Train / refresh
125
 
126
+ ```powershell
127
+ .\run_all.ps1 # everything (default topics)
128
+ .\.venv\Scripts\python.exe multi.py # all topics
129
+ .\.venv\Scripts\python.exe train.py --topic sports
130
+ .\.venv\Scripts\python.exe predict.py --topic ai --days 14
131
+ ```
132
 
133
+ Outputs: `model_<topic>.pt`, `config_<topic>.json`, `prediction_<topic>.png`, `prediction_<topic>.txt`, `results.json`.
134
 
135
+ ## Add a new domain
136
+
137
+ 1. Add an entry to `TOPICS` in `data.py` (label, unit, asset name)
138
+ 2. Add a fetcher returning `{source, series}` (date + value columns) to `FETCHERS`
139
+ 3. Run `python train.py --topic <name>` — everything else is automatic
config_weather.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "topic": "weather",
3
+ "topic_label": "Weather",
4
+ "unit": "K",
5
+ "source": "open-meteo:Chennai, India",
6
+ "lookback": 60,
7
+ "horizon_steps": 7,
8
+ "horizon_days": 7,
9
+ "hidden_size": 64,
10
+ "num_layers": 2,
11
+ "dropout": 0.1,
12
+ "val_mae": 0.9925612108358967,
13
+ "val_rel_mae": 0.0032603859435766935,
14
+ "val_h1_mape": 0.0018425700744697428,
15
+ "naive_baseline_mae": 1.0116436048493131,
16
+ "last_date": "2026-08-18",
17
+ "last_value": 303.25
18
+ }
data.py CHANGED
@@ -337,10 +337,7 @@ def set_weather_location(name: str, lat, lon):
337
 
338
 
339
  def artifact_suffix(topic: str) -> str:
340
- """Per-domain variant suffix so different instances (e.g. cities) of the
341
- same topic keep separate artifacts."""
342
- if topic == "weather":
343
- return f"_{weather_slug()}"
344
  return ""
345
 
346
 
 
337
 
338
 
339
  def artifact_suffix(topic: str) -> str:
340
+ """Kept for compatibility; artifacts use plain `model_<topic>.pt` names."""
 
 
 
341
  return ""
342
 
343
 
model.py CHANGED
@@ -2,6 +2,44 @@ import torch
2
  import torch.nn as nn
3
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  class LSTMForecaster(nn.Module):
6
  def __init__(self, input_size=1, hidden_size=64, num_layers=2, dropout=0.1, horizon=7):
7
  super().__init__()
 
2
  import torch.nn as nn
3
 
4
 
5
+ class UnifiedForecaster(nn.Module):
6
+ """Single multi-task model for all domains: one shared LSTM backbone,
7
+ a per-domain embedding, and a per-domain output head."""
8
+
9
+ def __init__(self, num_domains, input_size=1, hidden_size=128, num_layers=2,
10
+ dropout=0.1, horizon=7, embed_dim=16):
11
+ super().__init__()
12
+ self.horizon = horizon
13
+ self.num_domains = num_domains
14
+ self.domain_embed = nn.Embedding(num_domains, embed_dim)
15
+ self.lstm = nn.LSTM(
16
+ input_size=input_size + embed_dim,
17
+ hidden_size=hidden_size,
18
+ num_layers=num_layers,
19
+ batch_first=True,
20
+ dropout=dropout if num_layers > 1 else 0.0,
21
+ )
22
+ self.heads = nn.ModuleList([
23
+ nn.Sequential(
24
+ nn.Linear(hidden_size, hidden_size // 2),
25
+ nn.ReLU(),
26
+ nn.Dropout(dropout),
27
+ nn.Linear(hidden_size // 2, horizon),
28
+ )
29
+ for _ in range(num_domains)
30
+ ])
31
+
32
+ def forward(self, x, domain_ids):
33
+ emb = self.domain_embed(domain_ids).unsqueeze(1).expand(-1, x.size(1), -1)
34
+ out, _ = self.lstm(torch.cat([x, emb], dim=-1))
35
+ last = out[:, -1, :]
36
+ preds = torch.empty(x.size(0), self.horizon, device=x.device, dtype=last.dtype)
37
+ for d in torch.unique(domain_ids):
38
+ mask = domain_ids == d
39
+ preds[mask] = self.heads[int(d.item())](last[mask])
40
+ return preds
41
+
42
+
43
  class LSTMForecaster(nn.Module):
44
  def __init__(self, input_size=1, hidden_size=64, num_layers=2, dropout=0.1, horizon=7):
45
  super().__init__()
model_weather.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1c99536594bd56b91868633503b129d30d94eec7ac6eeab0e9c50569663e1b5b
3
+ size 224939
predict.py CHANGED
@@ -10,9 +10,11 @@ matplotlib.use("Agg")
10
  import matplotlib.pyplot as plt
11
 
12
  import data
13
- from model import LSTMForecaster
14
 
15
  BASE_DIR = Path(__file__).resolve().parent
 
 
16
 
17
 
18
  def write_text_report(rows, cfg, topic):
@@ -52,13 +54,68 @@ def write_text_report(rows, cfg, topic):
52
  return txt_path
53
 
54
 
55
- def load_artifacts(topic, device="cpu"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  suffix = data.artifact_suffix(topic)
57
  model_path = BASE_DIR / f"model_{topic}{suffix}.pt"
58
  config_path = BASE_DIR / f"config_{topic}{suffix}.json"
59
  if not model_path.exists() or not config_path.exists():
60
  raise FileNotFoundError(f"Model for topic '{topic}' not trained yet. Run: python train.py --topic {topic}")
61
  cfg = json.loads(config_path.read_text())
 
62
  model = LSTMForecaster(
63
  hidden_size=cfg["hidden_size"],
64
  num_layers=cfg["num_layers"],
@@ -70,8 +127,19 @@ def load_artifacts(topic, device="cpu"):
70
  return model, cfg
71
 
72
 
73
- def predict(topic, days=None, device="cpu"):
74
- model, cfg = load_artifacts(topic, device)
 
 
 
 
 
 
 
 
 
 
 
75
  series = pd.read_csv(BASE_DIR / "data" / f"{topic}.csv")
76
  vals = series["value"].to_numpy(dtype=np.float64)
77
  lb = cfg["lookback"]
@@ -85,9 +153,7 @@ def predict(topic, days=None, device="cpu"):
85
  last_date = pd.Timestamp(cfg["last_date"])
86
  rows = []
87
  for step in range(n_steps):
88
- x = torch.from_numpy(rel[-lb:].astype(np.float32)).unsqueeze(0).unsqueeze(-1).to(device)
89
- with torch.no_grad():
90
- pred_rel = model(x).cpu().numpy().flatten()
91
  cur_val = last_val * (1.0 + pred_rel[0])
92
  rel = np.concatenate([rel, pred_rel])
93
  rows.append({
@@ -124,11 +190,14 @@ def main():
124
  parser = argparse.ArgumentParser()
125
  parser.add_argument("--topic", default="finance", choices=list(data.TOPICS))
126
  parser.add_argument("--days", type=float, default=None, help="how many days ahead to predict")
 
 
127
  parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
128
  args = parser.parse_args()
129
- rows, cfg, chart_path, txt_path = predict(args.topic, days=args.days, device=args.device)
130
  unit = cfg.get("unit", "")
131
  print(f"Topic : {cfg.get('topic_label', args.topic)} - {cfg['source']}")
 
132
  print(f"Last known : {cfg['last_date']} = {cfg['last_value']:,.2f} {unit}")
133
  print(f"Validated on : MAE={cfg['val_mae']:,.2f}, rel MAE={cfg['val_rel_mae'] * 100:.2f}%, "
134
  f"H1 MAPE={cfg['val_h1_mape'] * 100:.2f}% (naive baseline MAE={cfg['naive_baseline_mae']:,.2f})")
 
10
  import matplotlib.pyplot as plt
11
 
12
  import data
13
+ from model import LSTMForecaster, UnifiedForecaster
14
 
15
  BASE_DIR = Path(__file__).resolve().parent
16
+ UNIFIED_MODEL = BASE_DIR / "unified_model.pt"
17
+ UNIFIED_CONFIG = BASE_DIR / "unified_config.json"
18
 
19
 
20
  def write_text_report(rows, cfg, topic):
 
54
  return txt_path
55
 
56
 
57
+ def load_unified(device="cpu"):
58
+ """Load the single combined model that serves all topics."""
59
+ if not UNIFIED_MODEL.exists() or not UNIFIED_CONFIG.exists():
60
+ raise FileNotFoundError("Unified model not trained yet. Run: python train_unified.py")
61
+ ucfg = json.loads(UNIFIED_CONFIG.read_text())
62
+ model = UnifiedForecaster(
63
+ num_domains=ucfg["num_domains"],
64
+ hidden_size=ucfg["hidden_size"],
65
+ num_layers=ucfg["num_layers"],
66
+ dropout=ucfg["dropout"],
67
+ horizon=ucfg["horizon_steps"],
68
+ embed_dim=ucfg["embed_dim"],
69
+ ).to(device)
70
+ model.load_state_dict(torch.load(UNIFIED_MODEL, map_location=device))
71
+ model.eval()
72
+ return model, ucfg
73
+
74
+
75
+ def load_topic_cfg(topic):
76
+ config_path = BASE_DIR / f"config_{topic}{data.artifact_suffix(topic)}.json"
77
+ if config_path.exists():
78
+ return json.loads(config_path.read_text())
79
+ return None
80
+
81
+
82
+ def load_artifacts(topic, device="cpu", mode="auto"):
83
+ """mode: 'unified' -> single combined model; 'separate' -> per-topic LSTM;
84
+ 'auto' -> unified when available, else per-topic."""
85
+ if mode in ("unified", "auto") and UNIFIED_MODEL.exists():
86
+ model, ucfg = load_unified(device)
87
+ cfg = load_topic_cfg(topic) or {}
88
+ topic_metrics = ucfg.get("metrics_per_topic", {}).get(topic, {})
89
+ meta = json.loads((BASE_DIR / "data" / f"{topic}.meta.json").read_text()) if (
90
+ BASE_DIR / "data" / f"{topic}.meta.json").exists() else {}
91
+ merged = {
92
+ "topic": topic,
93
+ "topic_label": data.TOPICS.get(topic, {}).get("label", topic),
94
+ "unit": data.TOPICS.get(topic, {}).get("unit", cfg.get("unit", "")),
95
+ "source": cfg.get("source", meta.get("source", "unified")),
96
+ "lookback": ucfg["lookback"],
97
+ "horizon_steps": ucfg["horizon_steps"],
98
+ "horizon_days": ucfg.get("horizon_days", 7),
99
+ "hidden_size": ucfg["hidden_size"],
100
+ "num_layers": ucfg["num_layers"],
101
+ "dropout": ucfg["dropout"],
102
+ "val_mae": topic_metrics.get("mae", cfg.get("val_mae", float("nan"))),
103
+ "val_rel_mae": topic_metrics.get("rel_mae", cfg.get("val_rel_mae", float("nan"))),
104
+ "val_h1_mape": topic_metrics.get("h1_mape", cfg.get("val_h1_mape", float("nan"))),
105
+ "naive_baseline_mae": topic_metrics.get("naive_mae", cfg.get("naive_baseline_mae", float("nan"))),
106
+ "last_date": cfg.get("last_date", meta.get("end", "")),
107
+ "last_value": cfg.get("last_value", float("nan")),
108
+ "engine": "unified",
109
+ }
110
+ return model, merged
111
+
112
  suffix = data.artifact_suffix(topic)
113
  model_path = BASE_DIR / f"model_{topic}{suffix}.pt"
114
  config_path = BASE_DIR / f"config_{topic}{suffix}.json"
115
  if not model_path.exists() or not config_path.exists():
116
  raise FileNotFoundError(f"Model for topic '{topic}' not trained yet. Run: python train.py --topic {topic}")
117
  cfg = json.loads(config_path.read_text())
118
+ cfg["engine"] = "separate"
119
  model = LSTMForecaster(
120
  hidden_size=cfg["hidden_size"],
121
  num_layers=cfg["num_layers"],
 
127
  return model, cfg
128
 
129
 
130
+ def _run_step(model, rel, lb, topic, engine, device):
131
+ x = torch.from_numpy(rel[-lb:].astype(np.float32)).unsqueeze(0).unsqueeze(-1).to(device)
132
+ with torch.no_grad():
133
+ if engine == "unified":
134
+ ucfg = json.loads(UNIFIED_CONFIG.read_text())
135
+ did = torch.tensor([ucfg["domain_ids"][topic]], dtype=torch.int64, device=device)
136
+ return model(x, did).cpu().numpy().flatten()
137
+ return model(x).cpu().numpy().flatten()
138
+
139
+
140
+ def predict(topic, days=None, device="cpu", mode="auto"):
141
+ model, cfg = load_artifacts(topic, device, mode=mode)
142
+ engine = cfg.get("engine", "separate")
143
  series = pd.read_csv(BASE_DIR / "data" / f"{topic}.csv")
144
  vals = series["value"].to_numpy(dtype=np.float64)
145
  lb = cfg["lookback"]
 
153
  last_date = pd.Timestamp(cfg["last_date"])
154
  rows = []
155
  for step in range(n_steps):
156
+ pred_rel = _run_step(model, rel, lb, topic, engine, device)
 
 
157
  cur_val = last_val * (1.0 + pred_rel[0])
158
  rel = np.concatenate([rel, pred_rel])
159
  rows.append({
 
190
  parser = argparse.ArgumentParser()
191
  parser.add_argument("--topic", default="finance", choices=list(data.TOPICS))
192
  parser.add_argument("--days", type=float, default=None, help="how many days ahead to predict")
193
+ parser.add_argument("--mode", default="auto", choices=["auto", "unified", "separate"],
194
+ help="unified = the single combined model; separate = per-topic LSTMs")
195
  parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
196
  args = parser.parse_args()
197
+ rows, cfg, chart_path, txt_path = predict(args.topic, days=args.days, device=args.device, mode=args.mode)
198
  unit = cfg.get("unit", "")
199
  print(f"Topic : {cfg.get('topic_label', args.topic)} - {cfg['source']}")
200
+ print(f"Engine : {cfg.get('engine', 'separate')} model")
201
  print(f"Last known : {cfg['last_date']} = {cfg['last_value']:,.2f} {unit}")
202
  print(f"Validated on : MAE={cfg['val_mae']:,.2f}, rel MAE={cfg['val_rel_mae'] * 100:.2f}%, "
203
  f"H1 MAPE={cfg['val_h1_mape'] * 100:.2f}% (naive baseline MAE={cfg['naive_baseline_mae']:,.2f})")
train_unified.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train ONE model for all domains (UnifiedForecaster).
2
+
3
+ Loads every topic's dataset, builds normalized windows tagged with a domain
4
+ id, and trains a shared LSTM backbone + per-domain heads jointly. Saves a
5
+ single checkpoint used by predict.py (mode=unified).
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import torch
15
+ import torch.nn as nn
16
+ from torch.utils.data import DataLoader, TensorDataset
17
+
18
+ import data
19
+ from model import UnifiedForecaster
20
+
21
+ BASE_DIR = Path(__file__).resolve().parent
22
+ UNIFIED_MODEL = BASE_DIR / "unified_model.pt"
23
+ UNIFIED_CONFIG = BASE_DIR / "unified_config.json"
24
+
25
+ DOMAIN_ORDER = ["ai", "programming", "finance", "sports", "weather", "economy", "energy"]
26
+ DOMAIN_IDS = {name: i for i, name in enumerate(DOMAIN_ORDER)}
27
+
28
+
29
+ def build_windows(values, lookback, horizon):
30
+ xs, ys, bases = [], [], []
31
+ for i in range(lookback, len(values) - horizon):
32
+ base = values[i - 1]
33
+ if not np.isfinite(base) or base <= 0:
34
+ continue
35
+ xs.append(values[i - lookback:i] / base - 1.0)
36
+ ys.append(values[i:i + horizon] / base - 1.0)
37
+ bases.append(base)
38
+ return (np.array(xs, dtype=np.float32), np.array(ys, dtype=np.float32), np.array(bases, dtype=np.float64))
39
+
40
+
41
+ def collect_all(topics, lookback, horizon, test_frac=0.2):
42
+ train_x, train_y, train_d = [], [], []
43
+ test_sets = {}
44
+ for topic in topics:
45
+ series, source = data.load(topic=topic, refresh=False)
46
+ vals = series["value"].to_numpy(dtype=np.float64)
47
+ x, y, b = build_windows(vals, lookback, horizon)
48
+ if len(x) == 0:
49
+ continue
50
+ n_test = max(horizon, int(len(x) * test_frac))
51
+ n_train = len(x) - n_test
52
+ did = DOMAIN_IDS[topic]
53
+ train_x.append(x[:n_train])
54
+ train_y.append(y[:n_train])
55
+ train_d.append(np.full(n_train, did, dtype=np.int64))
56
+ test_sets[topic] = (x[n_train:], y[n_train:], b[n_train:])
57
+ print(f"[unified] {topic:12s} train={n_train:6d} test={n_test:5d} ({source})")
58
+ X = np.concatenate(train_x)
59
+ Y = np.concatenate(train_y)
60
+ D = np.concatenate(train_d)
61
+ return X, Y, D, test_sets
62
+
63
+
64
+ def evaluate_unified(model, loader, device):
65
+ model.eval()
66
+ preds, actuals, bases = [], [], []
67
+ with torch.no_grad():
68
+ for xb, yb, bb, db in loader:
69
+ preds.append(model(xb.to(device), db.to(device)).cpu().numpy())
70
+ actuals.append(yb.numpy())
71
+ bases.append(bb.numpy())
72
+ preds = np.concatenate(preds)
73
+ actuals = np.concatenate(actuals)
74
+ bases = np.concatenate(bases)[:, None]
75
+ pred_usd = bases * (1.0 + preds)
76
+ act_usd = bases * (1.0 + actuals)
77
+ mae = float(np.mean(np.abs(pred_usd - act_usd)))
78
+ rel_mae = float(np.mean(np.abs(preds - actuals)))
79
+ denom = np.where(act_usd[:, 0] == 0, 1e-9, act_usd[:, 0])
80
+ h1_mape = float(np.mean(np.abs((act_usd[:, 0] - pred_usd[:, 0]) / denom)))
81
+ naive_mae = float(np.mean(np.abs(act_usd - bases)))
82
+ return mae, rel_mae, h1_mape, naive_mae
83
+
84
+
85
+ def train(topics=None, lookback=60, horizon=7, epochs=40, batch_size=256, lr=1e-3,
86
+ device="cpu", seed=0, hidden_size=128, num_layers=2, dropout=0.1, embed_dim=16,
87
+ patience=10):
88
+ torch.manual_seed(seed)
89
+ np.random.seed(seed)
90
+ topics = topics or DOMAIN_ORDER
91
+ X, Y, D, test_sets = collect_all(topics, lookback, horizon)
92
+ n_train = len(X)
93
+ if n_train < 100:
94
+ raise SystemExit("[unified] not enough training windows; run data.py --refresh for each topic first")
95
+
96
+ train_ds = TensorDataset(torch.from_numpy(X).unsqueeze(-1), torch.from_numpy(Y), torch.from_numpy(D))
97
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
98
+
99
+ test_by_topic = {}
100
+ for topic, (tx, ty, tb) in test_sets.items():
101
+ test_by_topic[topic] = DataLoader(
102
+ TensorDataset(torch.from_numpy(tx).unsqueeze(-1), torch.from_numpy(ty),
103
+ torch.from_numpy(tb),
104
+ torch.full((len(tx),), DOMAIN_IDS[topic], dtype=torch.int64)),
105
+ batch_size=512)
106
+
107
+ model = UnifiedForecaster(num_domains=len(DOMAIN_ORDER), hidden_size=hidden_size,
108
+ num_layers=num_layers, dropout=dropout, horizon=horizon,
109
+ embed_dim=embed_dim).to(device)
110
+ opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-5)
111
+ sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, patience=4, factor=0.5)
112
+ crit = nn.HuberLoss()
113
+
114
+ best_rel, best_state, seen = float("inf"), None, 0
115
+ for epoch in range(1, epochs + 1):
116
+ model.train()
117
+ total = 0.0
118
+ for xb, yb, db in train_loader:
119
+ opt.zero_grad()
120
+ loss = crit(model(xb.to(device), db.to(device)), yb.to(device))
121
+ loss.backward()
122
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
123
+ opt.step()
124
+ total += float(loss.item()) * len(xb)
125
+
126
+ rels, h1s = [], []
127
+ for topic, loader in test_by_topic.items():
128
+ mae, rel_mae, h1_mape, naive = evaluate_unified(model, loader, device)
129
+ rels.append(rel_mae)
130
+ h1s.append(h1_mape)
131
+ val_rel = float(np.mean(rels))
132
+ val_h1 = float(np.mean(h1s))
133
+ sched.step(val_rel)
134
+ print(f"epoch {epoch:3d}/{epochs} loss={total / n_train:.5f} "
135
+ f"val_relMAE={val_rel * 100:.2f}% avg_H1_MAPE={val_h1 * 100:.2f}%")
136
+ if val_rel < best_rel - 1e-6:
137
+ best_rel, seen = val_rel, 0
138
+ best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
139
+ else:
140
+ seen += 1
141
+ if seen >= patience:
142
+ print("[unified] early stopping")
143
+ break
144
+
145
+ if best_state is not None:
146
+ model.load_state_dict(best_state)
147
+
148
+ per_topic = {}
149
+ for topic, loader in test_by_topic.items():
150
+ mae, rel_mae, h1_mape, naive = evaluate_unified(model, loader, device)
151
+ per_topic[topic] = {"mae": mae, "rel_mae": rel_mae, "h1_mape": h1_mape, "naive_mae": naive}
152
+ print(f"[unified] {topic:12s} MAE={mae:,.2f} relMAE={rel_mae * 100:.2f}% "
153
+ f"H1_MAPE={h1_mape * 100:.2f}% naive={naive:,.2f}")
154
+
155
+ torch.save(model.state_dict(), UNIFIED_MODEL)
156
+ cfg = {
157
+ "mode": "unified",
158
+ "num_domains": len(DOMAIN_ORDER),
159
+ "domain_order": DOMAIN_ORDER,
160
+ "domain_ids": DOMAIN_IDS,
161
+ "lookback": lookback,
162
+ "horizon_steps": horizon,
163
+ "horizon_days": horizon,
164
+ "hidden_size": hidden_size,
165
+ "num_layers": num_layers,
166
+ "dropout": dropout,
167
+ "embed_dim": embed_dim,
168
+ "n_train_windows": int(n_train),
169
+ "metrics_per_topic": per_topic,
170
+ }
171
+ UNIFIED_CONFIG.write_text(json.dumps(cfg, indent=2))
172
+ print(f"\n[unified] saved single model -> {UNIFIED_MODEL.name}, config -> {UNIFIED_CONFIG.name}")
173
+ return model, cfg
174
+
175
+
176
+ def main():
177
+ parser = argparse.ArgumentParser()
178
+ parser.add_argument("--epochs", type=int, default=40)
179
+ parser.add_argument("--horizon", type=int, default=7)
180
+ parser.add_argument("--lookback", type=int, default=60)
181
+ parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
182
+ args = parser.parse_args()
183
+ train(epochs=args.epochs, horizon=args.horizon, lookback=args.lookback, device=args.device)
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
unified_config.json ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mode": "unified",
3
+ "num_domains": 7,
4
+ "domain_order": [
5
+ "ai",
6
+ "programming",
7
+ "finance",
8
+ "sports",
9
+ "weather",
10
+ "economy",
11
+ "energy"
12
+ ],
13
+ "domain_ids": {
14
+ "ai": 0,
15
+ "programming": 1,
16
+ "finance": 2,
17
+ "sports": 3,
18
+ "weather": 4,
19
+ "economy": 5,
20
+ "energy": 6
21
+ },
22
+ "lookback": 60,
23
+ "horizon_steps": 7,
24
+ "horizon_days": 7,
25
+ "hidden_size": 128,
26
+ "num_layers": 2,
27
+ "dropout": 0.1,
28
+ "embed_dim": 16,
29
+ "n_train_windows": 31735,
30
+ "metrics_per_topic": {
31
+ "ai": {
32
+ "mae": 3.4010714403826725,
33
+ "rel_mae": 0.046488404273986816,
34
+ "h1_mape": 0.023641787109347494,
35
+ "naive_mae": 3.4285758835207747
36
+ },
37
+ "programming": {
38
+ "mae": 1135964.0941619105,
39
+ "rel_mae": 0.058158028870821,
40
+ "h1_mape": 0.054231874000870206,
41
+ "naive_mae": 5238840.46007266
42
+ },
43
+ "finance": {
44
+ "mae": 2863.447612711464,
45
+ "rel_mae": 0.03436192125082016,
46
+ "h1_mape": 0.017374594652497116,
47
+ "naive_mae": 2704.601283383124
48
+ },
49
+ "sports": {
50
+ "mae": 2.7536647755605217,
51
+ "rel_mae": 0.0012617451138794422,
52
+ "h1_mape": 0.0003771134174271423,
53
+ "naive_mae": 2.502011465506802
54
+ },
55
+ "weather": {
56
+ "mae": 0.9439951772615057,
57
+ "rel_mae": 0.003122166031971574,
58
+ "h1_mape": 0.0017910992340668128,
59
+ "naive_mae": 0.9435578352786341
60
+ },
61
+ "economy": {
62
+ "mae": 72.2052313993689,
63
+ "rel_mae": 0.014413672499358654,
64
+ "h1_mape": 0.007524061656098242,
65
+ "naive_mae": 73.12155254020708
66
+ },
67
+ "energy": {
68
+ "mae": 2.9286401717398816,
69
+ "rel_mae": 0.03628615289926529,
70
+ "h1_mape": 0.018979403445247508,
71
+ "naive_mae": 2.9195108923456288
72
+ }
73
+ }
74
+ }
unified_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6eadd67d78c1019664062377451681c04815c0878c32e95de3532b216802a9ec
3
+ size 1086390