Spaces:
Running
Running
File size: 8,518 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 | #!/usr/bin/env python3
"""C18: DoubleEnsemble sample reweighting β target up_precision.
Two-stage walk-forward per window:
Stage 1: fast RF to score each training sample's difficulty.
Weight: upweight false-UP samples (trueβ UP, pred=UP) and boundary cases.
Stage 2: retrain RF + LGBM with sample_weight; blend 1:1.
Rationale: RF/LGBM support sample_weight natively. By penalising the model
for false-UP predictions on training data it sees fewer of them on test data,
directly improving up_precision without changing label quality or features.
"""
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 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
UP_IDX = 2 # column index for P(UP) in proba array
# Stage-1 RF is lighter β we just need decent in-sample discrimination
STAGE1_PARAMS = {**RF_PARAMS, "n_estimators": 50}
LGBM_PARAMS = dict(
n_estimators=150, max_depth=6, learning_rate=0.05,
class_weight="balanced", random_state=42, n_jobs=1, verbose=-1,
)
# Reweighting hyper-parameters β searched over alpha in main()
FALSE_UP_ALPHA = 2.0 # multiplier added for false-UP samples
NEAR_UP_BETA = 0.8 # multiplier for near-miss UP (high P(UP) but trueβ UP)
NEAR_UP_THRESH = 0.35 # P(UP) threshold to count as near-miss
def _compute_weights(y_true, y_pred, proba, alpha, beta, thresh):
"""Return per-sample weights emphasising false-UP and near-UP boundary cases."""
weights = np.ones(len(y_true))
up_prob = proba[:, UP_IDX]
for i in range(len(y_true)):
if y_true[i] != 1 and y_pred[i] == 1:
# False positive for UP on training data β most important to fix
weights[i] = 1.0 + alpha
elif y_true[i] != 1 and up_prob[i] >= thresh:
# Near-false-positive: model almost called UP but didn't
weights[i] = 1.0 + beta * up_prob[i]
return weights
def _proba_ordered(clf, X, classes=CLASSES):
"""Return proba columns ordered as CLASSES=[-1,0,1], zero-filling missing classes."""
raw = clf.predict_proba(X)
order = list(clf.classes_)
p = np.zeros((len(X), len(classes)))
for col_i, cls in enumerate(classes):
if cls in order:
p[:, col_i] = raw[:, order.index(cls)]
return p
def walk_forward_reweighted(feat_df, label_arr, cols, alpha, beta, thresh):
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, y_pred_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)
# ββ Stage 1: fast RF for difficulty scoring ββββββββββββββββββββββββββ
s1 = RandomForestClassifier(**STAGE1_PARAMS)
s1.fit(X_tr_s, y_v)
s1_proba = _proba_ordered(s1, X_tr_s)
s1_pred = np.array([CLASSES[i] for i in s1_proba.argmax(axis=1)])
weights = _compute_weights(y_v, s1_pred, s1_proba, alpha, beta, thresh)
# ββ Stage 2: retrain RF + LGBM with sample weights βββββββββββββββββββ
rf = RandomForestClassifier(**RF_PARAMS)
rf.fit(X_tr_s, y_v, sample_weight=weights)
lgbm = LGBMClassifier(**LGBM_PARAMS)
lgbm.fit(X_tr_s, y_v, sample_weight=weights)
# ββ Test window βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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])
rf_p = _proba_ordered(rf, X_te_s)
lgbm_p = _proba_ordered(lgbm, X_te_s)
blended = (rf_p + lgbm_p) / 2.0
y_pred = np.array([CLASSES[i] for i in blended.argmax(axis=1)])
y_true_all.extend(y_te[valid_te].astype(int).tolist())
y_pred_all.extend(y_pred.tolist())
cutoff += STEP
if not y_true_all:
return {}
return compute_metrics(np.array(y_true_all), np.array(y_pred_all))
def run_stocks(stocks, alpha, beta=NEAR_UP_BETA, thresh=NEAR_UP_THRESH, verbose=True):
results, metrics_list = {}, []
for stock_no in stocks:
if verbose:
print(f" {stock_no}...", end=" ", flush=True)
df = fetch_df(stock_no)
if df is None or df.empty:
if verbose: 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)
m = walk_forward_reweighted(feat, labels, CURRENT_FEATURES, alpha, beta, thresh)
results[stock_no] = m
metrics_list.append(m)
if verbose and m:
print(f"dir={m['dir_accuracy']}% βprec={m['up_precision']}%")
return results, metrics_list
def _avg(metrics_list, key):
vals = [m[key] for m in metrics_list if m and not np.isnan(m.get(key, float("nan")))]
return round(float(np.mean(vals)), 1) if vals else float("nan")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--extended", action="store_true")
parser.add_argument("--alpha-search", action="store_true",
help="Grid-search alpha on 5-stock first")
args = parser.parse_args()
stocks = EXTENDED_STOCKS if args.extended else DEFAULT_STOCKS
tag = "12-stock" if args.extended else "5-stock"
if args.alpha_search and not args.extended:
print("\n=== C18: alpha search [5-stock] ===")
best_alpha, best_up = FALSE_UP_ALPHA, -1.0
for a in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]:
_, ml = run_stocks(DEFAULT_STOCKS, alpha=a, verbose=False)
up = _avg(ml, "up_precision")
dr = _avg(ml, "dir_accuracy")
print(f" alpha={a:.1f} dir={dr}% βprec={up}%")
if up > best_up and dr >= 40.0:
best_up, best_alpha = up, a
print(f"\n Best alpha = {best_alpha} (βprec={best_up}%)")
alpha = best_alpha
else:
alpha = FALSE_UP_ALPHA
print(f"\n=== C18: DoubleEnsemble reweight [alpha={alpha}] [{tag}] ===")
per_stock, metrics_list = run_stocks(stocks, alpha=alpha)
avg_dir = _avg(metrics_list, "dir_accuracy")
avg_up = _avg(metrics_list, "up_precision")
passed = avg_dir >= PASS_DIR_ACC and avg_up >= PASS_UP_PREC
print(f"\n Avg: dir={avg_dir}% βprec={avg_up}%")
print(f" Gate (dirβ₯{PASS_DIR_ACC}% AND βprecβ₯{PASS_UP_PREC}%): {'PASS β' if passed else 'FAIL β'}")
result = {
"experiment": "C18",
"description": "DoubleEnsemble sample reweighting (false-UP upweight)",
"alpha": alpha, "beta": NEAR_UP_BETA, "thresh": NEAR_UP_THRESH,
"stocks": stocks,
"aggregate": {"dir_accuracy": avg_dir, "up_precision": avg_up},
"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/c18_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()
|