Spaces:
Sleeping
Sleeping
File size: 9,208 Bytes
525d364 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 | #!/usr/bin/env python3
"""C22: Ensemble weight search — find optimal RF/XGB/LGBM blend via walk-forward.
Strategy:
1. Walk-forward per stock, collect raw proba from RF/XGB/LGBM for each test window.
2. Grid-search blend weights [0.25..2.0] over pooled stock data, optimise up_precision
while requiring dir_accuracy >= 40%.
3. Report per-stock metrics under both equal weights and globally-optimal weights.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import warnings; warnings.filterwarnings("ignore")
import json
import argparse
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
from xgboost import XGBClassifier
from lightgbm import LGBMClassifier
from scripts.improvement_harness import (
fetch_df, build_triple_barrier_labels, compute_metrics,
CURRENT_FEATURES, DEFAULT_STOCKS, EXTENDED_STOCKS,
PASS_DIR_ACC, PASS_UP_PREC,
MIN_TRAIN, STEP, LABEL_HORIZON, RF_PARAMS,
)
from models.predictor import _build_features
CLASSES = [-1, 0, 1] # down, hold, up
XGB_PARAMS = dict(
n_estimators=100, max_depth=6, learning_rate=0.1,
eval_metric="mlogloss", random_state=42, n_jobs=1, verbosity=0,
)
LGBM_PARAMS = dict(
n_estimators=150, max_depth=6, learning_rate=0.05,
class_weight="balanced", random_state=42, n_jobs=1, verbose=-1,
)
WEIGHT_GRID = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
DIR_ACC_FLOOR = 40.0 # minimum dir_accuracy to accept a weight combo
def _collect_proba(feat_df, label_arr, cols):
"""Walk-forward: return accumulated (y_true, rf_p, xgb_p, lgbm_p) arrays."""
avail = [c for c in cols if c in feat_df.columns]
X_all = feat_df[avail].fillna(0).values
n = len(feat_df)
y_true_all, rf_all, xgb_all, lgbm_all = [], [], [], []
cutoff = MIN_TRAIN
while cutoff + STEP + LABEL_HORIZON <= n:
train_end = cutoff - LABEL_HORIZON
if train_end < MIN_TRAIN - LABEL_HORIZON:
cutoff += STEP; continue
y_tr = label_arr[:train_end]
valid = ~np.isnan(y_tr)
y_v = y_tr[valid].astype(int)
if len(y_v) < 10 or len(np.unique(y_v)) < 2:
cutoff += STEP; continue
X_tr = X_all[:train_end][valid]
scaler = StandardScaler()
X_tr_s = scaler.fit_transform(X_tr)
rf = RandomForestClassifier(**RF_PARAMS)
rf.fit(X_tr_s, y_v)
# XGB needs 0-indexed labels: -1→0, 0→1, 1→2
xgb = XGBClassifier(**XGB_PARAMS)
xgb.fit(X_tr_s, y_v + 1)
lgbm = LGBMClassifier(**LGBM_PARAMS)
lgbm.fit(X_tr_s, y_v)
test_end = min(cutoff + STEP, n - LABEL_HORIZON)
y_te = label_arr[cutoff:test_end]
valid_te = ~np.isnan(y_te)
if valid_te.sum() == 0:
cutoff += STEP; continue
X_te_s = scaler.transform(X_all[cutoff:test_end][valid_te])
y_true = y_te[valid_te].astype(int)
# RF: classes can be a subset of {-1,0,1}
rf_p = np.zeros((len(y_true), 3))
for col_i, cls in enumerate(CLASSES):
if cls in rf.classes_:
rf_p[:, col_i] = rf.predict_proba(X_te_s)[:, list(rf.classes_).index(cls)]
# XGB: predict_proba columns → 0-indexed, remap to [-1,0,1]
xgb_raw = xgb.predict_proba(X_te_s)
xgb_p = np.zeros((len(y_true), 3))
xgb_classes = [int(c) for c in xgb.classes_] # e.g. [0,1,2]
for shifted_cls in xgb_classes:
orig_cls = shifted_cls - 1 # shift back: 0→-1, 1→0, 2→1
if orig_cls in CLASSES:
col_i = CLASSES.index(orig_cls)
xgb_p[:, col_i] = xgb_raw[:, xgb_classes.index(shifted_cls)]
# LGBM: same class ordering logic as RF
lgbm_p = np.zeros((len(y_true), 3))
lgbm_raw = lgbm.predict_proba(X_te_s)
for col_i, cls in enumerate(CLASSES):
if cls in lgbm.classes_:
lgbm_p[:, col_i] = lgbm_raw[:, list(lgbm.classes_).index(cls)]
y_true_all.extend(y_true.tolist())
rf_all.append(rf_p)
xgb_all.append(xgb_p)
lgbm_all.append(lgbm_p)
cutoff += STEP
if not y_true_all:
return None
return {
"y_true": np.array(y_true_all),
"rf_proba": np.vstack(rf_all),
"xgb_proba": np.vstack(xgb_all),
"lgbm_proba": np.vstack(lgbm_all),
}
def _blend_metrics(collected, w_rf, w_xgb, w_lgbm):
total = w_rf + w_xgb + w_lgbm
blended = (
w_rf * collected["rf_proba"] +
w_xgb * collected["xgb_proba"] +
w_lgbm* collected["lgbm_proba"]
) / total
y_pred = np.array([CLASSES[i] for i in blended.argmax(axis=1)])
return compute_metrics(collected["y_true"], y_pred)
def _search_weights_pooled(all_collected):
"""Find globally optimal (w_rf=1 fixed, w_xgb, w_lgbm) across all stocks.
Evaluates each candidate on per-stock metrics then averages, so every
stock contributes equally regardless of sample count.
"""
best_score = -1.0
best_w = (1.0, 1.0, 1.0)
best_avg = {}
for w_xgb in WEIGHT_GRID:
for w_lgbm in WEIGHT_GRID:
up_precs, dir_accs = [], []
for c in all_collected:
m = _blend_metrics(c, 1.0, w_xgb, w_lgbm)
if m and not np.isnan(m.get("up_precision", float("nan"))):
up_precs.append(m["up_precision"])
dir_accs.append(m["dir_accuracy"])
if not up_precs:
continue
avg_up = float(np.mean(up_precs))
avg_dir = float(np.mean(dir_accs))
if avg_dir < DIR_ACC_FLOOR:
continue
if avg_up > best_score:
best_score = avg_up
best_w = (1.0, w_xgb, w_lgbm)
best_avg = {"dir_accuracy": round(avg_dir, 1),
"up_precision": round(avg_up, 1)}
return best_w, best_avg
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--extended", action="store_true", help="Use 12-stock set")
args = parser.parse_args()
stocks = EXTENDED_STOCKS if args.extended else DEFAULT_STOCKS
tag = "12-stock" if args.extended else "5-stock"
print(f"\n=== C22: Ensemble Weight Search [{tag}] ===")
all_collected = []
per_stock = {}
for stock_no in stocks:
print(f" {stock_no} collecting...", end=" ", flush=True)
df = fetch_df(stock_no)
if df is None or df.empty:
print("no data"); continue
feat = _build_features(df)
close = (df.set_index("date")["close"] if "date" in df.columns else df["close"]).values
labels = build_triple_barrier_labels(close)
c = _collect_proba(feat, labels, CURRENT_FEATURES)
if c is None:
print("insufficient data"); continue
baseline = _blend_metrics(c, 1.0, 1.0, 1.0)
per_stock[stock_no] = {"baseline": baseline, "collected_rows": int(len(c["y_true"]))}
all_collected.append(c)
print(f"dir={baseline['dir_accuracy']}% ↑prec={baseline['up_precision']}%")
if not all_collected:
print("No data collected."); return
print("\n Searching weights...", flush=True)
best_w, best_avg = _search_weights_pooled(all_collected)
w_rf, w_xgb, w_lgbm = best_w
# Per-stock metrics under global best weights
for stock_no, info in per_stock.items():
idx = list(per_stock.keys()).index(stock_no)
if idx < len(all_collected):
info["optimized"] = _blend_metrics(all_collected[idx], w_rf, w_xgb, w_lgbm)
# Baseline aggregate
base_dir = np.mean([v["baseline"]["dir_accuracy"] for v in per_stock.values() if "baseline" in v])
base_prec = np.mean([v["baseline"]["up_precision"] for v in per_stock.values() if "baseline" in v])
passed = (best_avg.get("dir_accuracy", 0) >= PASS_DIR_ACC and
best_avg.get("up_precision", 0) >= PASS_UP_PREC)
print(f"\n Best weights: RF={w_rf}, XGB={w_xgb}, LGBM={w_lgbm}")
print(f" Baseline (1/1/1): dir={base_dir:.1f}% ↑prec={base_prec:.1f}%")
print(f" Optimized : dir={best_avg.get('dir_accuracy')}% ↑prec={best_avg.get('up_precision')}%")
print(f" Gate (dir≥{PASS_DIR_ACC}% AND ↑prec≥{PASS_UP_PREC}%): {'PASS ✓' if passed else 'FAIL ✗'}")
result = {
"experiment": "C22",
"description": "Ensemble weight search: RF/XGB/LGBM blend optimisation",
"stocks": stocks,
"best_weights": {"RF": w_rf, "XGB": w_xgb, "LGBM": w_lgbm},
"baseline_aggregate": {"dir_accuracy": round(base_dir, 1), "up_precision": round(base_prec, 1)},
"optimized_aggregate": best_avg,
"per_stock": per_stock,
"passed": passed,
"pass_gate": {"dir_accuracy": PASS_DIR_ACC, "up_precision": PASS_UP_PREC},
}
suffix = "_12stock" if args.extended else ""
out = ROOT / f"docs/c22_result{suffix}.json"
out.parent.mkdir(exist_ok=True)
out.write_text(json.dumps(result, indent=2))
print(f"\n Saved: {out}")
if __name__ == "__main__":
main()
|