CodeDevX commited on
Commit
45a18db
·
verified ·
1 Parent(s): 9e065ce

Keep only model checkpoints + configs

Browse files
Files changed (14) hide show
  1. ask.py +0 -69
  2. data.py +0 -472
  3. debug_logger.py +0 -34
  4. model.py +0 -63
  5. multi.py +0 -62
  6. predict.py +0 -212
  7. prediction_engine.py +0 -97
  8. requirements.txt +0 -8
  9. response.py +0 -143
  10. response_formatter.py +0 -182
  11. run_all.ps1 +0 -21
  12. train.py +0 -163
  13. train_unified.py +0 -187
  14. weather_cities.py +0 -40
ask.py DELETED
@@ -1,69 +0,0 @@
1
- """Chat interface. Only the final assistant response reaches stdout.
2
-
3
- Usage:
4
- python ask.py # interactive chat
5
- python ask.py "what will bitcoin do next week?"
6
- python ask.py --debug "compare all topics" # internal logs on stderr
7
- """
8
-
9
- import argparse
10
- import sys
11
- import time
12
-
13
- import debug_logger
14
- import response
15
-
16
-
17
- def main():
18
- parser = argparse.ArgumentParser()
19
- parser.add_argument("question", nargs="*", help="one-shot question (omit for interactive chat)")
20
- parser.add_argument("--stream", action="store_true", help="stream output line by line")
21
- parser.add_argument("--debug", action="store_true", help="show internal logs on stderr")
22
- parser.add_argument("--detailed", action="store_true", help="include day-by-day table")
23
- parser.add_argument("--device", default="cpu")
24
- args = parser.parse_args()
25
-
26
- if args.debug:
27
- import os
28
- os.environ["FORECAST_DEBUG"] = "1"
29
-
30
- config = response.GenerationConfig(debug=args.debug, detailed=args.detailed)
31
- ctx = response.ConversationContext(max_turns=8)
32
-
33
- def ask(question: str) -> str:
34
- answer = debug_logger.strip_internal(
35
- response.generate_response(question, device=args.device, config=config))
36
- if args.stream:
37
- for chunk in response.stream_response(question, pregenerated=answer):
38
- sys.stdout.write(chunk)
39
- sys.stdout.flush()
40
- time.sleep(config.chunk_delay)
41
- if answer and not answer.endswith("\n"):
42
- print()
43
- else:
44
- print(answer)
45
- ctx.add("user", question)
46
- ctx.add("assistant", answer)
47
- return answer
48
-
49
- if args.question:
50
- ask(" ".join(args.question))
51
- return
52
-
53
- print("Forecast assistant - ask about AI, Programming, Finance, Sports,")
54
- print("Weather, Economy, or Energy. Example: 'what will bitcoin do next week?'")
55
- while True:
56
- try:
57
- q = input("you> ").strip()
58
- except (EOFError, KeyboardInterrupt):
59
- print()
60
- break
61
- if not q:
62
- continue
63
- if q.lower() in ("quit", "exit", "q"):
64
- break
65
- ask(q)
66
-
67
-
68
- if __name__ == "__main__":
69
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data.py DELETED
@@ -1,472 +0,0 @@
1
- import argparse
2
- import json
3
- import os
4
- import re
5
- from pathlib import Path
6
-
7
- import numpy as np
8
- import pandas as pd
9
-
10
- os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "30")
11
-
12
- BASE_DIR = Path(__file__).resolve().parent
13
- DATA_DIR = BASE_DIR / "data"
14
-
15
- WEATHER_NAME = os.environ.get("WEATHER_NAME", "New York")
16
- WEATHER_LAT = os.environ.get("WEATHER_LAT", "40.7128")
17
- WEATHER_LON = os.environ.get("WEATHER_LON", "-74.0060")
18
- _WEATHER_NAME = WEATHER_NAME
19
- _WEATHER_LAT = WEATHER_LAT
20
- _WEATHER_LON = WEATHER_LON
21
-
22
- TOPICS = {
23
- "ai": {"label": "AI", "unit": "USD", "asset": "NVIDIA stock",
24
- "desc": "NVIDIA stock close price (AI market leader)"},
25
- "programming": {"label": "Programming", "unit": "downloads", "asset": "react downloads",
26
- "desc": "npm downloads for 'react' (JS ecosystem demand)"},
27
- "finance": {"label": "Finance", "unit": "USD", "asset": "Bitcoin",
28
- "desc": "Bitcoin BTC-USD daily close"},
29
- "sports": {"label": "Sports", "unit": "Elo", "asset": "ATP world #1 Elo rating",
30
- "desc": "ATP world #1 Elo rating, computed from full tennis match history"},
31
- "weather": {"label": "Weather", "unit": "K", "asset": f"{WEATHER_NAME} temperature",
32
- "desc": f"Daily mean temperature for {WEATHER_NAME} (Open-Meteo archive, stored in Kelvin)"},
33
- "economy": {"label": "Economy", "unit": "USD", "asset": "S&P 500 index",
34
- "desc": "S&P 500 daily close (US market benchmark)"},
35
- "energy": {"label": "Energy", "unit": "USD", "asset": "crude oil price (WTI)",
36
- "desc": "WTI crude oil futures daily close"},
37
- }
38
-
39
- DATE_COL_RE = re.compile(r"(date|time|stamp|day|period|^dt$)", re.IGNORECASE)
40
- TARGET_CANDIDATES = ["close", "price", "value", "usd", "amount", "open", "target", "y"]
41
-
42
-
43
- def _strip_tz(parsed):
44
- if getattr(parsed.dt, "tz", None) is not None:
45
- parsed = parsed.dt.tz_localize(None)
46
- return parsed
47
-
48
-
49
- def _find_date_col(df):
50
- for col in df.columns:
51
- if DATE_COL_RE.search(str(col)):
52
- parsed = pd.to_datetime(df[col], errors="coerce")
53
- if parsed.notna().mean() > 0.8:
54
- return col, _strip_tz(parsed)
55
- for col in df.columns:
56
- if str(df[col].dtype).startswith("datetime"):
57
- return col, _strip_tz(pd.to_datetime(df[col]))
58
- for col in df.columns:
59
- parsed = pd.to_datetime(df[col], errors="coerce")
60
- if parsed.notna().mean() > 0.8:
61
- return col, _strip_tz(parsed)
62
- return None, None
63
-
64
-
65
- def _find_target_col(df, date_col):
66
- numeric_cols = [c for c in df.select_dtypes(include=[np.number]).columns if c != date_col]
67
- series_map = {c: df[c] for c in numeric_cols}
68
- for col in df.columns:
69
- if col == date_col or col in series_map:
70
- continue
71
- coerced = pd.to_numeric(df[col], errors="coerce")
72
- if coerced.notna().mean() > 0.6:
73
- series_map[col] = coerced
74
- if not series_map:
75
- return None, None
76
- for name in TARGET_CANDIDATES:
77
- for col in series_map:
78
- if name in str(col).lower():
79
- return col, series_map[col]
80
- best = max(series_map, key=lambda c: series_map[c].notna().mean())
81
- return best, series_map[best]
82
-
83
-
84
- def extract_series(df, min_points=300):
85
- date_col, dates = _find_date_col(df)
86
- if date_col is None:
87
- return None
88
- target_col, values = _find_target_col(df, date_col)
89
- if target_col is None:
90
- return None
91
- out = pd.DataFrame({"date": dates, "value": pd.to_numeric(values, errors="coerce")})
92
- out = out[np.isfinite(out["value"])].dropna().sort_values("date")
93
- if len(out) < min_points:
94
- return None
95
- out = out.set_index("date")
96
- gaps = out.index.to_series().diff().dropna().dt.total_seconds()
97
- if len(gaps) and gaps.median() < 6 * 3600:
98
- out = out.resample("1D").mean().dropna()
99
- out = out[~out.index.duplicated(keep="last")].sort_index()
100
- out = out.reset_index()
101
- out["date"] = out["date"].dt.strftime("%Y-%m-%d")
102
- if len(out) < min_points or out["value"].std() <= 0:
103
- return None
104
- return out
105
-
106
-
107
- def _hf_search_best(queries):
108
- try:
109
- from huggingface_hub import HfApi, hf_hub_download
110
- except Exception as exc:
111
- print(f"[data] huggingface_hub unavailable: {exc}")
112
- return None
113
- api = HfApi()
114
- repo_ids = []
115
- for query in queries:
116
- try:
117
- results = list(api.list_datasets(search=query, limit=8))
118
- results.sort(key=lambda i: -(getattr(i, "downloads", 0) or 0))
119
- for info in results:
120
- if info.id not in repo_ids:
121
- repo_ids.append(info.id)
122
- except Exception as exc:
123
- print(f"[data] search failed for {query!r}: {exc}")
124
- candidates = []
125
- for repo in repo_ids:
126
- try:
127
- files = api.list_repo_files(repo, repo_type="dataset")
128
- except Exception:
129
- continue
130
- data_files = [f for f in files if f.endswith((".csv", ".parquet")) and "readme" not in f.lower()]
131
- data_files.sort(key=lambda f: 0 if f.endswith(".csv") else 1)
132
- for f in data_files[:3]:
133
- try:
134
- local = hf_hub_download(repo, f, repo_type="dataset")
135
- df = pd.read_csv(local) if f.endswith(".csv") else pd.read_parquet(local)
136
- series = extract_series(df)
137
- if series is not None:
138
- candidates.append((pd.Timestamp(series["date"].iloc[-1]), len(series),
139
- f"huggingface:{repo}::{f}", series))
140
- except Exception:
141
- continue
142
- if not candidates:
143
- return None
144
- candidates.sort(key=lambda c: (c[0], c[1]), reverse=True)
145
- end_date, n_rows, source, series = candidates[0]
146
- print(f"[data] best HF dataset: {source} (ends {end_date.date()}, {n_rows} pts; {len(candidates)} usable)")
147
- return {"source": source, "series": series}
148
-
149
-
150
- def _yf_fetch(symbol, start):
151
- import yfinance as yf
152
- df = yf.download(symbol, start=start, auto_adjust=True, progress=False)
153
- if df is None or df.empty:
154
- raise RuntimeError(f"no data for {symbol}")
155
- if isinstance(df.columns, pd.MultiIndex):
156
- df.columns = df.columns.get_level_values(0)
157
- df = df.reset_index().rename(columns={"Date": "date", "Close": "close"})
158
- return extract_series(df)
159
-
160
-
161
- def fetch_finance():
162
- candidates = []
163
- hf = _hf_search_best(("bitcoin price", "cryptocurrency price", "stock price daily"))
164
- if hf:
165
- candidates.append(hf)
166
- try:
167
- series = _yf_fetch("BTC-USD", "2015-01-01")
168
- if series is not None:
169
- candidates.append({"source": "yfinance:BTC-USD", "series": series})
170
- except Exception as exc:
171
- print(f"[data] yfinance BTC-USD failed: {exc}")
172
- return candidates
173
-
174
-
175
- def fetch_ai():
176
- candidates = []
177
- try:
178
- series = _yf_fetch("NVDA", "1999-01-01")
179
- if series is not None:
180
- candidates.append({"source": "yfinance:NVDA", "series": series})
181
- except Exception as exc:
182
- print(f"[data] yfinance NVDA failed: {exc}")
183
- hf = _hf_search_best(("nvidia stock price", "ai stock price", "tech stock daily"))
184
- if hf:
185
- candidates.append(hf)
186
- return candidates
187
-
188
-
189
- def fetch_programming():
190
- import urllib.request
191
- today = pd.Timestamp.today().strftime("%Y-%m-%d")
192
- url = f"https://api.npmjs.org/downloads/range/2015-01-01:{today}/react"
193
- with urllib.request.urlopen(url, timeout=30) as resp:
194
- payload = json.loads(resp.read().decode())
195
- rows = payload.get("downloads", [])
196
- series = pd.DataFrame(rows)
197
- if series.empty:
198
- return []
199
- series["date"] = pd.to_datetime(series["day"], errors="coerce")
200
- series["value"] = pd.to_numeric(series.get("downloads"), errors="coerce")
201
- series = series[np.isfinite(series["value"])]
202
- series = series.dropna(subset=["date"]).set_index("date")
203
- series = series[~series.index.duplicated(keep="last")].sort_index()
204
- series = series.resample("1D").asfreq().ffill()
205
- series["value"] = series["value"].replace(0, np.nan).ffill().bfill()
206
- out = series.reset_index()
207
- out["date"] = out["date"].dt.strftime("%Y-%m-%d")
208
- out = out[["date", "value"]]
209
- if len(out) < 300 or out["value"].std() <= 0:
210
- return []
211
- return [{"source": "npm-api:react-daily-downloads", "series": out}]
212
-
213
-
214
- def _clubelo_series(club, tries=3):
215
- import io
216
- import time
217
- import urllib.request
218
- url = f"http://api.clubelo.com/{club}"
219
- req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (forecast-bot)"})
220
- for attempt in range(tries):
221
- try:
222
- with urllib.request.urlopen(req, timeout=25) as resp:
223
- text = resp.read().decode()
224
- df = pd.read_csv(io.StringIO(text))
225
- if "From" not in df.columns or "Elo" not in df.columns:
226
- return None
227
- s = pd.DataFrame({"date": pd.to_datetime(df["From"], errors="coerce"),
228
- "value": pd.to_numeric(df["Elo"], errors="coerce")})
229
- s = s.dropna().sort_values("date")
230
- s = s[s["date"] <= pd.Timestamp.today().normalize()]
231
- if len(s) < 300:
232
- return None
233
- s = s.set_index("date")
234
- s = s[~s.index.duplicated(keep="last")]
235
- s = s.resample("1D").ffill().ffill().dropna()
236
- out = s.reset_index()
237
- out["date"] = out["date"].dt.strftime("%Y-%m-%d")
238
- return out[["date", "value"]] if len(out) >= 300 else None
239
- except Exception as exc:
240
- print(f"[data] ClubElo {club} attempt {attempt + 1} failed: {exc}")
241
- time.sleep(1.5)
242
- return None
243
-
244
-
245
- def _atp_world1_elo():
246
- try:
247
- from huggingface_hub import HfApi, hf_hub_download
248
- except Exception:
249
- return None
250
- try:
251
- api = HfApi()
252
- files = api.list_repo_files("davidtadediji/tennis-atp", repo_type="dataset")
253
- except Exception as exc:
254
- print(f"[data] ATP repo listing failed: {exc}")
255
- return None
256
- import re as _re
257
- year_files = {}
258
- for f in files:
259
- m = _re.match(r"atp_matches_(\d{4})\.csv$", f)
260
- if m:
261
- year_files[int(m.group(1))] = f
262
- if not year_files:
263
- return None
264
- years = sorted(y for y in year_files if y >= 1995)
265
- records = []
266
- for y in years:
267
- try:
268
- local = hf_hub_download("davidtadediji/tennis-atp", year_files[y], repo_type="dataset")
269
- df = pd.read_csv(local, usecols=["tourney_date", "winner_id", "loser_id"], low_memory=False)
270
- records.append(df)
271
- print(f"[data] ATP {y}: {len(df)} matches")
272
- except Exception:
273
- print(f"[data] ATP {y}: download/parse failed")
274
- continue
275
- if not records:
276
- return None
277
- m = pd.concat(records, ignore_index=True)
278
- m["date"] = pd.to_datetime(m["tourney_date"].astype(str), format="%Y%m%d", errors="coerce")
279
- m["winner_id"] = pd.to_numeric(m["winner_id"], errors="coerce")
280
- m["loser_id"] = pd.to_numeric(m["loser_id"], errors="coerce")
281
- m = m.dropna(subset=["date", "winner_id", "loser_id"]).astype({"winner_id": "int64", "loser_id": "int64"})
282
- m = m.sort_values("date")
283
- print(f"[data] ATP: {len(m)} valid matches across years {years[0]}..{years[-1]}")
284
-
285
- K, ratings = 32.0, {}
286
- top_daily = []
287
- for d, grp in m.groupby("date"):
288
- for _, row in grp.iterrows():
289
- w, l = row["winner_id"], row["loser_id"]
290
- if pd.isna(w) or pd.isna(l) or w == l:
291
- continue
292
- rw, rl = ratings.get(w, 1500.0), ratings.get(l, 1500.0)
293
- ew = 1.0 / (1.0 + 10 ** ((rl - rw) / 400.0))
294
- ratings[w] = rw + K * (1 - ew)
295
- ratings[l] = rl + K * (0 - (1 - ew))
296
- top_daily.append((d, max(ratings.values())))
297
- if len(top_daily) < 300:
298
- return None
299
- s = pd.DataFrame(top_daily, columns=["date", "value"]).set_index("date")
300
- s = s.resample("1D").ffill().ffill().dropna()
301
- s = s[s.index <= pd.Timestamp.today().normalize()]
302
- out = s.reset_index()
303
- out["date"] = out["date"].dt.strftime("%Y-%m-%d")
304
- if len(out) < 300 or out["value"].std() <= 0:
305
- return None
306
- print(f"[data] sports source: ATP world #1 Elo ({len(out)} daily points, "
307
- f"{years[0]}-{years[-1]})")
308
- return [{"source": "huggingface:davidtadediji/tennis-atp->world#1-elo", "series": out[["date", "value"]]}]
309
-
310
-
311
- def fetch_sports():
312
- candidates = _atp_world1_elo()
313
- if candidates:
314
- return candidates
315
- club = None
316
- for c in ["Liverpool", "RealMadrid", "BayernMunich", "Chelsea"]:
317
- club = _clubelo_series(c, tries=1)
318
- if club is not None:
319
- return [{"source": f"clubelo:{c}", "series": club}]
320
- hf = _hf_search_best(("elo rating", "chess rating history"))
321
- if hf:
322
- return [hf]
323
- return []
324
-
325
-
326
- def weather_slug() -> str:
327
- return re.sub(r"[^a-z0-9]+", "_", _WEATHER_NAME.lower()).strip("_")
328
-
329
-
330
- def set_weather_location(name: str, lat, lon):
331
- global _WEATHER_NAME, _WEATHER_LAT, _WEATHER_LON, WEATHER_NAME, WEATHER_LAT, WEATHER_LON
332
- _WEATHER_NAME = WEATHER_NAME = name
333
- _WEATHER_LAT = WEATHER_LAT = str(lat)
334
- _WEATHER_LON = WEATHER_LON = str(lon)
335
- TOPICS["weather"]["asset"] = f"{name} temperature"
336
- TOPICS["weather"]["desc"] = f"Daily mean temperature for {name} (Open-Meteo archive, stored in Kelvin)"
337
-
338
-
339
- def artifact_suffix(topic: str) -> str:
340
- """Kept for compatibility; artifacts use plain `model_<topic>.pt` names."""
341
- return ""
342
-
343
-
344
- def fetch_weather():
345
- import json as _json
346
- import urllib.request
347
- end = pd.Timestamp.today().strftime("%Y-%m-%d")
348
- url = ("https://archive-api.open-meteo.com/v1/archive"
349
- f"?latitude={_WEATHER_LAT}&longitude={_WEATHER_LON}"
350
- f"&start_date=2015-01-01&end_date={end}&daily=temperature_2m_mean&timezone=auto")
351
- req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (forecast-bot)"})
352
- try:
353
- with urllib.request.urlopen(req, timeout=45) as resp:
354
- payload = _json.loads(resp.read().decode())
355
- daily = payload.get("daily", {})
356
- dates, temps = daily.get("time", []), daily.get("temperature_2m_mean", [])
357
- s = pd.DataFrame({"date": pd.to_datetime(dates, errors="coerce"),
358
- "value": pd.to_numeric(temps, errors="coerce")})
359
- s = s.dropna().sort_values("date")
360
- s = s[s["date"] <= pd.Timestamp.today().normalize()]
361
- if len(s) < 300:
362
- return []
363
- s = s.set_index("date")
364
- s = s[~s.index.duplicated(keep="last")]
365
- s = s.resample("1D").mean().ffill().dropna()
366
- s["value"] = s["value"] + 273.15
367
- out = s.reset_index()
368
- out["date"] = out["date"].dt.strftime("%Y-%m-%d")
369
- out = out[["date", "value"]]
370
- if len(out) >= 300 and out["value"].std() > 0:
371
- print(f"[data] weather source: Open-Meteo {_WEATHER_NAME} ({len(out)} daily points, Kelvin)")
372
- return [{"source": f"open-meteo:{_WEATHER_NAME}", "series": out}]
373
- except Exception as exc:
374
- print(f"[data] Open-Meteo failed: {exc}")
375
- return []
376
-
377
-
378
- def fetch_economy():
379
- try:
380
- series = _yf_fetch("^GSPC", "2000-01-01")
381
- if series is not None:
382
- return [{"source": "yfinance:^GSPC", "series": series}]
383
- except Exception as exc:
384
- print(f"[data] yfinance S&P500 failed: {exc}")
385
- return []
386
-
387
-
388
- def fetch_energy():
389
- try:
390
- series = _yf_fetch("CL=F", "2000-01-01")
391
- if series is not None:
392
- return [{"source": "yfinance:CL=F(WTI)", "series": series}]
393
- except Exception as exc:
394
- print(f"[data] yfinance WTI failed: {exc}")
395
- return []
396
-
397
-
398
- FETCHERS = {
399
- "ai": fetch_ai,
400
- "programming": fetch_programming,
401
- "finance": fetch_finance,
402
- "sports": fetch_sports,
403
- "weather": fetch_weather,
404
- "economy": fetch_economy,
405
- "energy": fetch_energy,
406
- }
407
-
408
-
409
- def generate_synthetic(topic="finance", n=1500):
410
- import zlib
411
- seed = zlib.crc32(topic.encode()) % (2**32)
412
- rng = np.random.default_rng(seed)
413
- t = np.arange(n)
414
- trend = 100 * np.exp(0.0005 * t)
415
- season = 8 * np.sin(2 * np.pi * t / 365.25) + 3 * np.sin(2 * np.pi * t / 30.4)
416
- ar = np.zeros(n)
417
- for i in range(1, n):
418
- ar[i] = 0.94 * ar[i - 1] + rng.normal(0, 1.5)
419
- value = trend + season + ar + rng.normal(0, 0.5, n)
420
- dates = pd.date_range("2020-01-01", periods=n, freq="D")
421
- return {
422
- "source": f"synthetic:{topic}",
423
- "series": pd.DataFrame({"date": dates.strftime("%Y-%m-%d"), "value": value}),
424
- }
425
-
426
-
427
- def load(topic="finance", refresh=True):
428
- if topic not in TOPICS:
429
- raise SystemExit(f"Unknown topic {topic!r}. Choose from: {', '.join(TOPICS)}")
430
- DATA_DIR.mkdir(exist_ok=True)
431
- cache_csv = DATA_DIR / f"{topic}.csv"
432
- cache_meta = DATA_DIR / f"{topic}.meta.json"
433
- if not refresh and cache_csv.exists():
434
- series = pd.read_csv(cache_csv)
435
- meta = json.loads(cache_meta.read_text()) if cache_meta.exists() else {}
436
- return series, meta.get("source", "cache")
437
- candidates = FETCHERS[topic]()
438
- if not candidates:
439
- print(f"[data] online sources failed for '{topic}', generating synthetic dataset...")
440
- result = generate_synthetic(topic)
441
- else:
442
- result = max(candidates, key=lambda r: (pd.Timestamp(r["series"]["date"].iloc[-1]), len(r["series"])))
443
- print(f"[data] selected '{result['source']}' (ends {result['series']['date'].iloc[-1]}, "
444
- f"{len(result['series'])} pts)")
445
- series = result["series"].reset_index(drop=True)
446
- series.to_csv(cache_csv, index=False)
447
- gaps = pd.to_datetime(series["date"]).diff().dropna().dt.days
448
- meta = {
449
- "topic": topic,
450
- "source": result["source"],
451
- "rows": len(series),
452
- "freq_days": float(gaps.median()) if len(gaps) else 1.0,
453
- "start": str(series["date"].iloc[0]),
454
- "end": str(series["date"].iloc[-1]),
455
- }
456
- cache_meta.write_text(json.dumps(meta, indent=2))
457
- return series, result["source"]
458
-
459
-
460
- def main():
461
- parser = argparse.ArgumentParser()
462
- parser.add_argument("--topic", default="finance", choices=list(TOPICS))
463
- parser.add_argument("--refresh", action="store_true")
464
- args = parser.parse_args()
465
- series, source = load(topic=args.topic, refresh=args.refresh)
466
- print(f"[data] topic: {TOPICS[args.topic]['label']} ({TOPICS[args.topic]['desc']})")
467
- print(f"[data] source: {source}")
468
- print(f"[data] points: {len(series)} range: {series['date'].iloc[0]} .. {series['date'].iloc[-1]}")
469
-
470
-
471
- if __name__ == "__main__":
472
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_logger.py DELETED
@@ -1,34 +0,0 @@
1
- """Debug logging. Internal logs NEVER reach the user-facing output.
2
-
3
- Enabled only when FORECAST_DEBUG=1 (or config.debug=True). All debug output
4
- goes to stderr, so stdout stays clean for the final assistant response.
5
- """
6
-
7
- import os
8
- import sys
9
-
10
- _INTERNAL_PREFIXES = (
11
- "thought:", "user wants", "$ python", "executing", "running tool",
12
- "model inference", "the prediction has been computed", "debug:",
13
- )
14
-
15
-
16
- def is_debug() -> bool:
17
- return os.environ.get("FORECAST_DEBUG", "").lower() in ("1", "true", "yes")
18
-
19
-
20
- def debug(msg: str, enabled: bool = None):
21
- if enabled is None:
22
- enabled = is_debug()
23
- if enabled:
24
- print(f"[debug] {msg}", file=sys.stderr, flush=True)
25
-
26
-
27
- def strip_internal(text: str) -> str:
28
- """Drop any line that looks like an internal execution log."""
29
- out = []
30
- for line in text.splitlines():
31
- if any(line.strip().lower().startswith(p) for p in _INTERNAL_PREFIXES):
32
- continue
33
- out.append(line)
34
- return "\n".join(out).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
model.py DELETED
@@ -1,63 +0,0 @@
1
- import torch
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__()
46
- self.horizon = horizon
47
- self.lstm = nn.LSTM(
48
- input_size=input_size,
49
- hidden_size=hidden_size,
50
- num_layers=num_layers,
51
- batch_first=True,
52
- dropout=dropout if num_layers > 1 else 0.0,
53
- )
54
- self.head = nn.Sequential(
55
- nn.Linear(hidden_size, hidden_size),
56
- nn.ReLU(),
57
- nn.Dropout(dropout),
58
- nn.Linear(hidden_size, horizon),
59
- )
60
-
61
- def forward(self, x):
62
- out, _ = self.lstm(x)
63
- return self.head(out[:, -1, :])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
multi.py DELETED
@@ -1,62 +0,0 @@
1
- import contextlib
2
- import io
3
- import json
4
- import sys
5
- from pathlib import Path
6
-
7
- import torch
8
-
9
- import data
10
- import debug_logger
11
- import prediction_engine
12
- import train
13
-
14
- BASE_DIR = Path(__file__).resolve().parent
15
-
16
-
17
- def _quiet():
18
- """Silence internal stdout unless FORECAST_DEBUG=1."""
19
- if debug_logger.is_debug():
20
- return contextlib.nullcontext()
21
- return contextlib.redirect_stdout(io.StringIO())
22
-
23
-
24
- def run_topic(topic, epochs=60, horizon_days=7, refresh_data=False, device="cpu"):
25
- print(f"\nTOPIC: {data.TOPICS[topic]['label']} - {data.TOPICS[topic]['desc']}")
26
- with _quiet():
27
- series, source = data.load(topic=topic, refresh=refresh_data)
28
- train.train_topic(topic, series, source, epochs=epochs,
29
- horizon_days=horizon_days, device=device)
30
- pred = prediction_engine.run_prediction(topic, horizon_days=horizon_days,
31
- device=device, debug=debug_logger.is_debug())
32
- return pred
33
-
34
-
35
- def main():
36
- topics = list(data.TOPICS)
37
- if len(sys.argv) > 1 and sys.argv[1] not in ("--help",):
38
- topics = [t for t in sys.argv[1:] if t in data.TOPICS] or topics
39
- results = []
40
- for topic in topics:
41
- try:
42
- results.append(run_topic(topic))
43
- except Exception as exc:
44
- print(f"[multi] topic '{topic}' failed: {exc}")
45
-
46
- print(f"\n{'=' * 60}\nSUMMARY\n{'=' * 60}")
47
- for r in results:
48
- unit = r["unit"]
49
- beat = r["naive_baseline_mae"] - r["val_mae"]
50
- verdict = f"beats naive by {beat:,.1f} {unit}" if beat > 0 else f"~naive baseline ({unit})"
51
- print(f"\n{r['label']} [{r['source']}]")
52
- print(f" accuracy: MAPE={r['val_h1_mape'] * 100:.2f}% MAE={r['val_mae']:,.2f} {unit} ({verdict})")
53
- print(f" {r['date_range_start']} -> {r['date_range_end']} center ~{r['center_estimate']:,.2f} "
54
- f"{unit} outlook={r['direction']}")
55
- print(f" charts/report: {r['chart']} / {r['report']}")
56
-
57
- (BASE_DIR / "results.json").write_text(json.dumps(results, indent=2))
58
- print(f"\n[done] full results written to results.json; charts at prediction_<topic>.png")
59
-
60
-
61
- if __name__ == "__main__":
62
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
predict.py DELETED
@@ -1,212 +0,0 @@
1
- import argparse
2
- import json
3
- from pathlib import Path
4
-
5
- import numpy as np
6
- import pandas as pd
7
- import torch
8
- import matplotlib
9
- matplotlib.use("Agg")
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):
21
- unit = cfg.get("unit", "")
22
- label = cfg.get("topic_label", topic)
23
- beat = cfg["naive_baseline_mae"] - cfg["val_mae"]
24
- lines = [
25
- "=" * 62,
26
- f"PREDICTION REPORT: {label}",
27
- "=" * 62,
28
- "",
29
- f"Topic : {topic}",
30
- f"Dataset : {cfg['source']}",
31
- f"Model : LSTM ({cfg['num_layers']} layers, {cfg['hidden_size']} hidden units)",
32
- f"Last known value : {cfg['last_date']} = {cfg['last_value']:,.2f} {unit}",
33
- "",
34
- "VALIDATION (held-out test set)",
35
- f" Test MAE : {cfg['val_mae']:,.2f} {unit}",
36
- f" Relative MAE : {cfg['val_rel_mae'] * 100:.2f}%",
37
- f" H1 MAPE : {cfg['val_h1_mape'] * 100:.2f}%",
38
- f" Naive baseline : {cfg['naive_baseline_mae']:,.2f} {unit}"
39
- + (f" (model beats it by {beat:,.2f} {unit})" if beat > 0 else ""),
40
- "",
41
- "FUTURE PREDICTIONS",
42
- ]
43
- for r in rows:
44
- lines.append(f" {r['date']} -> {r['predicted_value']:,.2f} {unit}")
45
- lines += [
46
- "",
47
- "NOTE: forecasts are statistical estimates on a hold-out",
48
- "validated model; no model can predict the future with 100%",
49
- "accuracy. Treat these as central-point predictions.",
50
- f"Generated: {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')}",
51
- ]
52
- txt_path = BASE_DIR / f"prediction_{topic}{data.artifact_suffix(topic)}.txt"
53
- txt_path.write_text("\n".join(lines), encoding="utf-8")
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"],
122
- dropout=cfg["dropout"],
123
- horizon=cfg["horizon_steps"],
124
- ).to(device)
125
- model.load_state_dict(torch.load(model_path, map_location=device))
126
- model.eval()
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"]
146
- freq_days = cfg.get("horizon_days", 7) / cfg["horizon_steps"]
147
-
148
- want_days = float(days) if days else cfg["horizon_days"]
149
- n_steps = int(round(want_days / freq_days)) if freq_days else cfg["horizon_steps"]
150
-
151
- last_val = vals[-1]
152
- rel = vals[-lb:] / last_val - 1.0
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({
160
- "date": (last_date + pd.Timedelta(days=freq_days * (step + 1))).strftime("%Y-%m-%d"),
161
- "predicted_value": round(float(cur_val), 2),
162
- })
163
-
164
- recent = series.tail(lb)
165
- history = pd.DataFrame({"date": recent["date"], "value": recent["value"], "predicted": np.nan})
166
- future = pd.DataFrame({"date": [r["date"] for r in rows],
167
- "value": np.nan,
168
- "predicted": [r["predicted_value"] for r in rows]})
169
- plot_df = pd.concat([history, future], ignore_index=True)
170
- plot_df["d"] = pd.to_datetime(plot_df["date"])
171
-
172
- chart_path = BASE_DIR / f"prediction_{topic}{data.artifact_suffix(topic)}.png"
173
- fig, ax = plt.subplots(figsize=(11, 5))
174
- ax.plot(plot_df["d"], plot_df["value"], label="actual", color="#2563eb")
175
- ax.plot(plot_df["d"], plot_df["predicted"], label="model prediction", color="#dc2626", marker="o", markersize=4)
176
- ax.axvline(last_date, color="gray", ls="--", lw=1)
177
- unit = cfg.get("unit", "")
178
- ax.set_title(f"{cfg.get('topic_label', topic)}: {cfg['source']} "
179
- f"(val MAE={cfg['val_mae']:,.1f} {unit}, H1 MAPE={cfg['val_h1_mape'] * 100:.2f}%)")
180
- ax.legend()
181
- fig.autofmt_xdate()
182
- fig.tight_layout()
183
- fig.savefig(chart_path, dpi=130)
184
- plt.close(fig)
185
- txt_path = write_text_report(rows, cfg, topic)
186
- return rows, cfg, chart_path, txt_path
187
-
188
-
189
- def main():
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})")
204
- print(f"\nFuture predictions:")
205
- for r in rows:
206
- print(f" {r['date']} -> {r['predicted_value']:,.2f} {unit}")
207
- print(f"\nChart saved to {chart_path}")
208
- print(f"Text report saved to {txt_path}")
209
-
210
-
211
- if __name__ == "__main__":
212
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
prediction_engine.py DELETED
@@ -1,97 +0,0 @@
1
- """Prediction engine: runs model inference only. No formatting, no printing.
2
-
3
- Calculates scientifically meaningful, clearly separated metrics:
4
- - net_change_pct : change along the forecast path (first -> last forecast)
5
- - forecast_range_pct : (max - min) of the forecast path / first forecast
6
- - current_to_forecast_pct : change from the latest OBSERVED value to the final forecast
7
- - direction : rising/falling/sideways via configurable threshold
8
- - primary_forecast : the forecast value at the END of the requested horizon
9
- - center_estimate : mean of the forecast path (a secondary statistic)
10
-
11
- The response_formatter presents these; this module never formats output.
12
- Internal logging goes through debug_logger (stderr).
13
- """
14
-
15
- import contextlib
16
- import io
17
-
18
- import numpy as np
19
-
20
- import data
21
- import debug_logger
22
- import predict
23
-
24
- DEFAULT_DIRECTION_THRESHOLD_PCT = 0.2 # configurable; can become domain-specific
25
-
26
-
27
- def _pct(change: float, base: float) -> float:
28
- return float(change / abs(base) * 100.0) if base != 0 else 0.0
29
-
30
-
31
- def run_prediction(topic: str, horizon_days: float = 7.0,
32
- device: str = "cpu", debug: bool = False,
33
- direction_threshold_pct: float = DEFAULT_DIRECTION_THRESHOLD_PCT) -> dict:
34
- """Structured prediction result from the trained model. The inference
35
- call's own stdout (status lines) is captured so nothing internal leaks."""
36
- debug_logger.debug(f"prediction_engine: topic={topic} horizon={horizon_days}d "
37
- f"thr={direction_threshold_pct}%", debug)
38
- buf = io.StringIO()
39
- with contextlib.redirect_stdout(buf):
40
- rows, cfg, chart_path, txt_path = predict.predict(topic, days=horizon_days, device=device)
41
- debug_logger.debug(f"prediction_engine internal: {buf.getvalue()[:300]!r}", debug)
42
-
43
- vals = np.array([r["predicted_value"] for r in rows], dtype=np.float64)
44
- if len(vals) == 0 or not np.all(np.isfinite(vals)):
45
- raise ValueError("model produced an empty or invalid forecast")
46
-
47
- first = float(vals[0])
48
- last = float(vals[-1])
49
- last_known = cfg.get("last_value")
50
-
51
- net_change_pct = _pct(last - first, first)
52
- forecast_range_pct = _pct(vals.max() - vals.min(), first)
53
- current_to_forecast_pct = (
54
- _pct(last - float(last_known), float(last_known))
55
- if last_known is not None and float(last_known) != 0 else None
56
- )
57
-
58
- if net_change_pct > direction_threshold_pct:
59
- direction = "rising"
60
- elif net_change_pct < -direction_threshold_pct:
61
- direction = "falling"
62
- else:
63
- direction = "sideways"
64
-
65
- result = {
66
- "topic": topic,
67
- "label": cfg.get("topic_label", topic),
68
- "unit": cfg.get("unit", ""),
69
- "asset": data.TOPICS.get(topic, {}).get("asset", cfg.get("topic_label", topic)),
70
- "source": cfg["source"],
71
- "date_range_start": rows[0]["date"],
72
- "date_range_end": rows[-1]["date"],
73
- "rows": rows,
74
- "values": vals.tolist(),
75
- "primary_forecast": last,
76
- "center_estimate": float(np.mean(vals)),
77
- "first_value": first,
78
- "last_value": last,
79
- "last_known_value": float(last_known) if last_known is not None else None,
80
- "last_known_date": cfg.get("last_date"),
81
- "net_change_pct": net_change_pct,
82
- "forecast_range_pct": forecast_range_pct,
83
- "current_to_forecast_pct": current_to_forecast_pct,
84
- "direction": direction,
85
- "direction_threshold_pct": direction_threshold_pct,
86
- "val_mae": cfg["val_mae"],
87
- "val_h1_mape": cfg["val_h1_mape"],
88
- "naive_baseline_mae": cfg["naive_baseline_mae"],
89
- "chart": str(chart_path),
90
- "report": str(txt_path),
91
- }
92
- debug_logger.debug(
93
- f"prediction_engine: {topic} end-of-horizon={last} direction={direction} "
94
- f"net={net_change_pct:+.3f}% range={forecast_range_pct:.3f}% "
95
- f"cur->end={current_to_forecast_pct if current_to_forecast_pct is None else format(current_to_forecast_pct, '+.3f')}%",
96
- debug)
97
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt DELETED
@@ -1,8 +0,0 @@
1
- torch>=2.0
2
- numpy
3
- pandas
4
- matplotlib
5
- scikit-learn
6
- datasets
7
- huggingface_hub
8
- yfinance
 
 
 
 
 
 
 
 
 
response.py DELETED
@@ -1,143 +0,0 @@
1
- """Orchestrator: intent detection -> prediction engine -> response formatter.
2
-
3
- Only the final formatted answer is returned; internal execution details stay
4
- in debug_logger (stderr, off by default). Raw tool output never reaches the
5
- user, and each prediction is presented exactly once.
6
- """
7
-
8
- import re
9
- import sys
10
- from dataclasses import dataclass, field
11
- from typing import Iterator, Optional
12
-
13
- import debug_logger
14
- import prediction_engine
15
- import response_formatter
16
- from data import TOPICS
17
-
18
- SYSTEM_PROMPT = (
19
- "You are a forecasting assistant. Present the model's prediction directly "
20
- "and naturally, exactly once. Never echo the user's prompt, never expose "
21
- "internal execution details or reasoning, and never present a forecast "
22
- "as guaranteed."
23
- )
24
-
25
- INTENT_PATTERNS = {
26
- "finance": {"bitcoin", "btc", "crypto", "cryptocurrency"},
27
- "ai": {"nvidia", "nvda", "gpu"},
28
- "programming": {"npm", "react", "downloads", "javascript"},
29
- "sports": {"tennis", "atp", "elo", "sport", "rating"},
30
- "weather": {"weather", "rain", "raining", "temperature", "snow", "sunny",
31
- "storm", "humidity", "forecast"},
32
- "economy": {"economy", "gspc", "sp500", "market", "stocks", "inflation"},
33
- "energy": {"oil", "wti", "crude", "energy", "gas", "petroleum"},
34
- }
35
-
36
-
37
- @dataclass
38
- class GenerationConfig:
39
- debug: bool = False
40
- streaming: bool = False
41
- chunk_delay: float = 0.02
42
- max_history_turns: int = 8
43
- max_input_chars: int = 4000
44
- default_horizon_days: float = 7.0
45
- detailed: bool = False
46
-
47
-
48
- @dataclass
49
- class ConversationContext:
50
- turns: list = field(default_factory=list)
51
- max_turns: int = 8
52
-
53
- def add(self, role: str, content: str):
54
- self.turns.append({"role": role, "content": content})
55
- if len(self.turns) > self.max_turns * 2:
56
- self.turns = self.turns[-self.max_turns * 2:]
57
-
58
-
59
- def detect_intent(query: str, config: GenerationConfig) -> dict:
60
- q = query.lower()
61
- tokens = set(re.findall(r"[a-z0-9$%-]+", q))
62
-
63
- unsupported = tokens & {"earthquake", "volcano", "traffic", "election", "lottery",
64
- "population", "pandemic"}
65
- topics = [t for t in TOPICS if t in tokens or tokens & INTENT_PATTERNS.get(t, set())]
66
- explicit_all = any(w in q for w in ("compare", "all topics", "everything", "each topic"))
67
- greeting = bool(tokens & {"hi", "hello", "hey", "thanks"}) and len(tokens) <= 3
68
-
69
- m = re.search(r"(\d+)\s*(?:-|\s)?\s*(?:days?|d)\b", q)
70
- if m:
71
- horizon = float(m.group(1))
72
- elif any(w in q for w in ("month", "30 days", "30d")):
73
- horizon = 30.0
74
- elif "tomorrow" in tokens:
75
- horizon = 1.0
76
- elif "week" in tokens:
77
- horizon = 7.0
78
- else:
79
- horizon = config.default_horizon_days
80
-
81
- detailed = config.detailed or bool(tokens & {"detail", "detailed", "breakdown", "table", "chart"})
82
- return {"greeting": greeting, "topics": topics, "compare": explicit_all,
83
- "horizon": horizon, "detailed": detailed,
84
- "unsupported": bool(unsupported) and not topics}
85
-
86
-
87
- def _run_topic(topic: str, horizon: float, device: str, config: GenerationConfig):
88
- try:
89
- return prediction_engine.run_prediction(topic, horizon_days=horizon,
90
- device=device, debug=config.debug)
91
- except Exception as exc:
92
- debug_logger.debug(f"engine error for {topic}: {exc!r}", config.debug)
93
- return None
94
-
95
-
96
- def generate_response(query: str, device: str = "cpu",
97
- config: Optional[GenerationConfig] = None) -> str:
98
- """USER INPUT -> intent -> prediction/engine -> formatted answer."""
99
- config = config or GenerationConfig()
100
- debug_logger.debug(f"user: {query[:200]!r}", config.debug)
101
-
102
- query = (query or "").strip()
103
- if not query:
104
- return "Please ask something - for example: *what will bitcoin do next week?*"
105
- query = query[: config.max_input_chars]
106
-
107
- try:
108
- intent = detect_intent(query, config)
109
- except Exception as exc:
110
- debug_logger.debug(f"intent error: {exc!r}", config.debug)
111
- return response_formatter.format_error()
112
-
113
- if intent["greeting"]:
114
- return response_formatter.format_greeting()
115
-
116
- if intent.get("unsupported"):
117
- return response_formatter.format_unknown()
118
-
119
- if intent["compare"] or len(intent["topics"]) > 1 or not intent["topics"]:
120
- topics = intent["topics"] if intent["topics"] and intent["compare"] else list(TOPICS)
121
- preds = [_run_topic(t, intent["horizon"], device, config) for t in topics]
122
- if all(p is None for p in preds):
123
- return response_formatter.format_error()
124
- return response_formatter.format_comparison([p for p in preds if p is not None],
125
- debug=config.debug)
126
-
127
- pred = _run_topic(intent["topics"][0], intent["horizon"], device, config)
128
- if pred is None:
129
- return response_formatter.format_error()
130
- return response_formatter.format_single(pred, detailed=intent["detailed"],
131
- debug=config.debug)
132
-
133
-
134
- def stream_response(query: str, device: str = "cpu",
135
- config: Optional[GenerationConfig] = None,
136
- pregenerated: Optional[str] = None) -> Iterator[str]:
137
- """Streaming: the full answer is generated first (or passed in), then
138
- delivered line by line, so no partial or internal fragments reach the UI."""
139
- config = config or GenerationConfig()
140
- full = pregenerated if pregenerated is not None else generate_response(
141
- query, device=device, config=config)
142
- for chunk in full.splitlines(True):
143
- yield chunk
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
response_formatter.py DELETED
@@ -1,182 +0,0 @@
1
- """Response formatter: presents structured prediction metrics naturally.
2
-
3
- Never alters predictions, never invents confidence/probability/factors.
4
- Only displays metrics the prediction engine actually computed. Adapts
5
- wording to direction and prediction type.
6
- """
7
-
8
- import pandas as pd
9
-
10
- import debug_logger
11
-
12
- _RISK_NOTES = {
13
- "USD": "This is a model-generated forecast, and actual market behavior may differ.",
14
- "Elo": "This is a model-generated forecast; actual results depend on future matches.",
15
- "downloads": "This is a model-generated estimate; actual download counts may differ.",
16
- "K": "This is a model-generated forecast; actual weather may differ.",
17
- }
18
- _DEFAULT_NOTE = "This is a model-generated estimate, and actual values may differ."
19
-
20
-
21
- def fmt_range_dates(start: str, end: str) -> str:
22
- a, b = pd.Timestamp(start), pd.Timestamp(end)
23
- if a.date() == b.date():
24
- return f"{a.strftime('%B')} {a.day}, {a.year}"
25
- if (a.year, a.month) == (b.year, b.month):
26
- return f"{a.strftime('%B')} {a.day}-{b.day}, {a.year}"
27
- if a.year == b.year:
28
- return f"{a.strftime('%B')} {a.day} - {b.strftime('%B')} {b.day}, {b.year}"
29
- return f"{a.strftime('%B %d, %Y')} - {b.strftime('%B %d, %Y')}"
30
-
31
-
32
- def fmt_value(v: float, unit: str) -> str:
33
- if v is None:
34
- return "n/a"
35
- if unit == "downloads":
36
- return f"{v / 1e6:.2f}M" if abs(v) >= 1e6 else f"{v:,.0f}"
37
- if unit == "USD":
38
- return f"${v:,.0f}" if abs(v) >= 1000 else f"${v:,.2f}"
39
- if unit == "K":
40
- return f"{v - 273.15:.1f} °C"
41
- if unit:
42
- return f"{v:,.2f} {unit}"
43
- return f"{v:,.2f}"
44
-
45
-
46
- def fmt_pct(pct: float, signed: bool = False) -> str:
47
- """Human-readable percent; very small values shown as <0.01%."""
48
- if pct is None:
49
- return "n/a"
50
- if abs(pct) < 0.005:
51
- return "<0.01%"
52
- return f"{pct:+.2f}%" if signed else f"{pct:.2f}%"
53
-
54
-
55
- def _horizon_word(start: str, end: str) -> str:
56
- days = (pd.Timestamp(end) - pd.Timestamp(start)).days + 1
57
- if days <= 1:
58
- return "tomorrow"
59
- if days == 7:
60
- return "next week"
61
- if days == 30:
62
- return "next month"
63
- return f"over the next {days} days"
64
-
65
-
66
- _DIRECTION_VERB = {"sideways": "relatively sideways", "rising": "upward", "falling": "downward"}
67
- _DIRECTION_LABEL = {"sideways": "Sideways", "rising": "Rising", "falling": "Falling"}
68
-
69
-
70
- def _interpretation(pred: dict, unit: str, horizon_word: str) -> str:
71
- asset = pred.get("asset", pred["label"])
72
- direction = pred["direction"]
73
- end_val = fmt_value(pred["primary_forecast"], unit)
74
- start_val = fmt_value(pred["first_value"], unit)
75
-
76
- if direction == "rising":
77
- return (f"The model forecasts {_DIRECTION_VERB['rising']} movement for {asset} {horizon_word}, "
78
- f"with the predicted level increasing from {start_val} to approximately **{end_val}** "
79
- f"({fmt_pct(pred['net_change_pct'], signed=True)} net change).")
80
- if direction == "falling":
81
- return (f"The model forecasts {_DIRECTION_VERB['falling']} movement for {asset} {horizon_word}, "
82
- f"with the predicted level decreasing from {start_val} to approximately **{end_val}** "
83
- f"({fmt_pct(pred['net_change_pct'], signed=True)} net change).")
84
- return (f"The model forecasts {_DIRECTION_VERB['sideways']} movement for {asset} {horizon_word}, "
85
- f"with an estimated level around **{end_val}**.")
86
-
87
-
88
- def _summary_sentence(pred: dict, unit: str, horizon_word: str) -> str:
89
- """A single conversational sentence restating the forecast in plain words."""
90
- asset = pred.get("asset", pred["label"])
91
- direction = pred["direction"]
92
- end_val = fmt_value(pred["primary_forecast"], unit)
93
- change = fmt_pct(pred["net_change_pct"], signed=True)
94
- if direction == "sideways":
95
- return (f"In short, the model expects {asset} to stay roughly stable {horizon_word}, "
96
- f"hovering near {end_val}.")
97
- if direction == "rising":
98
- return (f"In short, the model expects {asset} to climb {change} {horizon_word}, "
99
- f"reaching about {end_val}.")
100
- return (f"In short, the model expects {asset} to slip {change} {horizon_word}, "
101
- f"settling around {end_val}.")
102
-
103
-
104
- def format_single(pred: dict, detailed: bool = False, debug: bool = False) -> str:
105
- """Summary sentence + title, date range, interpretation, key values, note.
106
- Only shows metrics that exist and are meaningful. Single coherent answer."""
107
- unit = pred["unit"]
108
- asset = pred.get("asset", pred["label"])
109
- direction = pred["direction"]
110
- horizon_word = _horizon_word(pred["date_range_start"], pred["date_range_end"])
111
- end_val = fmt_value(pred["primary_forecast"], unit)
112
-
113
- lines = [
114
- _summary_sentence(pred, unit, horizon_word),
115
- "",
116
- f"{asset} Forecast",
117
- "",
118
- fmt_range_dates(pred["date_range_start"], pred["date_range_end"]),
119
- "",
120
- _interpretation(pred, unit, horizon_word),
121
- "",
122
- f"Outlook: {_DIRECTION_LABEL[direction]}",
123
- f"Predicted level ({pred['date_range_end']}): ~{end_val}",
124
- f"Expected change: {fmt_pct(pred['net_change_pct'], signed=True)}",
125
- ]
126
-
127
- if pred.get("forecast_range_pct") is not None:
128
- lines.append(f"Forecast range: {fmt_pct(abs(pred['forecast_range_pct']))}")
129
- if pred.get("current_to_forecast_pct") is not None and pred.get("last_known_value") is not None:
130
- lines.append(
131
- f"From latest observed value ({fmt_value(pred['last_known_value'], unit)}, "
132
- f"{pred.get('last_known_date', 'latest')}): {fmt_pct(pred['current_to_forecast_pct'], signed=True)}")
133
-
134
- if detailed:
135
- lines += ["", "| Date | Prediction |", "|---|---|"]
136
- for r in pred["rows"]:
137
- lines.append(f"| {r['date']} | {fmt_value(r['predicted_value'], unit)} |")
138
- lines += [
139
- "",
140
- f"Path average: {fmt_value(pred['center_estimate'], unit)}. "
141
- f"Validated accuracy on unseen data: MAPE {pred['val_h1_mape'] * 100:.2f}% "
142
- f"(MAE {pred['val_mae']:,.2f} {unit}).",
143
- ]
144
-
145
- lines += ["", _RISK_NOTES.get(unit, _DEFAULT_NOTE)]
146
- return debug_logger.strip_internal("\n".join(lines))
147
-
148
-
149
- def format_comparison(preds: list, debug: bool = False) -> str:
150
- """One row per topic; shows end-of-horizon forecast + net change."""
151
- lines = ["Forecast Summary", "",
152
- "| Topic | Outlook | Predicted (end) | Expected change | Accuracy |",
153
- "|---|---|---|---|---|"]
154
- for p in preds:
155
- if p is None:
156
- continue
157
- unit = p["unit"]
158
- lines.append(
159
- f"| {p['label']} | {_DIRECTION_LABEL[p['direction']]} | "
160
- f"~{fmt_value(p['primary_forecast'], unit)} "
161
- f"({fmt_range_dates(p['date_range_start'], p['date_range_end'])}) | "
162
- f"{fmt_pct(p['net_change_pct'], signed=True)} | "
163
- f"MAPE {p['val_h1_mape'] * 100:.2f}% |")
164
- lines += ["", _DEFAULT_NOTE]
165
- return debug_logger.strip_internal("\n".join(lines))
166
-
167
-
168
- def format_error(message: str = "") -> str:
169
- return "I hit a problem generating that forecast. Please try again in a moment."
170
-
171
-
172
- def format_greeting() -> str:
173
- return ("Hello! I generate forecasts from trained models. Ask about **AI**, "
174
- "**Programming**, **Finance**, **Sports**, **Weather**, **Economy**, "
175
- "or **Energy** - for example, *what will bitcoin do next week?*")
176
-
177
-
178
- def format_unknown() -> str:
179
- import data
180
- parts = ", ".join(f"**{info['label']}**" for info in data.TOPICS.values())
181
- return (f"I can forecast these topics: {parts}. "
182
- f"Try: *predict finance for 14 days* or *what will the weather do tomorrow?*")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
run_all.ps1 DELETED
@@ -1,21 +0,0 @@
1
- ## Full automation: fetch data -> train -> evaluate -> predict (all topics)
2
- ## Usage: .\run_all.ps1 [-Days 7] [-Epochs 60] [-Topics ai,programming,finance,sports] [-RefreshData]
3
-
4
- param(
5
- [double]$Days = 7,
6
- [int]$Epochs = 60,
7
- [string[]]$Topics = @("ai", "programming", "finance", "sports"),
8
- [switch]$RefreshData
9
- )
10
-
11
- $ErrorActionPreference = "Stop"
12
- $py = Join-Path $PSScriptRoot ".venv\Scripts\python.exe"
13
- if (-not (Test-Path $py)) { $py = "python" }
14
-
15
- if ($RefreshData) {
16
- foreach ($t in $Topics) {
17
- & $py "$PSScriptRoot\data.py" --topic $t --refresh
18
- }
19
- }
20
-
21
- & $py "$PSScriptRoot\multi.py" @Topics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
train.py DELETED
@@ -1,163 +0,0 @@
1
- import argparse
2
- import json
3
- from pathlib import Path
4
-
5
- import numpy as np
6
- import pandas as pd
7
- import torch
8
- import torch.nn as nn
9
- from torch.utils.data import DataLoader, TensorDataset
10
-
11
- import data
12
- from model import LSTMForecaster
13
-
14
-
15
- def build_windows(values, lookback, horizon):
16
- xs, ys, bases = [], [], []
17
- for i in range(lookback, len(values) - horizon):
18
- base = values[i - 1]
19
- if not np.isfinite(base) or base <= 0:
20
- continue
21
- xs.append(values[i - lookback:i] / base - 1.0)
22
- ys.append(values[i:i + horizon] / base - 1.0)
23
- bases.append(base)
24
- return (np.array(xs, dtype=np.float32), np.array(ys, dtype=np.float32), np.array(bases, dtype=np.float64))
25
-
26
-
27
- def evaluate(model, loader, device):
28
- model.eval()
29
- preds, actuals, bases = [], [], []
30
- with torch.no_grad():
31
- for xb, yb, bb in loader:
32
- preds.append(model(xb.to(device)).cpu().numpy())
33
- actuals.append(yb.numpy())
34
- bases.append(bb.numpy())
35
- preds = np.concatenate(preds)
36
- actuals = np.concatenate(actuals)
37
- bases = np.concatenate(bases)[:, None]
38
- pred_usd = bases * (1.0 + preds)
39
- act_usd = bases * (1.0 + actuals)
40
- mae = float(np.mean(np.abs(pred_usd - act_usd)))
41
- rel_mae = float(np.mean(np.abs(preds - actuals)))
42
- h1_mape = float(np.mean(np.abs((act_usd[:, 0] - pred_usd[:, 0]) / act_usd[:, 0])))
43
- naive_mae = float(np.mean(np.abs(act_usd - bases)))
44
- return mae, rel_mae, h1_mape, naive_mae, pred_usd, act_usd
45
-
46
-
47
- def train_model(series, lookback=60, horizon=7, epochs=60, batch_size=32, lr=1e-3, device="cpu", seed=0,
48
- hidden_size=64, num_layers=2, dropout=0.1, patience=15):
49
- torch.manual_seed(seed)
50
- np.random.seed(seed)
51
- vals = series["value"].to_numpy(dtype=np.float64)
52
- n = len(vals)
53
- n_test = min(180, max(horizon * 5, n // 5))
54
-
55
- x_all, y_all, b_all = build_windows(vals, lookback, horizon)
56
- train_count = max(lookback, (n - n_test - horizon) - lookback)
57
- test_start = (n - n_test) - lookback
58
- x_train, y_train = x_all[:train_count], y_all[:train_count]
59
- x_test, y_test, b_test = x_all[test_start:], y_all[test_start:], b_all[test_start:]
60
-
61
- train_ds = TensorDataset(torch.from_numpy(x_train).unsqueeze(-1), torch.from_numpy(y_train))
62
- train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
63
- test_loader = DataLoader(
64
- TensorDataset(torch.from_numpy(x_test).unsqueeze(-1), torch.from_numpy(y_test), torch.from_numpy(b_test)),
65
- batch_size=512,
66
- )
67
-
68
- model = LSTMForecaster(hidden_size=hidden_size, num_layers=num_layers, dropout=dropout, horizon=horizon).to(device)
69
- opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-5)
70
- sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, patience=6, factor=0.5)
71
- crit = nn.HuberLoss()
72
-
73
- best_mae, best_state, patience_seen = float("inf"), None, 0
74
- for epoch in range(1, epochs + 1):
75
- model.train()
76
- total = 0.0
77
- for xb, yb in train_loader:
78
- opt.zero_grad()
79
- loss = crit(model(xb.to(device)), yb.to(device))
80
- loss.backward()
81
- torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
82
- opt.step()
83
- total += float(loss.item()) * len(xb)
84
- mae, rel_mae, mape, naive, _, _ = evaluate(model, test_loader, device)
85
- sched.step(mae)
86
- print(f"epoch {epoch:3d}/{epochs} loss={total / len(train_ds):.5f} "
87
- f"val_MAE=${mae:,.0f} rel_MAE={rel_mae * 100:.2f}% H1_MAPE={mape * 100:.2f}%")
88
- if mae < best_mae - 1e-6:
89
- best_mae, patience_seen = mae, 0
90
- best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
91
- else:
92
- patience_seen += 1
93
- if patience_seen >= patience:
94
- print("[train] early stopping")
95
- break
96
-
97
- if best_state is not None:
98
- model.load_state_dict(best_state)
99
- mae, rel_mae, h1_mape, naive_mae, _, _ = evaluate(model, test_loader, device)
100
- return model, {"mae": mae, "rel_mae": rel_mae, "h1_mape": h1_mape, "naive_mae": naive_mae,
101
- "lookback": lookback, "horizon": horizon}
102
-
103
-
104
- def train_topic(topic, series, source, epochs=60, horizon_days=7, device="cpu", hidden_size=64, num_layers=2,
105
- dropout=0.1, lookback=60, patience=15):
106
- gaps_days = float(pd.to_datetime(series["date"]).diff().dropna().dt.days.median()) if len(series) > 1 else 1.0
107
- horizon_steps = max(1, int(round(horizon_days / gaps_days))) if gaps_days > 0 else horizon_days
108
-
109
- print(f"[train] topic={topic} dataset={source} points={len(series)} "
110
- f"horizon={horizon_steps} steps ({horizon_days} days)")
111
- model, stats = train_model(series, lookback=lookback, horizon=horizon_steps, epochs=epochs, device=device,
112
- hidden_size=hidden_size, num_layers=num_layers, dropout=dropout, patience=patience)
113
-
114
- suffix = data.artifact_suffix(topic)
115
- model_path = Path(__file__).resolve().parent / f"model_{topic}{suffix}.pt"
116
- config_path = Path(__file__).resolve().parent / f"config_{topic}{suffix}.json"
117
- torch.save(model.state_dict(), model_path)
118
- cfg = {
119
- "topic": topic,
120
- "topic_label": data.TOPICS[topic]["label"],
121
- "unit": data.TOPICS[topic]["unit"],
122
- "source": source,
123
- "lookback": stats["lookback"],
124
- "horizon_steps": horizon_steps,
125
- "horizon_days": horizon_days,
126
- "hidden_size": hidden_size,
127
- "num_layers": num_layers,
128
- "dropout": dropout,
129
- "val_mae": stats["mae"],
130
- "val_rel_mae": stats["rel_mae"],
131
- "val_h1_mape": stats["h1_mape"],
132
- "naive_baseline_mae": stats["naive_mae"],
133
- "last_date": str(series["date"].iloc[-1]),
134
- "last_value": float(series["value"].iloc[-1]),
135
- }
136
- config_path.write_text(json.dumps(cfg, indent=2))
137
- improvement = (stats["naive_mae"] - stats["mae"]) / stats["naive_mae"] * 100 if stats["naive_mae"] else 0.0
138
- print(f"\n[result] {topic}: test MAE={stats['mae']:,.2f} rel_MAE={stats['rel_mae'] * 100:.2f}% "
139
- f"H1 MAPE={stats['h1_mape'] * 100:.2f}%")
140
- print(f"[result] naive(persistence) MAE={stats['naive_mae']:,.2f} model improvement={improvement:+.1f}%")
141
- print(f"[result] model -> {model_path.name}, config -> {config_path.name}")
142
- return model, cfg
143
-
144
-
145
- def main():
146
- parser = argparse.ArgumentParser()
147
- parser.add_argument("--topic", default="finance", choices=list(data.TOPICS))
148
- parser.add_argument("--refresh-data", action="store_true")
149
- parser.add_argument("--epochs", type=int, default=60)
150
- parser.add_argument("--horizon", type=int, default=7)
151
- parser.add_argument("--hidden-size", type=int, default=64)
152
- parser.add_argument("--num-layers", type=int, default=2)
153
- parser.add_argument("--lookback", type=int, default=60)
154
- parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
155
- args = parser.parse_args()
156
-
157
- series, source = data.load(topic=args.topic, refresh=args.refresh_data)
158
- train_topic(args.topic, series, source, epochs=args.epochs, horizon_days=args.horizon, device=args.device,
159
- hidden_size=args.hidden_size, num_layers=args.num_layers, lookback=args.lookback)
160
-
161
-
162
- if __name__ == "__main__":
163
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
train_unified.py DELETED
@@ -1,187 +0,0 @@
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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
weather_cities.py DELETED
@@ -1,40 +0,0 @@
1
- """Weather city registry (name, latitude, longitude). Cities are keyed by
2
- lowercase token; add entries to expand weather coverage without touching
3
- any other module."""
4
-
5
- CITIES = {
6
- "chennai": {"name": "Chennai, India", "lat": 13.0827, "lon": 80.2707},
7
- "mumbai": {"name": "Mumbai, India", "lat": 19.076, "lon": 72.8777},
8
- "delhi": {"name": "Delhi, India", "lat": 28.6139, "lon": 77.209},
9
- "bengaluru": {"name": "Bengaluru, India", "lat": 12.9716, "lon": 77.5946},
10
- "bangalore": {"name": "Bengaluru, India", "lat": 12.9716, "lon": 77.5946},
11
- "hyderabad": {"name": "Hyderabad, India", "lat": 17.385, "lon": 78.4867},
12
- "kolkata": {"name": "Kolkata, India", "lat": 22.5726, "lon": 88.3639},
13
- "pune": {"name": "Pune, India", "lat": 18.5204, "lon": 73.8567},
14
- "new york": {"name": "New York, USA", "lat": 40.7128, "lon": -74.006},
15
- "nyc": {"name": "New York, USA", "lat": 40.7128, "lon": -74.006},
16
- "london": {"name": "London, UK", "lat": 51.5072, "lon": -0.1276},
17
- "tokyo": {"name": "Tokyo, Japan", "lat": 35.6762, "lon": 139.6503},
18
- "singapore": {"name": "Singapore", "lat": 1.3521, "lon": 103.8198},
19
- "dubai": {"name": "Dubai, UAE", "lat": 25.2048, "lon": 55.2708},
20
- "berlin": {"name": "Berlin, Germany", "lat": 52.52, "lon": 13.405},
21
- "paris": {"name": "Paris, France", "lat": 48.8566, "lon": 2.3522},
22
- "sydney": {"name": "Sydney, Australia", "lat": -33.8688, "lon": 151.2093},
23
- }
24
-
25
-
26
- def match_location(tokens) -> str | None:
27
- """Return CITIES key present in the token set; None if no known city."""
28
- for key in CITIES:
29
- if key in tokens:
30
- return key
31
- return None
32
-
33
-
34
- def display_names() -> list:
35
- seen, out = set(), []
36
- for info in CITIES.values():
37
- if info["name"] not in seen:
38
- seen.add(info["name"])
39
- out.append(info["name"].split(",")[0])
40
- return out