Files
2026-06-17 17:12:19 -04:00

341 lines
14 KiB
Python

"""Neural + tree hybrid ensemble predictor.
Stacks gradient-boosted trees with a Transformer/BiLSTM network and a Bayesian
network (Monte-Carlo dropout) for uncertainty-aware signal filtering.
"""
import numpy as np
import pandas as pd
from sklearn.preprocessing import RobustScaler
from sklearn.model_selection import TimeSeriesSplit
import xgboost as xgb
import lightgbm as lgb
from catboost import CatBoostClassifier
from hmmlearn import hmm
import talib
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
class TimeSeriesDataset(Dataset):
def __init__(self, X, y):
self.X = torch.tensor(X, dtype=torch.float32)
self.y = torch.tensor(y, dtype=torch.long)
def __len__(self):
return len(self.y)
def __getitem__(self, idx):
return self.X[idx], self.y[idx]
class TransformerBlock(nn.Module):
def __init__(self, d_model, nhead, dropout=0.3):
super().__init__()
self.attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout, batch_first=True)
self.norm1 = nn.LayerNorm(d_model)
self.ff = nn.Sequential(
nn.Linear(d_model, 64),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(64, d_model)
)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
attn_out, _ = self.attn(x, x, x)
x = self.norm1(x + self.dropout(attn_out))
ff_out = self.ff(x)
x = self.norm2(x + self.dropout(ff_out))
return x
class NeuralEnsemblePredictor:
def __init__(self, forecast_period=12, confidence_threshold=0.6, device='cuda' if torch.cuda.is_available() else 'cpu'):
self.forecast_period = forecast_period
self.confidence_threshold = confidence_threshold
self.device = device
self.scaler = RobustScaler()
self.regime_model = hmm.GaussianHMM(n_components=3, random_state=42)
# Tree-based models with pre-tuned parameters
self.models = {
'xgboost': xgb.XGBClassifier(
colsample_bytree=0.9821911945239713,
learning_rate=0.07305593001592295,
max_depth=9,
min_child_weight=5,
subsample=0.7524259059189972,
random_state=42,
eval_metric='logloss'
),
'lightgbm': lgb.LGBMClassifier(
feature_fraction=0.9249583953429453,
learning_rate=0.025468440525690465,
max_depth=7,
min_child_samples=41,
subsample=0.8092209312217534,
random_state=44
),
'catboost': CatBoostClassifier(
colsample_bylevel=0.7762414086064304,
depth=4,
learning_rate=0.08067885793496102,
subsample=0.858134636983408,
random_state=45,
verbose=0
)
}
# PyTorch neural networks
self.nn_model = self._build_neural_network().to(self.device)
self.bnn_model = self._build_bayesian_network().to(self.device)
# Non-linear meta-model
self.meta_model = lgb.LGBMClassifier(
n_estimators=100, max_depth=3, learning_rate=0.05, random_state=46
)
def _build_neural_network(self):
"""Transformer-based neural network"""
class Net(nn.Module):
def __init__(self):
super().__init__()
self.transformer = nn.ModuleList([
TransformerBlock(d_model=6, nhead=2, dropout=0.3)
for _ in range(2)
])
self.lstm = nn.LSTM(6, 32, batch_first=True, bidirectional=True)
self.attn = nn.Linear(64, 1) # Attention over 64 from bidirectional LSTM
self.fc = nn.Sequential(
nn.Linear(64, 64),
nn.ReLU(),
nn.Dropout(0.4),
nn.Linear(64, 2)
)
def forward(self, x):
for t in self.transformer:
x = t(x)
x, _ = self.lstm(x) # Shape: (batch, 20, 64)
attn_weights = torch.softmax(self.attn(x), dim=1) # Shape: (batch, 20, 1)
x = (x * attn_weights).sum(dim=1) # Shape: (batch, 64)
x = self.fc(x)
return x
return Net()
def _build_bayesian_network(self):
"""Bayesian neural network with Monte Carlo dropout"""
class BNN(nn.Module):
def __init__(self):
super().__init__()
self.flatten = nn.Flatten()
self.fc1 = nn.Linear(20 * 6, 64)
self.fc2 = nn.Linear(64, 32)
self.fc3 = nn.Linear(32, 2)
self.dropout = nn.Dropout(0.3)
self.relu = nn.ReLU()
def forward(self, x, training=False):
x = self.flatten(x)
x = self.relu(self.fc1(x))
x = self.dropout(x) if training else x
x = self.relu(self.fc2(x))
x = self.dropout(x) if training else x
x = self.fc3(x)
return x
return BNN()
def detect_market_regime(self, data):
returns = np.log(data['close'] / data['close'].shift(1))
volatility = returns.rolling(window=20).std()
combined = pd.DataFrame({'returns': returns, 'volatility': volatility}).dropna()
self.regime_model.fit(combined.values)
regimes = self.regime_model.predict(combined.values)
regime_series = pd.Series(index=data.index, dtype='float64')
regime_series.iloc[len(data)-len(regimes):] = regimes
return regime_series
def create_advanced_features(self, df):
data = df.copy()
data['market_regime'] = self.detect_market_regime(data)
for period in [21, 55]:
data[f'ema_{period}'] = talib.EMA(data['close'], timeperiod=period)
data[f'trend_{period}'] = (data[f'ema_{period}'] - data[f'ema_{period}'].shift(period)) / data[f'ema_{period}'].shift(period)
data['atr_ratio'] = talib.ATR(data['high'], data['low'], data['close'], 14) / data['close']
data['rsi'] = talib.RSI(data['close'], 14)
data['volume_ma'] = talib.EMA(data['volume'], timeperiod=20)
data['volume_ratio'] = data['volume'] / data['volume_ma']
returns = data['close'].shift(-self.forecast_period) / data['close'] - 1
data['target'] = np.where(returns > 0.005, 1, np.where(returns < -0.005, 0, None))
return data.dropna()
def prepare_features(self, data, for_nn=False):
feature_columns = [
'market_regime', 'atr_ratio', 'rsi', 'volume_ratio',
'trend_21', 'trend_55'
]
X = data[feature_columns]
y = data['target'].astype(int)
if for_nn:
X_3d = np.array([X.iloc[i-20:i].values for i in range(20, len(X))])
y_3d = y.iloc[20:].values
return X_3d, y_3d
return X, y
def train_nn(self, X_nn, y_nn, model, epochs=50, batch_size=32):
dataset = TimeSeriesDataset(X_nn, y_nn)
train_size = int(0.8 * len(dataset))
val_size = len(dataset) - train_size
train_dataset, val_dataset = torch.utils.data.random_split(dataset, [train_size, val_size])
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=batch_size)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.0001)
best_val_loss = float('inf')
patience = 15
patience_counter = 0
for epoch in range(epochs):
model.train()
train_loss = 0
for X_batch, y_batch in train_loader:
X_batch, y_batch = X_batch.to(self.device), y_batch.to(self.device)
optimizer.zero_grad()
outputs = model(X_batch)
loss = criterion(outputs, y_batch)
loss.backward()
optimizer.step()
train_loss += loss.item()
model.eval()
val_loss = 0
with torch.no_grad():
for X_batch, y_batch in val_loader:
X_batch, y_batch = X_batch.to(self.device), y_batch.to(self.device)
outputs = model(X_batch)
val_loss += criterion(outputs, y_batch).item()
train_loss /= len(train_loader)
val_loss /= len(val_loader)
print(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss:.4f}, Val Loss: {val_loss:.4f}")
if val_loss < best_val_loss:
best_val_loss = val_loss
patience_counter = 0
torch.save(model.state_dict(), 'nn_best.pth')
else:
patience_counter += 1
if patience_counter >= patience:
print("Early stopping")
break
model.load_state_dict(torch.load('nn_best.pth'))
return model
def fit(self, train_data):
processed_data = self.create_advanced_features(train_data)
X, y = self.prepare_features(processed_data)
X_nn, y_nn = self.prepare_features(processed_data, for_nn=True)
X_scaled = self.scaler.fit_transform(X)
X_scaled = pd.DataFrame(X_scaled, columns=X.columns)
# Train tree-based models (no tuning)
tscv = TimeSeriesSplit(n_splits=5)
oof_preds = np.zeros((len(X_scaled), len(self.models) + 2))
for fold, (train_idx, val_idx) in enumerate(tscv.split(X_scaled)):
X_train, X_val = X_scaled.iloc[train_idx], X_scaled.iloc[val_idx]
y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
for i, (name, model) in enumerate(self.models.items()):
if name == 'xgboost':
model.fit(X_train, y_train, eval_set=[(X_val, y_val)],verbose=False)
elif name == 'lightgbm':
model.fit(X_train, y_train, eval_set=[(X_val, y_val)])
elif name == 'catboost':
model.fit(X_train, y_train, eval_set=(X_val, y_val))
oof_preds[val_idx, i] = model.predict_proba(X_val)[:, 1]
# Train neural network
nn_idx_shift = len(X_scaled) - len(X_nn)
self.nn_model = self.train_nn(X_nn, y_nn, self.nn_model)
self.nn_model.eval()
with torch.no_grad():
nn_preds = torch.softmax(self.nn_model(torch.tensor(X_nn, dtype=torch.float32).to(self.device)), dim=1)[:, 1].cpu().numpy()
oof_preds[nn_idx_shift:, len(self.models)] = nn_preds
# Train Bayesian NN with Monte Carlo dropout
self.bnn_model = self.train_nn(X_nn, y_nn, self.bnn_model)
self.bnn_model.train() # Enable dropout for MC estimation
mc_preds = []
with torch.no_grad():
for _ in range(10): # 10 Monte Carlo samples
preds = torch.softmax(self.bnn_model(torch.tensor(X_nn, dtype=torch.float32).to(self.device), training=True), dim=1)[:, 1].cpu().numpy()
mc_preds.append(preds)
bnn_mean = np.mean(mc_preds, axis=0)
bnn_std = np.std(mc_preds, axis=0)
oof_preds[nn_idx_shift:, len(self.models) + 1] = bnn_mean
# Train meta-model
self.meta_model.fit(oof_preds, y, sample_weight=np.exp(np.linspace(-1, 0, len(y))))
# Final training of tree models
for name, model in self.models.items():
if name in ['xgboost']:
model.fit(X_scaled, y, eval_set=[(X_scaled, y)], verbose=False)
elif name in ['lightgbm']:
model.fit(X_scaled, y, eval_set=[(X_scaled, y)])
else:
model.fit(X_scaled, y, eval_set=(X_scaled, y))
return processed_data
def predict(self, data):
processed_data = self.create_advanced_features(data)
X, _ = self.prepare_features(processed_data)
X_nn, _ = self.prepare_features(processed_data, for_nn=True)
X_scaled = self.scaler.transform(X)
X_scaled = pd.DataFrame(X_scaled, columns=X.columns)
base_preds = np.zeros((len(X_scaled), len(self.models) + 2))
for i, (name, model) in enumerate(self.models.items()):
base_preds[:, i] = model.predict_proba(X_scaled)[:, 1]
nn_idx_shift = len(X_scaled) - len(X_nn)
self.nn_model.eval()
with torch.no_grad():
nn_preds = torch.softmax(self.nn_model(torch.tensor(X_nn, dtype=torch.float32).to(self.device)), dim=1)[:, 1].cpu().numpy()
base_preds[nn_idx_shift:, len(self.models)] = nn_preds
# Bayesian NN predictions with uncertainty
self.bnn_model.train() # Enable dropout
mc_preds = []
with torch.no_grad():
for _ in range(10):
preds = torch.softmax(self.bnn_model(torch.tensor(X_nn, dtype=torch.float32).to(self.device), training=True), dim=1)[:, 1].cpu().numpy()
mc_preds.append(preds)
bnn_mean = np.mean(mc_preds, axis=0)
bnn_std = np.std(mc_preds, axis=0)
base_preds[nn_idx_shift:, len(self.models) + 1] = bnn_mean
# Meta-model prediction
meta_proba = self.meta_model.predict_proba(base_preds)
signals = pd.Series(0, index=processed_data.index)
valid_indices = processed_data.index[20:] # Skip first 20 due to lookback
meta_proba_valid = meta_proba[nn_idx_shift:] # Align with NN predictions
long_mask = (meta_proba_valid[:, 1] > self.confidence_threshold) & (bnn_std < 0.2)
short_mask = (meta_proba_valid[:, 0] > self.confidence_threshold) & (bnn_std < 0.2)
signals.loc[valid_indices[long_mask]] = 1
signals.loc[valid_indices[short_mask]] = -1
return signals