PaPP v2 Modello: calibrazione rev (isotonic) + curva precisione/copertura + soglia operativa

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Pietro Giacobazzi
2026-06-17 09:59:35 +00:00
parent f4ec58440a
commit afcae4fbf7
6 changed files with 259 additions and 10 deletions
+49 -10
View File
@@ -99,12 +99,15 @@ Modello/
├── config_mr.yaml parametri del modello di mean-reversion
├── src/
│ ├── mr_labeling.py costruzione dei 3 target + maschere
── mr_train.py pool + walk-forward + LOSO + modelli finali
── mr_train.py pool + walk-forward + LOSO + modelli finali
│ └── mr_calibrate.py calibrazione + curva precisione/copertura
└── results/
├── mr_wf_<task>.csv metriche walk-forward per anno
├── mr_loso_<task>.csv metriche leave-one-symbol-out
├── mr_summary.json riepilogo medio
── mr_model_<task>.joblib modelli finali (non versionati)
├── mr_wf_<task>.csv metriche walk-forward per anno
├── mr_loso_<task>.csv metriche leave-one-symbol-out
├── mr_precision_coverage.csv soglia -> copertura/precisione/lift
── mr_calibration_metrics.json Brier prima/dopo + LOSO
├── mr_*.png grafici (affidabilita', precisione/copertura)
└── mr_*.joblib modelli (non versionati)
```
```bash
@@ -112,12 +115,48 @@ pip install -r requirements.txt
# copia in ./data i CSV crosses+bars dei 4 simboli (vedi data/README.md), poi:
cd src
python mr_train.py # usa ../config_mr.yaml
python mr_calibrate.py # calibrazione + soglia operativa (sez. 8)
```
## 8. Prossimi passi
## 8. Calibrazione e soglia operativa (`src/mr_calibrate.py`)
Il modello `rev` **ordina** bene gli eventi, ma la probabilità grezza non è
"vera" (vedi curva rossa sotto: un 0.55 grezzo corrispondeva a ~46% di rientri
reali). La **calibrazione isotonica** la rende affidabile nella zona operativa
(0.70.9) e migliora il Brier score da **0.198 → 0.189** (più basso = meglio).
Split temporale onesto: modello base su anni `< 2016`, calibratore su `20162017`,
valutazione su `2018+` (mai visto). LOSO calibrato: AUC 0.670.70 su simbolo mai
visto → regge.
![Affidabilità](results/mr_calibration_reliability.png)
### Curva precisione/copertura (test 2018+, base rate 73%)
Per ogni soglia di probabilità: **copertura** = quanti segnali tieni,
**precisione** = tasso di rientro effettivo tra quelli tenuti.
| soglia | copertura | precisione | lift |
|---|---|---|---|
| 0.50 | 99% | 73.1% | 1.00 |
| 0.70 | 85% | 76.2% | 1.04 |
| **0.75** | **51%** | **81.2%** | **1.11** |
| 0.80 | 42% | 82.2% | 1.13 |
| 0.85 | 28% | 83.3% | 1.14 |
| 0.90 | 6% | 87.0% | 1.19 |
![Precisione vs Copertura](results/mr_precision_coverage.png)
**Soglia consigliata: ~0.75** → l'EA opera su ~metà dei setup di mean-reversion
ma il tasso di rientro sale dal 73% all'**81%**. Chi vuole essere più selettivo
usa 0.800.85 (precisione ~8283%, copertura 2842%). La soglia è un parametro:
la tabella completa è in `results/mr_precision_coverage.csv`.
Nota: tra 0.70 e 0.75 la copertura crolla (85%→51%) perché lì si concentra la
massa delle probabilità; è il punto in cui il filtro inizia a "mordere".
## 9. Prossimi passi
- **Calibrazione** delle probabilità di `rev` e scelta soglia operativa
(precisione vs copertura) per l'EA.
- Aggiungere altri major FX al pool per irrobustire ancora il LOSO.
- **Fase 4**: export del modello `rev` in **ONNX** (skl2onnx) e inferenza in MT5
come filtro dei segnali di mean-reversion.
- **Fase 4**: export del modello `rev` **calibrato** in **ONNX** (skl2onnx) e
inferenza in MT5 come filtro dei segnali di mean-reversion (soglia ~0.75).
@@ -0,0 +1,33 @@
{
"method": "isotonic",
"brier_raw": 0.19825470393871938,
"brier_cal": 0.18872294228809272,
"auc": 0.6457651952507415,
"test_base_rate": 0.7297476759628154,
"loso": [
{
"symbol": "EURUSD",
"brier": 0.1813803944436018,
"auc": 0.6746989606877932,
"base": 0.7324227355699628
},
{
"symbol": "USDJPY",
"brier": 0.19381118796660657,
"auc": 0.6742950427540226,
"base": 0.7028252001812962
},
{
"symbol": "USDCHF",
"brier": 0.16726747719373602,
"auc": 0.699568173561329,
"base": 0.7603328331741918
},
{
"symbol": "GBPUSD",
"brier": 0.17639890281257165,
"auc": 0.6957124361813346,
"base": 0.7374227714033539
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,11 @@
soglia,copertura,precisione,n_tenuti,lift_vs_base
0.5,0.9932577382776586,0.7307929651342179,19446,1.0014323980820128
0.55,0.9445295740116457,0.744646333549643,18492,1.02041617682053
0.6,0.9440698743487588,0.7448466158091219,18483,1.0206906309449841
0.65,0.8972826642149351,0.7543689873057438,17567,1.0337394857893085
0.7,0.8463581571151292,0.7623415811707905,16570,1.0446646235152053
0.75,0.509704770660946,0.8117045796171961,9979,1.1123085504126453
0.8,0.42312800081724383,0.8221873491067118,8284,1.1266734738441382
0.85,0.2760241086934314,0.8330866025166543,5404,1.1416091204641323
0.9,0.0641025641025641,0.8701195219123506,1255,1.1923566878980891
0.95,0.0,,0,
1 soglia copertura precisione n_tenuti lift_vs_base
2 0.5 0.9932577382776586 0.7307929651342179 19446 1.0014323980820128
3 0.55 0.9445295740116457 0.744646333549643 18492 1.02041617682053
4 0.6 0.9440698743487588 0.7448466158091219 18483 1.0206906309449841
5 0.65 0.8972826642149351 0.7543689873057438 17567 1.0337394857893085
6 0.7 0.8463581571151292 0.7623415811707905 16570 1.0446646235152053
7 0.75 0.509704770660946 0.8117045796171961 9979 1.1123085504126453
8 0.8 0.42312800081724383 0.8221873491067118 8284 1.1266734738441382
9 0.85 0.2760241086934314 0.8330866025166543 5404 1.1416091204641323
10 0.9 0.0641025641025641 0.8701195219123506 1255 1.1923566878980891
11 0.95 0.0 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+166
View File
@@ -0,0 +1,166 @@
"""Calibrazione delle probabilita' del modello `rev` + scelta soglia operativa.
Perche': il modello `rev` ordina bene gli eventi (AUC ~0.68) ma la probabilita'
grezza puo' non essere "vera" (un 0.80 deve voler dire ~80% di rientri reali).
La calibrazione rende le probabilita' affidabili; la curva precisione/copertura
serve a scegliere la soglia per l'EA.
Split temporale onesto (nessun leakage):
train_core : anni < calib_start -> addestra il modello base
calib : [calib_start, test_start) -> stima il calibratore
test : >= test_start -> valutazione (mai vista)
Output (in ../results):
mr_calibration_reliability.png curva di affidabilita' (prima/dopo)
mr_precision_coverage.png precisione e copertura vs soglia
mr_precision_coverage.csv tabella soglia -> copertura/precisione/lift
mr_calibration_metrics.json Brier prima/dopo + LOSO
mr_rev_calibrated.joblib modello calibrato finale (non versionato)
Uso:
python mr_calibrate.py [config_mr.yaml] [method] # method: isotonic|sigmoid
"""
from __future__ import annotations
import os, sys, json
import numpy as np, pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from mr_train import load_cfg, load_pool
from mr_labeling import make_targets
from features import select_xy
from model import make_model
from sklearn.calibration import CalibratedClassifierCV
from sklearn.frozen import FrozenEstimator
from sklearn.metrics import brier_score_loss, roc_auc_score
RESULTS = os.path.normpath(os.path.join(HERE, "..", "results"))
CALIB_START = 2016
TEST_START = 2018
def reliability(y, p, bins=10):
edges = np.linspace(0, 1, bins + 1)
idx = np.clip(np.digitize(p, edges) - 1, 0, bins - 1)
xs, ys, ns = [], [], []
for b in range(bins):
m = idx == b
if m.sum() > 0:
xs.append(p[m].mean()); ys.append(y[m].mean()); ns.append(int(m.sum()))
return np.array(xs), np.array(ys), np.array(ns)
def prec_cov(y, p, taus):
rows = []
base = y.mean()
for t in taus:
keep = p >= t
cov = keep.mean()
prec = y[keep].mean() if keep.sum() > 0 else np.nan
rows.append({"soglia": round(float(t), 3), "copertura": float(cov),
"precisione": float(prec) if not np.isnan(prec) else np.nan,
"n_tenuti": int(keep.sum()),
"lift_vs_base": float(prec / base) if keep.sum() > 0 else np.nan})
return pd.DataFrame(rows)
def run(cfg_path, method="isotonic"):
cfg = load_cfg(cfg_path)
num, cat = cfg["features_numeric"], cfg["features_categorical"]
df = load_pool(cfg)
y_all, mask = make_targets(df, cfg["horizon"], cfg["drop_trivial"], cfg["trivial_eps"])["rev"]
d = df[mask].reset_index(drop=True)
y = y_all[mask]
yr = d.time.dt.year.values
tr = yr < CALIB_START
ca = (yr >= CALIB_START) & (yr < TEST_START)
te = yr >= TEST_START
print(f"train_core<{CALIB_START}: {tr.sum()} calib[{CALIB_START}-{TEST_START}): "
f"{ca.sum()} test>={TEST_START}: {te.sum()} base_rate test={y[te].mean():.3f}")
base = make_model(cfg, num, cat)
base.fit(select_xy(d[tr], num, cat), y[tr])
Xte = select_xy(d[te], num, cat); yte = y[te]
p_raw = base.predict_proba(Xte)[:, 1]
cal = CalibratedClassifierCV(FrozenEstimator(base), method=method)
cal.fit(select_xy(d[ca], num, cat), y[ca])
p_cal = cal.predict_proba(Xte)[:, 1]
b_raw, b_cal = brier_score_loss(yte, p_raw), brier_score_loss(yte, p_cal)
auc = roc_auc_score(yte, p_cal)
print(f"Brier grezzo={b_raw:.4f} calibrato={b_cal:.4f} (piu' basso = meglio) AUC={auc:.3f}")
# ---- reliability plot ----
os.makedirs(RESULTS, exist_ok=True)
xr, yr_, _ = reliability(yte, p_raw)
xc, yc, _ = reliability(yte, p_cal)
plt.figure(figsize=(5, 5))
plt.plot([0, 1], [0, 1], "k--", lw=1, label="ideale")
plt.plot(xr, yr_, "o-", color="#c0392b", label=f"grezzo (Brier {b_raw:.3f})")
plt.plot(xc, yc, "o-", color="#27ae60", label=f"{method} (Brier {b_cal:.3f})")
plt.xlabel("probabilita' prevista"); plt.ylabel("frequenza reale di rientro")
plt.title(f"Affidabilita' rev (test >= {TEST_START})"); plt.legend(); plt.tight_layout()
plt.savefig(os.path.join(RESULTS, "mr_calibration_reliability.png"), dpi=110); plt.close()
# ---- precisione/copertura ----
taus = np.round(np.arange(0.50, 0.951, 0.05), 2)
pc = prec_cov(yte, p_cal, taus)
pc.to_csv(os.path.join(RESULTS, "mr_precision_coverage.csv"), index=False)
print("\nsoglia copertura precisione lift n")
for _, r in pc.iterrows():
print(f" {r.soglia:.2f} {r.copertura*100:5.1f}% "
f"{r.precisione*100:5.1f}% {r.lift_vs_base:.2f} {int(r.n_tenuti)}")
fig, ax1 = plt.subplots(figsize=(6, 4))
ax1.plot(pc.soglia, pc.precisione * 100, "o-", color="#27ae60")
ax1.set_xlabel("soglia di probabilita'"); ax1.set_ylabel("precisione (% rientri)", color="#27ae60")
ax1.axhline(yte.mean() * 100, color="#7f8c8d", ls=":", label=f"base rate {yte.mean()*100:.0f}%")
ax2 = ax1.twinx()
ax2.plot(pc.soglia, pc.copertura * 100, "s--", color="#2980b9")
ax2.set_ylabel("copertura (% segnali tenuti)", color="#2980b9")
ax1.set_title("Precisione vs Copertura (rev calibrato)"); ax1.legend(loc="lower left")
fig.tight_layout(); fig.savefig(os.path.join(RESULTS, "mr_precision_coverage.png"), dpi=110)
plt.close()
# ---- LOSO: Brier calibrato su simbolo mai visto ----
loso = []
for s in cfg["symbols"]:
m_tr = d.symbol.values != s; m_te = d.symbol.values == s
b = make_model(cfg, num, cat)
cv = CalibratedClassifierCV(b, method=method, cv=5)
cv.fit(select_xy(d[m_tr], num, cat), y[m_tr])
pp = cv.predict_proba(select_xy(d[m_te], num, cat))[:, 1]
loso.append({"symbol": s, "brier": float(brier_score_loss(y[m_te], pp)),
"auc": float(roc_auc_score(y[m_te], pp)), "base": float(y[m_te].mean())})
print("\nLOSO (calibrato, simbolo mai visto):")
for r in loso:
print(f" {r['symbol']:7s} Brier={r['brier']:.4f} AUC={r['auc']:.3f} base={r['base']:.3f}")
with open(os.path.join(RESULTS, "mr_calibration_metrics.json"), "w") as f:
json.dump({"method": method, "brier_raw": b_raw, "brier_cal": b_cal,
"auc": auc, "test_base_rate": float(yte.mean()), "loso": loso}, f, indent=2)
# ---- modello finale: base su (train+calib) + calibratore sull'ultimo blocco ----
fit_mask = yr < TEST_START
base_f = make_model(cfg, num, cat); base_f.fit(select_xy(d[fit_mask], num, cat), y[fit_mask])
final = CalibratedClassifierCV(base_f, method=method, cv=5)
final.fit(select_xy(d[fit_mask], num, cat), y[fit_mask])
try:
import joblib
joblib.dump(final, os.path.join(RESULTS, "mr_rev_calibrated.joblib"))
except Exception as e:
print("modello non serializzato:", e)
print(f"\nsalvati grafici, tabella e modello in {RESULTS}")
return pc
if __name__ == "__main__":
cfg_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "config_mr.yaml")
method = sys.argv[2] if len(sys.argv) > 2 else "isotonic"
run(cfg_path, method)