Keep only model checkpoints + configs
Browse files- ask.py +0 -69
- data.py +0 -472
- debug_logger.py +0 -34
- model.py +0 -63
- multi.py +0 -62
- predict.py +0 -212
- prediction_engine.py +0 -97
- requirements.txt +0 -8
- response.py +0 -143
- response_formatter.py +0 -182
- run_all.ps1 +0 -21
- train.py +0 -163
- train_unified.py +0 -187
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|