Initial import: XAUUSD news sentiment models, evals, and data tooling
TF-IDF baseline (news_sentiment.py, models/xauusd_sentiment.joblib) and the two-head DeBERTa-v3-small transformer (transformer/, news_transformer.py) with its Google News crawler and DeepSeek labeller (data_collect/), the golden-set and archive evals (eval/), and the 2026-09-22 and 2026-09-27 results and progress notes. Transformer weights (model.pt, model.onnx) and the crawl database are not tracked; rebuild with transformer/train.py and transformer/export_onnx.py. The DeepSeek label cache in data/ is tracked.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a trained two-head model to int8 ONNX for CPU scoring.
|
||||
|
||||
Reads a train.py output directory (model.pt, tokenizer, meta.json),
|
||||
exports ONNX (inputs input_ids / attention_mask, outputs
|
||||
relevance_logit / direction_logit), quantises the embedding table to
|
||||
int8 (weight-only; compute stays fp32), and writes the runtime bundle
|
||||
news_transformer.py loads:
|
||||
|
||||
models/xau_transformer/model.onnx
|
||||
models/xau_transformer/tokenizer.json
|
||||
models/xau_transformer/meta.json
|
||||
|
||||
Why only the embeddings: on DeBERTa-v3 any dynamic int8 quantisation
|
||||
of the transformer MatMuls (all, per-channel, reduce-range, FFN only)
|
||||
moved probabilities by up to 0.55-0.72 and flipped 13-16% of decisions
|
||||
(activation outliers). The 128k x 768 embedding table is 70% of the
|
||||
weights; quantising it alone halves the file (566 -> 271 MB) with mean
|
||||
|diff| 0.004 and ~1% flips, all near 0.5. Latency is set by the fp32
|
||||
encoder: ~18 ms per headline on 1 thread, ~9 ms on 4.
|
||||
|
||||
Then checks, on up to 500 headlines, that the runtime tokenizer produces
|
||||
the same ids as the training tokenizer, that probabilities stay within
|
||||
--tolerance of the PyTorch fp32 ones and flip at most --max-flip-rate of
|
||||
decisions, and times single-headline CPU latency.
|
||||
|
||||
Usage:
|
||||
.venv-train/bin/python transformer/export_onnx.py # torch_final if present
|
||||
.venv-train/bin/python transformer/export_onnx.py --src models/xau_transformer/torch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
ML_NEWS_DIR = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ML_NEWS_DIR))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import news_transformer # noqa: E402
|
||||
from train import TwoHeadModel, load_rows, split # noqa: E402
|
||||
|
||||
BUNDLE_DIR = ML_NEWS_DIR / "models" / "xau_transformer"
|
||||
|
||||
|
||||
class Exportable(torch.nn.Module):
|
||||
def __init__(self, model: TwoHeadModel):
|
||||
super().__init__()
|
||||
self.model = model
|
||||
|
||||
def forward(self, input_ids, attention_mask):
|
||||
return self.model(input_ids, attention_mask)
|
||||
|
||||
|
||||
def export(model: TwoHeadModel, tokenizer, path: Path) -> None:
|
||||
sample = tokenizer(["Gold rises as the dollar weakens", "Fed hikes"], padding=True,
|
||||
return_tensors="pt")
|
||||
args = (sample["input_ids"], sample["attention_mask"])
|
||||
wrapper = Exportable(model).eval()
|
||||
# TorchScript exporter: the dynamo exporter's value_info shapes fail
|
||||
# onnxruntime's shape inference during quantisation (768 vs 1).
|
||||
torch.onnx.export(
|
||||
wrapper, args, str(path),
|
||||
input_names=["input_ids", "attention_mask"],
|
||||
output_names=["relevance_logit", "direction_logit"],
|
||||
dynamic_axes={"input_ids": {0: "batch", 1: "sequence"},
|
||||
"attention_mask": {0: "batch", 1: "sequence"},
|
||||
"relevance_logit": {0: "batch"}, "direction_logit": {0: "batch"}},
|
||||
opset_version=17, dynamo=False)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
ap.add_argument("--src", type=Path, default=None,
|
||||
help="train.py output dir (default: torch_final, else torch)")
|
||||
ap.add_argument("--out", type=Path, default=BUNDLE_DIR)
|
||||
ap.add_argument("--tolerance", type=float, default=0.05,
|
||||
help="max |probability difference| vs PyTorch fp32")
|
||||
ap.add_argument("--max-flip-rate", type=float, default=0.02,
|
||||
help="max share of relevance/direction decisions that may flip")
|
||||
ap.add_argument("--check-rows", type=int, default=500)
|
||||
args = ap.parse_args()
|
||||
|
||||
src = args.src or next(p for p in (BUNDLE_DIR / "torch_final", BUNDLE_DIR / "torch")
|
||||
if (p / "model.pt").is_file())
|
||||
meta = json.loads((src / "meta.json").read_text())
|
||||
from transformers import AutoTokenizer
|
||||
tokenizer = AutoTokenizer.from_pretrained(src)
|
||||
model = TwoHeadModel(meta["base_model"])
|
||||
model.load_state_dict(torch.load(src / "model.pt", map_location="cpu"))
|
||||
model.eval()
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
fp32 = args.out / "model.fp32.onnx"
|
||||
int8 = args.out / "model.onnx"
|
||||
print(f"exporting {src} -> {fp32}")
|
||||
export(model, tokenizer, fp32)
|
||||
from onnxruntime.quantization import QuantType, quantize_dynamic
|
||||
quantize_dynamic(str(fp32), str(int8), weight_type=QuantType.QInt8,
|
||||
op_types_to_quantize=["Gather"])
|
||||
print(f"embeddings int8 -> {int8} ({int8.stat().st_size / 1e6:.0f} MB, "
|
||||
f"fp32 {fp32.stat().st_size / 1e6:.0f} MB)")
|
||||
|
||||
shutil.copy(src / "tokenizer.json", args.out / "tokenizer.json")
|
||||
runtime_meta = {
|
||||
**meta,
|
||||
"onnx_file": int8.name,
|
||||
"quantization": "int8 weight-only embedding table; fp32 compute",
|
||||
"pad_token": tokenizer.pad_token,
|
||||
"pad_token_id": tokenizer.pad_token_id,
|
||||
"exported_from": os.path.relpath(src.resolve(), ML_NEWS_DIR),
|
||||
}
|
||||
(args.out / "meta.json").write_text(json.dumps(runtime_meta, indent=2, default=str) + "\n")
|
||||
|
||||
# ── parity: tokenizer ids and probabilities on held-out headlines ──
|
||||
_, _, val = split(load_rows(), meta["hyperparameters"]["val_from"],
|
||||
meta["hyperparameters"]["seed"])
|
||||
texts = [r["text"] for r in val[: args.check_rows]]
|
||||
bundle = news_transformer.load_model(args.out)
|
||||
tok_mismatch = sum(
|
||||
bundle["tokenizer"].encode(t).ids
|
||||
!= tokenizer(t, truncation=True, max_length=news_transformer.MAX_LENGTH)["input_ids"]
|
||||
for t in texts)
|
||||
with torch.no_grad():
|
||||
ref_rel, ref_dir = [], []
|
||||
for start in range(0, len(texts), 64):
|
||||
enc = tokenizer(texts[start:start + 64], padding=True, truncation=True,
|
||||
max_length=news_transformer.MAX_LENGTH, return_tensors="pt")
|
||||
r, d = model(enc["input_ids"], enc["attention_mask"])
|
||||
ref_rel.append(torch.sigmoid(r))
|
||||
ref_dir.append(torch.sigmoid(d))
|
||||
ref_rel = torch.cat(ref_rel).numpy()
|
||||
ref_dir = torch.cat(ref_dir).numpy()
|
||||
got = []
|
||||
for start in range(0, len(texts), 64):
|
||||
got += news_transformer.score_batch(bundle, texts[start:start + 64])
|
||||
diff_rel = np.abs(ref_rel - np.array([g["p_relevant"] for g in got]))
|
||||
diff_dir = np.abs(ref_dir - np.array([g["p_bullish"] for g in got]))
|
||||
thr = runtime_meta["relevance_threshold"]
|
||||
flips = int(sum((a >= thr) != g["relevant"] for a, g in zip(ref_rel, got))
|
||||
+ sum((b >= 0.5) != (g["direction"] == "bullish") for b, g in zip(ref_dir, got)))
|
||||
print(f"tokenizer mismatches: {tok_mismatch}/{len(texts)}")
|
||||
print(f"int8 vs fp32 over {len(texts)}: p_relevant |diff| mean {diff_rel.mean():.4f} max "
|
||||
f"{diff_rel.max():.4f}; p_bullish mean {diff_dir.mean():.4f} max {diff_dir.max():.4f}; "
|
||||
f"decision flips {flips}")
|
||||
|
||||
# ── latency: one headline at a time, as the live feed would call it ──
|
||||
latency = {}
|
||||
for threads in (1, os.cpu_count()):
|
||||
b = news_transformer.load_model(args.out, threads=threads)
|
||||
for t in texts[:20]:
|
||||
news_transformer.score_news(b, t)
|
||||
t0 = time.perf_counter()
|
||||
for t in texts[:200]:
|
||||
news_transformer.score_news(b, t)
|
||||
latency[threads] = (time.perf_counter() - t0) * 1000 / min(200, len(texts))
|
||||
print(f"CPU latency, {threads} thread(s): {latency[threads]:.1f} ms per headline")
|
||||
|
||||
runtime_meta["export_checks"] = {
|
||||
"rows": len(texts), "tokenizer_mismatches": tok_mismatch,
|
||||
"p_relevant_max_abs_diff": float(diff_rel.max()),
|
||||
"p_bullish_max_abs_diff": float(diff_dir.max()),
|
||||
"decision_flips": flips,
|
||||
"latency_ms_per_headline": {str(k): round(v, 2) for k, v in latency.items()},
|
||||
}
|
||||
(args.out / "meta.json").write_text(json.dumps(runtime_meta, indent=2, default=str) + "\n")
|
||||
fp32.unlink()
|
||||
flip_rate = flips / (2 * len(texts))
|
||||
ok = (tok_mismatch == 0 and max(diff_rel.max(), diff_dir.max()) <= args.tolerance
|
||||
and flip_rate <= args.max_flip_rate)
|
||||
print("PARITY OK" if ok else
|
||||
f"PARITY FAILED (tolerance {args.tolerance}, flip rate {flip_rate:.1%} "
|
||||
f"> {args.max_flip_rate:.0%}?)")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fine-tune a two-head transformer: gold-relevant? and bullish/bearish.
|
||||
|
||||
Data (all labels are DeepSeek's live-prompt XAU score):
|
||||
* data/history.db crawled headlines 2019-2026 + labels table
|
||||
* newsdata.db the live archive (xau_score -> JSON XAU -> backfill)
|
||||
Headlines are deduplicated on case-folded text (median xau). Anything in
|
||||
the golden set is removed from every split.
|
||||
|
||||
Targets per headline, from xau:
|
||||
relevance 1 if |xau| >= 0.25, 0 if |xau| <= 0.10, ignored in between
|
||||
direction sign(xau), only where relevance = 1
|
||||
|
||||
Splits:
|
||||
val newsdata rows published on or after --val-from (2026-09-18):
|
||||
the unseen days of the 2026-09-22 report, never used to choose
|
||||
anything
|
||||
dev a seeded random 5% of the rest: picks the best epoch and the
|
||||
relevance threshold
|
||||
train everything else
|
||||
``--final`` trains on train + dev + val for the epoch count chosen by
|
||||
the last non-final run, for deployment.
|
||||
|
||||
Outputs models/xau_transformer/torch/ (state dict, tokenizer, meta.json);
|
||||
``--final`` writes models/xau_transformer/torch_final/.
|
||||
Export for CPU with transformer/export_onnx.py.
|
||||
|
||||
Usage:
|
||||
.venv-train/bin/python transformer/train.py
|
||||
.venv-train/bin/python transformer/train.py --final
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import nn
|
||||
from transformers import AutoModel, AutoTokenizer, get_linear_schedule_with_warmup
|
||||
|
||||
ML_NEWS_DIR = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ML_NEWS_DIR))
|
||||
sys.path.insert(0, str(ML_NEWS_DIR / "eval"))
|
||||
|
||||
from news_transformer import (MAX_LENGTH, RELEVANT_MAX_IRRELEVANT, # noqa: E402
|
||||
RELEVANT_MIN, clean_headline)
|
||||
|
||||
HISTORY_DB = ML_NEWS_DIR / "data" / "history.db"
|
||||
NEWSDATA_DB = ML_NEWS_DIR / "newsdata.db"
|
||||
BACKFILL = ML_NEWS_DIR / "eval" / "results" / "backfill_labels.jsonl"
|
||||
OUT_DIR = ML_NEWS_DIR / "models" / "xau_transformer" / "torch"
|
||||
IGNORE = -1.0
|
||||
|
||||
|
||||
class TwoHeadModel(nn.Module):
|
||||
"""Mean-pooled encoder with a relevance logit and a direction logit."""
|
||||
|
||||
def __init__(self, base: str, dropout: float = 0.1):
|
||||
super().__init__()
|
||||
# transformers 5 loads the checkpoint's stored dtype (fp16 for
|
||||
# deberta-v3-small) by default; fp16 master weights overflow under
|
||||
# AdamW. Keep fp32 weights; autocast does bf16 compute.
|
||||
self.encoder = AutoModel.from_pretrained(base, dtype=torch.float32)
|
||||
hidden = self.encoder.config.hidden_size
|
||||
self.dropout = nn.Dropout(dropout)
|
||||
self.relevance = nn.Linear(hidden, 1)
|
||||
self.direction = nn.Linear(hidden, 1)
|
||||
|
||||
def forward(self, input_ids, attention_mask):
|
||||
states = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
|
||||
mask = attention_mask.unsqueeze(-1).to(states.dtype)
|
||||
pooled = (states * mask).sum(1) / mask.sum(1).clamp(min=1.0)
|
||||
pooled = self.dropout(pooled)
|
||||
return self.relevance(pooled).squeeze(-1), self.direction(pooled).squeeze(-1)
|
||||
|
||||
|
||||
# ── data ─────────────────────────────────────────────────────────────
|
||||
|
||||
def golden_texts() -> set[str]:
|
||||
from eval_common import load_golden
|
||||
return {clean_headline(g["headline"]).casefold() for g in load_golden()}
|
||||
|
||||
|
||||
def load_rows() -> list[dict]:
|
||||
raw: list[dict] = []
|
||||
if HISTORY_DB.is_file():
|
||||
uri = f"{HISTORY_DB.resolve().as_uri()}?mode=ro"
|
||||
with sqlite3.connect(uri, uri=True) as conn:
|
||||
has_labels = conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table'"
|
||||
" AND name = 'labels'").fetchone()
|
||||
for headline, published_at, xau in [] if not has_labels else conn.execute(
|
||||
"SELECT h.headline, h.published_at, l.xau FROM headlines h"
|
||||
" JOIN labels l ON l.hash = h.hash"):
|
||||
raw.append({"headline": headline, "published_at": published_at or "",
|
||||
"xau": float(xau), "source": "history"})
|
||||
from eval_common import load_archive_rows
|
||||
for r in load_archive_rows(NEWSDATA_DB, BACKFILL if BACKFILL.is_file() else None):
|
||||
if r["score_source"] == "llm" and r["xau"] is not None and r["headline"]:
|
||||
raw.append({"headline": r["headline"],
|
||||
"published_at": r["published_at"] or r["first_seen"] or "",
|
||||
"xau": float(r["xau"]), "source": "newsdata"})
|
||||
|
||||
golden = golden_texts()
|
||||
grouped: dict[str, dict] = {}
|
||||
dropped_golden = 0
|
||||
for r in raw:
|
||||
try:
|
||||
text = clean_headline(r["headline"])
|
||||
except ValueError:
|
||||
continue
|
||||
key = text.casefold()
|
||||
if key in golden:
|
||||
dropped_golden += 1
|
||||
continue
|
||||
g = grouped.setdefault(key, {"text": text, "xaus": [], "dates": [], "sources": set()})
|
||||
g["xaus"].append(r["xau"])
|
||||
g["dates"].append(r["published_at"])
|
||||
g["sources"].add(r["source"])
|
||||
rows = []
|
||||
for g in grouped.values():
|
||||
xau = float(median(g["xaus"]))
|
||||
a = abs(xau)
|
||||
rel = 1.0 if a >= RELEVANT_MIN else 0.0 if a <= RELEVANT_MAX_IRRELEVANT else IGNORE
|
||||
rows.append({
|
||||
"text": g["text"], "xau": xau, "published_at": max(g["dates"]),
|
||||
"source": "newsdata" if "newsdata" in g["sources"] else "history",
|
||||
"rel": rel, "dir": (1.0 if xau > 0 else 0.0) if rel == 1.0 else IGNORE,
|
||||
})
|
||||
print(f"{len(raw)} labelled rows -> {len(rows)} unique headlines "
|
||||
f"({dropped_golden} golden-set duplicates dropped)")
|
||||
return rows
|
||||
|
||||
|
||||
def split(rows: list[dict], val_from: str, seed: int):
|
||||
val = [r for r in rows if r["source"] == "newsdata" and r["published_at"] >= val_from]
|
||||
rest = [r for r in rows if not (r["source"] == "newsdata" and r["published_at"] >= val_from)]
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(rest)
|
||||
n_dev = max(200, len(rest) // 20)
|
||||
return rest[n_dev:], rest[:n_dev], val
|
||||
|
||||
|
||||
def describe(name: str, rows: list[dict]) -> dict:
|
||||
rel = Counter(r["rel"] for r in rows)
|
||||
d = Counter(r["dir"] for r in rows if r["dir"] != IGNORE)
|
||||
years = Counter(r["published_at"][:4] for r in rows)
|
||||
info = {"n": len(rows), "relevant": rel[1.0], "irrelevant": rel[0.0], "grey": rel[IGNORE],
|
||||
"bullish": d[1.0], "bearish": d[0.0],
|
||||
"sources": dict(Counter(r["source"] for r in rows)),
|
||||
"years": dict(sorted(years.items()))}
|
||||
print(f" {name:<6} n={info['n']:>6} relevant {info['relevant']:>6} irrelevant "
|
||||
f"{info['irrelevant']:>6} grey {info['grey']:>5} bull {info['bullish']:>5} "
|
||||
f"bear {info['bearish']:>5} {info['sources']}")
|
||||
return info
|
||||
|
||||
|
||||
# ── training / evaluation ────────────────────────────────────────────
|
||||
|
||||
def batches(rows, tokenizer, batch_size, shuffle, rng=None):
|
||||
order = list(range(len(rows)))
|
||||
if shuffle:
|
||||
rng.shuffle(order)
|
||||
for start in range(0, len(order), batch_size):
|
||||
chunk = [rows[i] for i in order[start:start + batch_size]]
|
||||
enc = tokenizer([r["text"] for r in chunk], padding=True, truncation=True,
|
||||
max_length=MAX_LENGTH, return_tensors="pt")
|
||||
rel = torch.tensor([r["rel"] for r in chunk])
|
||||
dirs = torch.tensor([r["dir"] for r in chunk])
|
||||
yield enc["input_ids"], enc["attention_mask"], rel, dirs
|
||||
|
||||
|
||||
def masked_bce(logits, targets, pos_weight):
|
||||
mask = targets != IGNORE
|
||||
if not mask.any():
|
||||
return logits.sum() * 0.0
|
||||
return nn.functional.binary_cross_entropy_with_logits(
|
||||
logits[mask].float(), targets[mask].float(), pos_weight=pos_weight)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def predict(model, rows, tokenizer, device, batch_size=256):
|
||||
model.eval()
|
||||
p_rel, p_bull = [], []
|
||||
for ids, mask, _, _ in batches(rows, tokenizer, batch_size, False):
|
||||
with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device.type == "cuda"):
|
||||
r, d = model(ids.to(device), mask.to(device))
|
||||
p_rel.append(torch.sigmoid(r.float()).cpu())
|
||||
p_bull.append(torch.sigmoid(d.float()).cpu())
|
||||
return torch.cat(p_rel).numpy(), torch.cat(p_bull).numpy()
|
||||
|
||||
|
||||
def deepseek_class(xau: float, band: float = 0.05) -> int:
|
||||
return 1 if xau > band else -1 if xau < -band else 0
|
||||
|
||||
|
||||
def metrics(rows, p_rel, p_bull, threshold: float) -> dict:
|
||||
rel_t = np.array([r["rel"] for r in rows])
|
||||
dir_t = np.array([r["dir"] for r in rows])
|
||||
m = rel_t != IGNORE
|
||||
pred_rel = p_rel >= threshold
|
||||
tp = int(((pred_rel == 1) & (rel_t == 1)).sum())
|
||||
fp = int(((pred_rel == 1) & (rel_t == 0)).sum())
|
||||
fn = int(((pred_rel == 0) & (rel_t == 1)).sum())
|
||||
prec = tp / (tp + fp) if tp + fp else 0.0
|
||||
rec = tp / (tp + fn) if tp + fn else 0.0
|
||||
dm = dir_t != IGNORE
|
||||
dir_acc = float(((p_bull[dm] >= 0.5) == (dir_t[dm] == 1)).mean()) if dm.any() else float("nan")
|
||||
pred_cls = np.where(pred_rel, np.where(p_bull >= 0.5, 1, -1), 0)
|
||||
ds = np.array([deepseek_class(r["xau"]) for r in rows])
|
||||
cls3 = float((pred_cls == ds).mean())
|
||||
from sklearn.metrics import f1_score
|
||||
return {
|
||||
"relevance_threshold": threshold,
|
||||
"relevance_precision": prec, "relevance_recall": rec,
|
||||
"relevance_f1": 2 * prec * rec / (prec + rec) if prec + rec else 0.0,
|
||||
"relevance_accuracy": float((pred_rel[m] == (rel_t[m] == 1)).mean()),
|
||||
"direction_accuracy_on_relevant": dir_acc,
|
||||
"three_class_vs_deepseek_0.05": cls3,
|
||||
"three_class_macro_f1": float(f1_score(ds, pred_cls, average="macro")),
|
||||
"n": len(rows),
|
||||
}
|
||||
|
||||
|
||||
def best_threshold(rows, p_rel, p_bull) -> float:
|
||||
"""Relevance cut that maximises 3-class agreement on dev."""
|
||||
grid = [round(x, 2) for x in np.arange(0.2, 0.81, 0.05)]
|
||||
return max(grid, key=lambda t: metrics(rows, p_rel, p_bull, t)["three_class_vs_deepseek_0.05"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
ap.add_argument("--base", default="microsoft/deberta-v3-small")
|
||||
ap.add_argument("--epochs", type=int, default=4)
|
||||
ap.add_argument("--batch-size", type=int, default=64)
|
||||
ap.add_argument("--lr", type=float, default=5e-5)
|
||||
ap.add_argument("--warmup", type=float, default=0.06)
|
||||
ap.add_argument("--weight-decay", type=float, default=0.01)
|
||||
ap.add_argument("--val-from", default="2026-09-18")
|
||||
ap.add_argument("--seed", type=int, default=42)
|
||||
ap.add_argument("--out", type=Path, default=OUT_DIR)
|
||||
ap.add_argument("--final", action="store_true",
|
||||
help="train on every split for the epoch count chosen by the last run")
|
||||
args = ap.parse_args()
|
||||
|
||||
random.seed(args.seed)
|
||||
np.random.seed(args.seed)
|
||||
torch.manual_seed(args.seed)
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
rows = load_rows()
|
||||
train, dev, val = split(rows, args.val_from, args.seed)
|
||||
print("splits:")
|
||||
data_info = {"train": describe("train", train), "dev": describe("dev", dev),
|
||||
"val": describe("val", val)}
|
||||
previous = {}
|
||||
if args.final:
|
||||
prev_path = args.out / "meta.json"
|
||||
if not prev_path.is_file():
|
||||
raise SystemExit("--final needs a previous non-final run (its meta.json)")
|
||||
previous = json.loads(prev_path.read_text())
|
||||
args.epochs = int(previous["best_epoch"])
|
||||
train = train + dev + val
|
||||
args.out = args.out.with_name("torch_final")
|
||||
print(f"final: training on all {len(train)} rows for {args.epochs} epochs -> {args.out}")
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(args.base)
|
||||
model = TwoHeadModel(args.base).to(device)
|
||||
rel_counts = Counter(r["rel"] for r in train)
|
||||
dir_counts = Counter(r["dir"] for r in train)
|
||||
rel_pw = torch.tensor(rel_counts[0.0] / max(1, rel_counts[1.0]), device=device)
|
||||
dir_pw = torch.tensor(dir_counts[0.0] / max(1, dir_counts[1.0]), device=device)
|
||||
|
||||
no_decay = ("bias", "LayerNorm.weight", "layernorm", "norm")
|
||||
params = [
|
||||
{"params": [p for n, p in model.named_parameters() if not any(k in n for k in no_decay)],
|
||||
"weight_decay": args.weight_decay},
|
||||
{"params": [p for n, p in model.named_parameters() if any(k in n for k in no_decay)],
|
||||
"weight_decay": 0.0},
|
||||
]
|
||||
optimizer = torch.optim.AdamW(params, lr=args.lr)
|
||||
steps = math.ceil(len(train) / args.batch_size) * args.epochs
|
||||
scheduler = get_linear_schedule_with_warmup(optimizer, int(steps * args.warmup), steps)
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
rng = random.Random(args.seed)
|
||||
history = []
|
||||
best = {"score": -1.0}
|
||||
started = time.time()
|
||||
for epoch in range(1, args.epochs + 1):
|
||||
model.train()
|
||||
total = n = 0
|
||||
for ids, mask, rel, dirs in batches(train, tokenizer, args.batch_size, True, rng):
|
||||
ids, mask, rel, dirs = ids.to(device), mask.to(device), rel.to(device), dirs.to(device)
|
||||
with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device.type == "cuda"):
|
||||
r_logit, d_logit = model(ids, mask)
|
||||
loss = masked_bce(r_logit, rel, rel_pw) + masked_bce(d_logit, dirs, dir_pw)
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
||||
optimizer.step()
|
||||
scheduler.step()
|
||||
total += loss.item() * len(ids)
|
||||
n += len(ids)
|
||||
entry = {"epoch": epoch, "train_loss": total / n, "seconds": round(time.time() - started)}
|
||||
if not args.final:
|
||||
pr, pb = predict(model, dev, tokenizer, device)
|
||||
thr = best_threshold(dev, pr, pb)
|
||||
entry["dev"] = metrics(dev, pr, pb, thr)
|
||||
score = entry["dev"]["three_class_vs_deepseek_0.05"]
|
||||
if score > best["score"]:
|
||||
best = {"score": score, "epoch": epoch, "threshold": thr}
|
||||
torch.save(model.state_dict(), args.out / "model.pt")
|
||||
d = entry["dev"]
|
||||
print(f"epoch {epoch}: loss {entry['train_loss']:.4f} dev 3-class "
|
||||
f"{d['three_class_vs_deepseek_0.05']:.3f} rel F1 {d['relevance_f1']:.3f} "
|
||||
f"dir acc {d['direction_accuracy_on_relevant']:.3f} thr {thr} "
|
||||
f"({entry['seconds']}s)", flush=True)
|
||||
else:
|
||||
print(f"epoch {epoch}: loss {entry['train_loss']:.4f} ({entry['seconds']}s)", flush=True)
|
||||
history.append(entry)
|
||||
|
||||
if args.final:
|
||||
torch.save(model.state_dict(), args.out / "model.pt")
|
||||
best = {"epoch": args.epochs, "threshold": previous["relevance_threshold"]}
|
||||
evaluation = previous.get("evaluation", {})
|
||||
else:
|
||||
model.load_state_dict(torch.load(args.out / "model.pt", map_location=device))
|
||||
evaluation = {}
|
||||
for name, part in (("dev", dev), ("val", val)):
|
||||
pr, pb = predict(model, part, tokenizer, device)
|
||||
evaluation[name] = metrics(part, pr, pb, best["threshold"])
|
||||
v = evaluation["val"]
|
||||
print(f"best epoch {best['epoch']}, relevance threshold {best['threshold']}")
|
||||
print(f"VAL (unseen {args.val_from}+, n={v['n']}): 3-class {v['three_class_vs_deepseek_0.05']:.3f}"
|
||||
f" macro-F1 {v['three_class_macro_f1']:.3f} rel P/R/F1 {v['relevance_precision']:.3f}/"
|
||||
f"{v['relevance_recall']:.3f}/{v['relevance_f1']:.3f} dir acc "
|
||||
f"{v['direction_accuracy_on_relevant']:.3f}")
|
||||
|
||||
tokenizer.save_pretrained(args.out)
|
||||
meta = {
|
||||
"base_model": args.base,
|
||||
"trained_at": datetime.now(timezone.utc).isoformat(),
|
||||
"final": args.final,
|
||||
"max_length": MAX_LENGTH,
|
||||
"relevant_min_abs_xau": RELEVANT_MIN,
|
||||
"irrelevant_max_abs_xau": RELEVANT_MAX_IRRELEVANT,
|
||||
"relevance_threshold": best["threshold"],
|
||||
"best_epoch": best["epoch"],
|
||||
"hyperparameters": {k: v for k, v in vars(args).items() if k not in ("out",)},
|
||||
"data": data_info,
|
||||
"history": history,
|
||||
"evaluation": evaluation,
|
||||
"evaluation_note": ("final model trained on val too; evaluation is from the "
|
||||
"non-final run" if args.final else "val never used for selection"),
|
||||
"labels": "DeepSeek deepseek-v4-flash live prompt XAU score",
|
||||
}
|
||||
(args.out / "meta.json").write_text(json.dumps(meta, indent=2, default=str) + "\n")
|
||||
print(f"wrote {args.out}/model.pt and meta.json")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user