datadb's picture
upload track a submission package
80dcfe9 verified
Raw
History Blame Contribute Delete
18 kB
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Training and experiment entry point for Track A.
This script owns model fitting and evaluation:
1. Load the labelled Phase 1 training scenarios.
2. Cross-validate the LightGBM template classifier and option selector.
3. Save fold metrics, overall metrics, OOF predictions, train/val split
manifests, full train/val split JSON files, and training logs under a
timestamped experiment directory.
4. Train final models on all labelled data.
5. Save the model bundle in the experiment folder and copy it to --out.
Shared feature extraction, prediction-time selection, and bundle IO live in
src/model_core.py. Runtime inference orchestration lives in main.py.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
import time
from collections import Counter
from contextlib import redirect_stderr, redirect_stdout
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Tuple
import numpy as np
import pandas as pd
from lightgbm import LGBMClassifier
from sklearn.feature_extraction import DictVectorizer
from sklearn.model_selection import KFold, StratifiedKFold
from sklearn.pipeline import Pipeline
from src.model_core import (
MODEL_BUNDLE_VERSION,
answer_to_template,
build_prediction_context,
extract_scenario_features,
iou_score,
get_options,
load_json,
option_feature_dict,
parse_answer,
predict_labels,
predict_labels_batch,
save_model_bundle,
template_to_actions,
)
class Tee:
def __init__(self, *streams):
self.streams = streams
def write(self, data: str) -> None:
for stream in self.streams:
stream.write(data)
stream.flush()
def flush(self) -> None:
for stream in self.streams:
stream.flush()
def json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {str(k): json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [json_safe(v) for v in value]
if isinstance(value, np.generic):
return value.item()
return value
def get_last_version(results_dir: Path) -> int:
versions = []
if not results_dir.exists():
return 0
for child in results_dir.iterdir():
if not child.is_dir():
continue
prefix = child.name.split("_", 1)[0]
if prefix.isdigit():
versions.append(int(prefix))
return sorted(versions)[-1] if versions else 0
def make_experiment_dir(root: Path, name: str = "") -> Path:
root.mkdir(parents=True, exist_ok=True)
clean_name = name.strip()[:10]
last_version = get_last_version(root)
base = (
f"{last_version + 1:02d}_{clean_name}"
if clean_name
else f"{last_version + 1:02d}"
)
exp_dir = root / base
suffix = 2
while exp_dir.exists():
exp_dir = root / f"{base}_{suffix}"
suffix += 1
exp_dir.mkdir(parents=True)
return exp_dir
def scenario_id(s: Dict[str, Any], index: int) -> str:
return str(s.get("scenario_id") or s.get("ID") or f"train_{index}")
def write_json(path: Path, obj: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(json_safe(obj), ensure_ascii=False, indent=2), encoding="utf-8"
)
def fit_template_model(
train: List[Dict[str, Any]],
seed: int = 42,
n_jobs: int = 1,
boost_rounds: int = 2000,
) -> Pipeline:
started = time.time()
print(f"Building template features for {len(train)} scenarios...")
X = [extract_scenario_features(s) for s in train]
y = [answer_to_template(s) for s in train]
print(
f"Template features built in {time.time() - started:.1f}s; fitting LightGBM for {boost_rounds} rounds..."
)
clf = LGBMClassifier(
objective="multiclass",
n_estimators=boost_rounds,
learning_rate=0.05,
num_leaves=20,
path_smooth=10,
feature_fraction=0.8,
bagging_fraction=0.8,
bagging_freq=5,
min_child_samples=20,
class_weight="balanced",
random_state=seed,
n_jobs=n_jobs,
verbosity=-1,
force_col_wise=True,
)
model = Pipeline([("vec", DictVectorizer(sparse=False)), ("clf", clf)])
model.fit(X, y)
print(f"Template model fitted in {time.time() - started:.1f}s total.")
return model
def build_selector_rows(
train: List[Dict[str, Any]],
) -> Tuple[List[Dict[str, float]], List[int], List[Dict[str, Any]]]:
X, y, meta = [], [], []
for s in train:
context = build_prediction_context(s)
opts = context["options"]
ans_ids = set(parse_answer(s.get("answer", "")))
template = answer_to_template(s)
for cid, label in opts.items():
action = context["option_actions"].get(cid, "other")
feats = option_feature_dict(s, cid, label, template, action, context)
X.append(feats)
y.append(1 if cid in ans_ids else 0)
meta.append(
{
"scenario_id": s.get("scenario_id"),
"cid": cid,
"action": action,
"template": template,
"in_template": action in set(template_to_actions(template)),
}
)
return X, y, meta
def fit_selector_model(
train: List[Dict[str, Any]],
seed: int = 42,
n_jobs: int = 1,
boost_rounds: int = 2000,
) -> Pipeline:
started = time.time()
print(f"Building selector rows for {len(train)} scenarios...")
X, y, _ = build_selector_rows(train)
print(
f"Selector rows built: {len(X)} rows in {time.time() - started:.1f}s; fitting LightGBM for {boost_rounds} rounds..."
)
clf = LGBMClassifier(
objective="binary",
n_estimators=boost_rounds,
learning_rate=0.05,
num_leaves=20,
path_smooth=10,
feature_fraction=0.8,
bagging_fraction=0.8,
bagging_freq=5,
min_child_samples=20,
class_weight="balanced",
random_state=seed,
n_jobs=n_jobs,
verbosity=-1,
force_col_wise=True,
)
model = Pipeline([("vec", DictVectorizer(sparse=False)), ("clf", clf)])
model.fit(X, y)
print(f"Selector model fitted in {time.time() - started:.1f}s total.")
return model
def save_split_artifacts(
exp_dir: Path,
fold: int,
train: List[Dict[str, Any]],
tr_idx: np.ndarray,
va_idx: np.ndarray,
) -> Tuple[Path, Path]:
split_dir = exp_dir / "splits" / f"fold_{fold}"
split_dir.mkdir(parents=True, exist_ok=True)
def rows(indices: np.ndarray, split: str) -> List[Dict[str, Any]]:
out = []
for idx in indices:
s = train[int(idx)]
out.append(
{
"fold": fold,
"split": split,
"row_index": int(idx),
"scenario_id": scenario_id(s, int(idx)),
"answer": s.get("answer", ""),
"template": answer_to_template(s),
}
)
return out
train_rows = rows(tr_idx, "train")
val_rows = rows(va_idx, "val")
pd.DataFrame(train_rows).to_csv(split_dir / "train_manifest.csv", index=False)
pd.DataFrame(val_rows).to_csv(split_dir / "val_manifest.csv", index=False)
train_json = split_dir / "train.json"
val_json = split_dir / "val.json"
write_json(train_json, [train[int(i)] for i in tr_idx])
write_json(val_json, [train[int(i)] for i in va_idx])
return train_json, val_json
def cross_validate_and_save(
train: List[Dict[str, Any]],
exp_dir: Path,
folds: int,
seed: int,
n_jobs: int,
template_boost_rounds: int,
selector_boost_rounds: int,
) -> Dict[str, Any]:
for child in ("metrics", "predictions", "splits"):
(exp_dir / child).mkdir(parents=True, exist_ok=True)
templates = [answer_to_template(s) for s in train]
counts = Counter(templates)
split_y = [t if counts[t] >= folds else "__rare__" for t in templates]
split_counts = Counter(split_y)
usable_folds = min(folds, len(train))
min_split_count = min(split_counts.values()) if split_counts else 0
if min_split_count >= 2:
usable_folds = min(usable_folds, min_split_count)
splitter = StratifiedKFold(
n_splits=usable_folds, shuffle=True, random_state=seed
)
splits = splitter.split(np.zeros(len(train)), split_y)
split_strategy = "stratified"
else:
usable_folds = min(usable_folds, max(2, len(train)))
splitter = KFold(n_splits=usable_folds, shuffle=True, random_state=seed)
splits = splitter.split(np.zeros(len(train)))
split_strategy = "kfold"
metrics: List[Dict[str, Any]] = []
oof_rows: List[Dict[str, Any]] = []
split_manifest: List[Dict[str, Any]] = []
print(f"Template classes: {len(counts)}")
print("Top templates:")
for k, v in counts.most_common(20):
print(f" {k}: {v}")
if usable_folds != folds:
print(
f"Adjusted CV folds from {folds} to {usable_folds} because of rare classes."
)
print(f"CV split strategy: {split_strategy}")
for fold, (tr_idx, va_idx) in enumerate(splits, start=1):
fold_start = time.time()
print(f"\nTraining fold {fold}/{folds}...")
train_json, val_json = save_split_artifacts(
exp_dir, fold, train, tr_idx, va_idx
)
split_manifest.append(
{
"fold": fold,
"train_rows": int(len(tr_idx)),
"val_rows": int(len(va_idx)),
"train_json": str(train_json),
"val_json": str(val_json),
}
)
tr = [train[int(i)] for i in tr_idx]
va = [train[int(i)] for i in va_idx]
template_model = fit_template_model(
tr,
seed + fold,
n_jobs=n_jobs,
boost_rounds=template_boost_rounds,
)
selector_model = fit_selector_model(
tr,
seed + fold,
n_jobs=n_jobs,
boost_rounds=selector_boost_rounds,
)
fold_ious: List[float] = []
fold_tpl: List[float] = []
pred_start = time.time()
print(f"Scoring {len(va)} validation scenarios...")
predictions = predict_labels_batch(template_model, selector_model, va)
for local_i, s, (pred, dbg) in zip(va_idx, va, predictions):
truth = parse_answer(s.get("answer", ""))
iou = iou_score(pred, truth)
template_ok = 1.0 if dbg["template"] == answer_to_template(s) else 0.0
fold_ious.append(iou)
fold_tpl.append(template_ok)
oof_rows.append(
{
"fold": fold,
"row_index": int(local_i),
"scenario_id": scenario_id(s, int(local_i)),
"truth": "|".join(truth),
"prediction": "|".join(pred),
"iou": float(iou),
"true_template": answer_to_template(s),
"pred_template": dbg["template"],
"template_prob": float(dbg["template_prob"]),
"template_correct": bool(template_ok),
}
)
print(f"Validation scoring completed in {time.time() - pred_start:.1f}s.")
row = {
"fold": fold,
"train_rows": int(len(tr_idx)),
"val_rows": int(len(va_idx)),
"iou_mean": float(np.mean(fold_ious)),
"iou_std": float(np.std(fold_ious)),
"template_acc": float(np.mean(fold_tpl)),
"duration_seconds": round(time.time() - fold_start, 3),
}
metrics.append(row)
print(
f"Fold {fold}: IoU={row['iou_mean']:.4f} "
f"template_acc={row['template_acc']:.4f} "
f"duration={row['duration_seconds']:.1f}s"
)
pd.DataFrame(metrics).to_csv(
exp_dir / "metrics" / "fold_metrics.csv", index=False
)
pd.DataFrame(oof_rows).to_csv(
exp_dir / "predictions" / "oof_predictions.csv", index=False
)
write_json(exp_dir / "splits" / "split_manifest.json", split_manifest)
summary = {
"requested_folds": folds,
"folds": usable_folds,
"split_strategy": split_strategy,
"seed": seed,
"n_jobs": n_jobs,
"template_boost_rounds": template_boost_rounds,
"selector_boost_rounds": selector_boost_rounds,
"iou_mean": float(np.mean([m["iou_mean"] for m in metrics])),
"iou_std": float(np.std([m["iou_mean"] for m in metrics])),
"template_acc_mean": float(np.mean([m["template_acc"] for m in metrics])),
"template_acc_std": float(np.std([m["template_acc"] for m in metrics])),
"fold_metrics": metrics,
}
write_json(exp_dir / "metrics" / "overall_metrics.json", summary)
pd.DataFrame([summary]).drop(columns=["fold_metrics"]).to_csv(
exp_dir / "metrics" / "overall_metrics.csv", index=False
)
return summary
def run_training(args: argparse.Namespace) -> None:
started = time.time()
exp_dir = make_experiment_dir(args.experiments_root, args.experiment_name)
log_path = exp_dir / "training.log"
config = {
"train_path": args.train_path,
"out": args.out,
"experiments_root": str(args.experiments_root),
"experiment_dir": str(exp_dir),
"experiment_name": args.experiment_name,
"folds": args.folds,
"seed": args.seed,
"n_jobs": args.n_jobs,
"template_boost_rounds": args.template_boost_rounds,
"selector_boost_rounds": args.selector_boost_rounds,
"selector_training_scope": "all_options",
"selector_prediction_mode": "candidate_ranker",
"model_bundle_version": MODEL_BUNDLE_VERSION,
}
write_json(exp_dir / "config.json", config)
(exp_dir / "experiment.txt").write_text(
f"{exp_dir.name}\n{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n",
encoding="utf-8",
)
with log_path.open("w", encoding="utf-8") as log_f, redirect_stdout(
Tee(sys.stdout, log_f)
), redirect_stderr(Tee(sys.stderr, log_f)):
print(f"Experiment directory: {exp_dir}")
print(f"Training log: {log_path}")
print(f"Config: {json.dumps(config, indent=2)}")
train = load_json(args.train_path)
print(f"Loaded train={len(train)} from {args.train_path}")
cv_summary = cross_validate_and_save(
train,
exp_dir,
args.folds,
args.seed,
args.n_jobs,
args.template_boost_rounds,
args.selector_boost_rounds,
)
print("\nTraining full template model...")
template_model = fit_template_model(
train,
args.seed,
n_jobs=args.n_jobs,
boost_rounds=args.template_boost_rounds,
)
print("Training full selector model...")
selector_model = fit_selector_model(
train,
args.seed,
n_jobs=args.n_jobs,
boost_rounds=args.selector_boost_rounds,
)
templates = Counter(answer_to_template(s) for s in train)
metadata = {
"version": MODEL_BUNDLE_VERSION,
"train_path": args.train_path,
"train_rows": len(train),
"seed": args.seed,
"n_jobs": args.n_jobs,
"template_boost_rounds": args.template_boost_rounds,
"selector_boost_rounds": args.selector_boost_rounds,
"selector_training_scope": "all_options",
"selector_prediction_mode": "candidate_ranker",
"template_classes": len(templates),
"top_templates": templates.most_common(20),
"cv": cv_summary,
"experiment_dir": str(exp_dir),
"trained_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"duration_seconds": round(time.time() - started, 3),
}
bundle_path = exp_dir / "models" / "model_v4_bundle.pkl"
save_model_bundle(str(bundle_path), template_model, selector_model, metadata)
write_json(exp_dir / "metadata.json", metadata)
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(bundle_path, args.out)
print(f"\nSaved experiment model bundle: {bundle_path}")
print(f"Copied model bundle to: {args.out}")
print(f"Saved metadata: {exp_dir / 'metadata.json'}")
print(f"Saved metrics: {exp_dir / 'metrics'}")
print(f"Saved splits: {exp_dir / 'splits'}")
print(f"Total duration: {metadata['duration_seconds']:.1f}s")
def main() -> None:
parser = argparse.ArgumentParser(
description="Cross-validate, train, and save the Track A v4 ML model bundle."
)
parser.add_argument("--train_path", default="data/Phase_1/train.json")
parser.add_argument("--out", default="models/model_v4_bundle.pkl")
parser.add_argument(
"--experiments_root", type=Path, default=Path("results/experiments")
)
parser.add_argument("--experiment_name", default="lgbm_v4")
parser.add_argument("--folds", type=int, default=5)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument(
"--n_jobs",
type=int,
default=-1,
help="Parallel jobs for LightGBM.",
)
parser.add_argument("--template_boost_rounds", type=int, default=2000)
parser.add_argument("--selector_boost_rounds", type=int, default=2000)
args = parser.parse_args()
run_training(args)
if __name__ == "__main__":
main()