mirror of
https://github.com/Arianhgh/fx-quant-research.git
synced 2026-07-29 03:27:46 +00:00
1150 lines
43 KiB
Python
1150 lines
43 KiB
Python
"""Model training, feature selection, evaluation and prediction manager."""
|
|
import time
|
|
import warnings
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import matplotlib.pyplot as plt
|
|
import seaborn as sns
|
|
from sklearn.model_selection import TimeSeriesSplit
|
|
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
|
|
from sklearn.preprocessing import StandardScaler
|
|
from sklearn.decomposition import PCA
|
|
from sklearn.feature_selection import RFE, SelectFromModel
|
|
import lightgbm as lgb
|
|
from boruta import BorutaPy
|
|
from joblib import Parallel, delayed, dump, load
|
|
import optuna
|
|
import shap
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
|
|
class GoldModelManager:
|
|
"""
|
|
Manages model training, evaluation, and prediction for 5-minute gold trading.
|
|
Implements various feature selection techniques, training strategies,
|
|
and signal generation approaches.
|
|
"""
|
|
def __init__(
|
|
self,
|
|
n_splits=5,
|
|
test_size=0.2,
|
|
feature_selection_method='boruta',
|
|
max_features=25,
|
|
ensemble_models=3,
|
|
volatility_based_models=True,
|
|
n_jobs=-1,
|
|
model_path='models',
|
|
random_state=42
|
|
):
|
|
"""
|
|
Initialize the model manager
|
|
|
|
Parameters:
|
|
-----------
|
|
n_splits : int
|
|
Number of splits for time series cross-validation
|
|
test_size : float
|
|
Proportion of data to use for final testing
|
|
feature_selection_method : str
|
|
Method for feature selection ('boruta', 'rfe', 'importance', 'pca')
|
|
max_features : int
|
|
Maximum number of features to select
|
|
ensemble_models : int
|
|
Number of models in the ensemble
|
|
volatility_based_models : bool
|
|
Whether to train separate models for different volatility regimes
|
|
n_jobs : int
|
|
Number of parallel jobs for training
|
|
model_path : str
|
|
Directory to save trained models
|
|
random_state : int
|
|
Random seed for reproducibility
|
|
"""
|
|
self.n_splits = n_splits
|
|
self.test_size = test_size
|
|
self.feature_selection_method = feature_selection_method
|
|
self.max_features = max_features
|
|
self.ensemble_models = ensemble_models
|
|
self.volatility_based_models = volatility_based_models
|
|
self.n_jobs = n_jobs
|
|
self.random_state = random_state
|
|
self.model_path = Path(model_path)
|
|
self.model_path.mkdir(exist_ok=True)
|
|
|
|
# Initialize components
|
|
self.scaler = StandardScaler()
|
|
self.models = {}
|
|
self.meta_model = None
|
|
self.feature_selector = None
|
|
self.selected_features = None
|
|
self.feature_importance = None
|
|
self.pca = None
|
|
def validate_data(self, X, y):
|
|
"""
|
|
Validate data before modeling to ensure all classes are represented
|
|
"""
|
|
print("Validating data...")
|
|
|
|
# Check for missing values
|
|
missing_count = X.isna().sum().sum()
|
|
if missing_count > 0:
|
|
print(f"Warning: {missing_count} missing values detected in features")
|
|
|
|
# Check for infinities - only for numeric columns
|
|
numeric_cols = X.select_dtypes(include=['number']).columns
|
|
if len(numeric_cols) > 0:
|
|
inf_count = np.isinf(X[numeric_cols].values).sum()
|
|
if inf_count > 0:
|
|
print(f"Warning: {inf_count} infinity values detected in features")
|
|
|
|
# Check class distribution
|
|
unique_classes = np.unique(y)
|
|
class_counts = {cls: np.sum(y == cls) for cls in unique_classes}
|
|
|
|
print("Class distribution:")
|
|
for cls, count in sorted(class_counts.items()):
|
|
print(f" Class {cls}: {count} samples ({count/len(y):.2%})")
|
|
|
|
# Verify all expected classes are present
|
|
expected_classes = set(range(-2, 3)) # -2, -1, 0, 1, 2
|
|
missing_classes = expected_classes - set(unique_classes)
|
|
|
|
if missing_classes:
|
|
print(f"Warning: Missing classes in data: {missing_classes}")
|
|
print("Model will be trained with available classes only.")
|
|
|
|
# Check for severe imbalance
|
|
min_class_count = min(class_counts.values())
|
|
if min_class_count < 10:
|
|
print(f"Warning: Some classes have very few samples (min: {min_class_count})")
|
|
|
|
# Check feature values for numeric columns only
|
|
for col in numeric_cols:
|
|
try:
|
|
col_min = X[col].min()
|
|
col_max = X[col].max()
|
|
col_mean = X[col].mean()
|
|
col_std = X[col].std()
|
|
|
|
# Check for suspiciously high values
|
|
if col_max > 1e6 or col_min < -1e6:
|
|
print(f"Warning: Feature '{col}' has extreme values: min={col_min}, max={col_max}")
|
|
|
|
# Check for very low variance
|
|
if col_std < 1e-6:
|
|
print(f"Warning: Feature '{col}' has very low variance: std={col_std}")
|
|
except Exception as e:
|
|
print(f"Warning: Could not analyze feature '{col}': {str(e)}")
|
|
|
|
# For categorical columns, check cardinality
|
|
cat_cols = X.select_dtypes(include=['category', 'object']).columns
|
|
for col in cat_cols:
|
|
try:
|
|
n_unique = X[col].nunique()
|
|
print(f"Categorical feature '{col}' has {n_unique} unique values")
|
|
except Exception as e:
|
|
print(f"Warning: Could not analyze categorical feature '{col}': {str(e)}")
|
|
|
|
return True
|
|
|
|
def prepare_data(self, data, remove_cols=None):
|
|
"""
|
|
Prepare data for modeling by separating features from target
|
|
and removing unwanted columns
|
|
|
|
Parameters:
|
|
-----------
|
|
data : pd.DataFrame
|
|
DataFrame with features and target
|
|
remove_cols : list
|
|
List of columns to remove (not used as features)
|
|
|
|
Returns:
|
|
--------
|
|
X : pd.DataFrame
|
|
Feature matrix
|
|
y : pd.Series
|
|
Target variable
|
|
"""
|
|
if remove_cols is None:
|
|
# Default columns to remove (original data and non-feature columns)
|
|
remove_cols = [
|
|
'open', 'high', 'low', 'close', 'volume',
|
|
'future_return', 'strong_threshold', 'weak_threshold'
|
|
]
|
|
|
|
# Create copy to avoid modifying original
|
|
df = data.copy()
|
|
|
|
# Ensure target column exists
|
|
if 'target' not in df.columns:
|
|
raise ValueError("Target column 'target' not found in data")
|
|
|
|
# Extract target
|
|
y = df['target']
|
|
|
|
# Remove target and other non-feature columns
|
|
X = df.drop(['target'] + remove_cols, axis=1, errors='ignore')
|
|
|
|
# Handle remaining non-numeric columns
|
|
for col in X.select_dtypes(include=['object', 'category']).columns:
|
|
X[col] = X[col].astype('category')
|
|
|
|
return X, y
|
|
|
|
def _create_time_series_splits(self, X, y, train_ratio=0.8, validation_ratio=0.1):
|
|
"""
|
|
Create time-series aware data splits for proper backtesting
|
|
|
|
Parameters:
|
|
-----------
|
|
X : pd.DataFrame
|
|
Feature matrix
|
|
y : pd.Series
|
|
Target variable
|
|
train_ratio : float
|
|
Ratio of data to use for training
|
|
validation_ratio : float
|
|
Ratio of data to use for validation (from end of train)
|
|
|
|
Returns:
|
|
--------
|
|
X_train, X_val, X_test, y_train, y_val, y_test : DataFrames/Series
|
|
Split data components
|
|
"""
|
|
# Ensure indices match between X and y
|
|
assert X.index.equals(y.index), "X and y must have the same index"
|
|
|
|
# Calculate split points
|
|
n = len(X)
|
|
train_end = int(n * train_ratio)
|
|
val_end = train_end + int(n * validation_ratio)
|
|
|
|
# Split data
|
|
X_train = X.iloc[:train_end]
|
|
X_val = X.iloc[train_end:val_end]
|
|
X_test = X.iloc[val_end:]
|
|
|
|
y_train = y.iloc[:train_end]
|
|
y_val = y.iloc[train_end:val_end]
|
|
y_test = y.iloc[val_end:]
|
|
|
|
return X_train, X_val, X_test, y_train, y_val, y_test
|
|
|
|
def _get_optimal_lightgbm_params(self, X_train, y_train, X_val, y_val):
|
|
"""
|
|
Optimize LightGBM hyperparameters using Optuna with proper class weight handling
|
|
"""
|
|
def objective(trial):
|
|
param = {
|
|
'boosting_type': 'gbdt',
|
|
'objective': 'multiclass',
|
|
'num_class': 5,
|
|
'metric': 'multi_logloss',
|
|
'num_leaves': trial.suggest_int('num_leaves', 10, 100),
|
|
'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.1, log=True),
|
|
'feature_fraction': trial.suggest_float('feature_fraction', 0.5, 1.0),
|
|
'bagging_fraction': trial.suggest_float('bagging_fraction', 0.5, 1.0),
|
|
'bagging_freq': trial.suggest_int('bagging_freq', 1, 10),
|
|
'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
|
|
'reg_alpha': trial.suggest_float('reg_alpha', 1e-8, 10.0, log=True),
|
|
'reg_lambda': trial.suggest_float('reg_lambda', 1e-8, 10.0, log=True),
|
|
'verbose': -1,
|
|
'random_state': self.random_state
|
|
}
|
|
|
|
# Get unique classes in the training data
|
|
unique_classes = np.unique(y_train)
|
|
|
|
# Create weights for each class that exists in the data
|
|
class_weights = {}
|
|
for cls in range(-2, 3): # -2, -1, 0, 1, 2
|
|
if cls in unique_classes:
|
|
# Calculate actual weight based on class frequency
|
|
count = np.sum(y_train == cls)
|
|
class_weights[cls] = 1.0 / max(count, 1)
|
|
else:
|
|
# For missing classes, assign a moderate weight
|
|
# (average of existing weights would be another option)
|
|
class_weights[cls] = 1.0
|
|
|
|
# Normalize weights to sum to number of classes
|
|
total_weight = sum(class_weights.values())
|
|
class_weights = {k: (v / total_weight) * 5 for k, v in class_weights.items()}
|
|
|
|
# Train model
|
|
model = lgb.LGBMClassifier(**param, class_weight=class_weights)
|
|
model.fit(
|
|
X_train, y_train,
|
|
eval_set=[(X_val, y_val)],
|
|
eval_metric='multi_logloss',
|
|
callbacks=[lgb.early_stopping(50, verbose=False)],
|
|
)
|
|
|
|
# Evaluate on validation set
|
|
preds = model.predict(X_val)
|
|
return accuracy_score(y_val, preds)
|
|
|
|
# Create and run the study
|
|
study = optuna.create_study(direction='maximize')
|
|
study.optimize(objective, n_trials=50)
|
|
|
|
return study.best_params
|
|
|
|
def select_features(self, X, y, method=None):
|
|
"""
|
|
Select the most important features using the specified method
|
|
|
|
Parameters:
|
|
-----------
|
|
X : pd.DataFrame
|
|
Feature matrix
|
|
y : pd.Series
|
|
Target variable
|
|
method : str, optional
|
|
Feature selection method (if None, use the instance's method)
|
|
|
|
Returns:
|
|
--------
|
|
selected_features : list
|
|
List of selected feature names
|
|
"""
|
|
if method is None:
|
|
method = self.feature_selection_method
|
|
|
|
print(f"Selecting features using {method} method...")
|
|
if method == 'boruta':
|
|
# Boruta feature selection
|
|
try:
|
|
# Ensure positive value for n_estimators
|
|
base_model = lgb.LGBMClassifier(
|
|
n_jobs=self.n_jobs,
|
|
random_state=self.random_state,
|
|
n_estimators=100, # Explicit positive value
|
|
verbose=-1
|
|
)
|
|
|
|
# Properly handle class weights for all possible classes
|
|
unique_classes = np.unique(y)
|
|
class_weights = {}
|
|
|
|
for cls in range(-2, 3): # -2, -1, 0, 1, 2
|
|
if cls in unique_classes:
|
|
count = np.sum(y == cls)
|
|
class_weights[cls] = 1.0 / max(count, 1)
|
|
else:
|
|
class_weights[cls] = 1.0 # Default weight for missing classes
|
|
|
|
# Normalize weights
|
|
total_weight = sum(class_weights.values())
|
|
class_weights = {k: (v / total_weight) * 5 for k, v in class_weights.items()}
|
|
|
|
base_model.set_params(class_weight=class_weights)
|
|
|
|
self.feature_selector = BorutaPy(
|
|
estimator=base_model,
|
|
n_estimators=100, # Explicit positive value instead of 'auto'
|
|
max_iter=100,
|
|
verbose=0,
|
|
random_state=self.random_state
|
|
)
|
|
|
|
# Convert to numpy arrays
|
|
X_values = X.values.astype(np.float64) # Ensure float type
|
|
y_values = y.values
|
|
|
|
# Check for NaNs and infinities
|
|
if np.isnan(X_values).any() or np.isinf(X_values).any():
|
|
print("Warning: NaN or Inf values detected in features, cleaning...")
|
|
X_values = np.nan_to_num(X_values, nan=0, posinf=0, neginf=0)
|
|
|
|
# Fit Boruta
|
|
self.feature_selector.fit(X_values, y_values)
|
|
selected_mask = self.feature_selector.support_
|
|
self.selected_features = X.columns[selected_mask].tolist()
|
|
|
|
except Exception as e:
|
|
print(f"Boruta feature selection failed: {str(e)}")
|
|
print("Falling back to importance-based feature selection...")
|
|
method = 'importance' # Fall back to importance-based selection
|
|
|
|
if method == 'rfe':
|
|
# Recursive Feature Elimination
|
|
base_model = lgb.LGBMClassifier(
|
|
n_jobs=self.n_jobs,
|
|
random_state=self.random_state
|
|
)
|
|
|
|
self.feature_selector = RFE(
|
|
estimator=base_model,
|
|
n_features_to_select=min(self.max_features, X.shape[1]),
|
|
step=1,
|
|
verbose=0
|
|
)
|
|
|
|
# Fit RFE
|
|
self.feature_selector.fit(X, y)
|
|
selected_mask = self.feature_selector.support_
|
|
self.selected_features = X.columns[selected_mask].tolist()
|
|
|
|
if method == 'importance':
|
|
# Feature importance-based selection
|
|
base_model = lgb.LGBMClassifier(
|
|
n_estimators=100,
|
|
n_jobs=self.n_jobs,
|
|
random_state=self.random_state,
|
|
verbose=-1
|
|
)
|
|
|
|
# Properly handle class weights
|
|
unique_classes = np.unique(y)
|
|
class_weights = {}
|
|
|
|
for cls in range(-2, 3): # -2, -1, 0, 1, 2
|
|
if cls in unique_classes:
|
|
count = np.sum(y == cls)
|
|
class_weights[cls] = 1.0 / max(count, 1)
|
|
else:
|
|
class_weights[cls] = 1.0
|
|
|
|
# Normalize weights
|
|
total_weight = sum(class_weights.values())
|
|
class_weights = {k: (v / total_weight) * 5 for k, v in class_weights.items()}
|
|
|
|
base_model.set_params(class_weight=class_weights)
|
|
|
|
# Train model
|
|
base_model.fit(X, y)
|
|
|
|
# Get feature importance
|
|
importance = base_model.feature_importances_
|
|
self.feature_importance = pd.DataFrame({
|
|
'feature': X.columns,
|
|
'importance': importance
|
|
}).sort_values('importance', ascending=False)
|
|
|
|
# Select top features
|
|
self.selected_features = self.feature_importance['feature'].iloc[:min(self.max_features, X.shape[1])].tolist()
|
|
|
|
# Create a SelectFromModel as feature_selector for consistency
|
|
self.feature_selector = SelectFromModel(
|
|
base_model,
|
|
max_features=min(self.max_features, X.shape[1])
|
|
)
|
|
self.feature_selector.fit(X, y)
|
|
|
|
if method == 'pca':
|
|
# PCA-based dimensionality reduction
|
|
# Scale the data first
|
|
X_scaled = self.scaler.fit_transform(X)
|
|
|
|
# Create and fit PCA
|
|
self.pca = PCA(n_components=min(self.max_features, X.shape[1]))
|
|
self.pca.fit(X_scaled)
|
|
|
|
# Get explained variance
|
|
explained_variance = self.pca.explained_variance_ratio_
|
|
cumulative_variance = np.cumsum(explained_variance)
|
|
|
|
# Determine number of components for 95% variance
|
|
n_components = np.argmax(cumulative_variance >= 0.95) + 1
|
|
n_components = min(n_components, self.max_features)
|
|
|
|
# Create new PCA with optimal components
|
|
self.pca = PCA(n_components=n_components)
|
|
self.pca.fit(X_scaled)
|
|
|
|
# PCA doesn't select features but transforms them
|
|
# For API consistency, we'll return feature names
|
|
self.selected_features = X.columns.tolist()
|
|
|
|
else:
|
|
# No feature selection, use all features
|
|
self.selected_features = X.columns.tolist()
|
|
|
|
print(f"Selected {len(self.selected_features)} features")
|
|
return self.selected_features
|
|
|
|
def train_base_models(self, X_train, y_train, X_val, y_val):
|
|
"""
|
|
Train the base models for the ensemble with proper class weight handling
|
|
"""
|
|
print("Training base models...")
|
|
models = {}
|
|
|
|
# Optimize hyperparameters once for shared configuration
|
|
best_params = self._get_optimal_lightgbm_params(X_train, y_train, X_val, y_val)
|
|
print(f"Optimized hyperparameters: {best_params}")
|
|
|
|
# Get unique classes in the training data
|
|
unique_classes = np.unique(y_train)
|
|
print(f"Classes present in training data: {unique_classes}")
|
|
|
|
# Create weights for each class that exists in the data
|
|
class_weights = {}
|
|
for cls in range(-2, 3): # -2, -1, 0, 1, 2
|
|
if cls in unique_classes:
|
|
# Calculate actual weight based on class frequency
|
|
count = np.sum(y_train == cls)
|
|
class_weights[cls] = 1.0 / max(count, 1)
|
|
print(f"Class {cls}: {count} samples, weight: {class_weights[cls]:.4f}")
|
|
else:
|
|
# For missing classes, assign a moderate weight
|
|
class_weights[cls] = 1.0
|
|
print(f"Class {cls}: 0 samples (missing), weight: 1.0000")
|
|
|
|
# Normalize weights to sum to number of classes
|
|
total_weight = sum(class_weights.values())
|
|
class_weights = {k: (v / total_weight) * 5 for k, v in class_weights.items()}
|
|
|
|
print("Normalized class weights:")
|
|
for k, v in class_weights.items():
|
|
print(f"Class {k}: {v:.4f}")
|
|
|
|
# Train ensemble models with different seeds and subset of features
|
|
for i in range(self.ensemble_models):
|
|
# Create a unique subset of features for diversity
|
|
if len(self.selected_features) > 10:
|
|
# Select 80-90% of features randomly for each model
|
|
sample_size = int(len(self.selected_features) * (0.8 + 0.1 * np.random.random()))
|
|
features = np.random.choice(self.selected_features, size=sample_size, replace=False)
|
|
else:
|
|
features = self.selected_features
|
|
|
|
# Adjust random seed for diversity
|
|
model_seed = self.random_state + i
|
|
|
|
# Create model with optimized parameters
|
|
model = lgb.LGBMClassifier(
|
|
**best_params,
|
|
random_state=model_seed,
|
|
n_jobs=self.n_jobs,
|
|
class_weight=class_weights
|
|
)
|
|
|
|
# Fit model
|
|
model.fit(
|
|
X_train[features], y_train,
|
|
eval_set=[(X_val[features], y_val)],
|
|
eval_metric='multi_logloss',
|
|
callbacks=[lgb.early_stopping(50, verbose=False)],
|
|
)
|
|
|
|
# Store model and its features
|
|
models[f'base_model_{i}'] = {
|
|
'model': model,
|
|
'features': features.tolist() if isinstance(features, np.ndarray) else features
|
|
}
|
|
|
|
print(f" Trained base model {i+1}/{self.ensemble_models}")
|
|
|
|
# If using volatility-based models, train separate models for each regime
|
|
if self.volatility_based_models and 'volatility_regime' in X_train.columns:
|
|
volatility_regimes = X_train['volatility_regime'].unique()
|
|
|
|
for regime in volatility_regimes:
|
|
# Get data for this regime
|
|
regime_mask = X_train['volatility_regime'] == regime
|
|
if regime_mask.sum() < 1000: # Skip if too few samples
|
|
continue
|
|
|
|
X_regime = X_train[regime_mask]
|
|
y_regime = y_train[regime_mask]
|
|
|
|
# Validation data for this regime
|
|
val_regime_mask = X_val['volatility_regime'] == regime
|
|
X_val_regime = X_val[val_regime_mask]
|
|
y_val_regime = y_val[val_regime_mask]
|
|
|
|
if len(X_val_regime) < 100: # Skip if too few validation samples
|
|
continue
|
|
|
|
# Get unique classes for this regime
|
|
regime_unique_classes = np.unique(y_regime)
|
|
|
|
# Create weights for each class that exists in this regime
|
|
regime_class_weights = {}
|
|
for cls in range(-2, 3): # -2, -1, 0, 1, 2
|
|
if cls in regime_unique_classes:
|
|
# Calculate actual weight based on class frequency
|
|
count = np.sum(y_regime == cls)
|
|
regime_class_weights[cls] = 1.0 / max(count, 1)
|
|
else:
|
|
# For missing classes, assign a default weight
|
|
regime_class_weights[cls] = 1.0
|
|
|
|
# Normalize weights
|
|
regime_total_weight = sum(regime_class_weights.values())
|
|
regime_class_weights = {k: (v / regime_total_weight) * 5 for k, v in regime_class_weights.items()}
|
|
|
|
# Create and train model
|
|
model = lgb.LGBMClassifier(
|
|
**best_params,
|
|
random_state=self.random_state,
|
|
n_jobs=self.n_jobs,
|
|
class_weight=regime_class_weights
|
|
)
|
|
|
|
# Fit model
|
|
model.fit(
|
|
X_regime[self.selected_features], y_regime,
|
|
eval_set=[(X_val_regime[self.selected_features], y_val_regime)],
|
|
eval_metric='multi_logloss',
|
|
callbacks=[lgb.early_stopping(50, verbose=False)],
|
|
)
|
|
|
|
# Store model
|
|
models[f'regime_model_{int(regime)}'] = {
|
|
'model': model,
|
|
'features': self.selected_features,
|
|
'regime': regime
|
|
}
|
|
|
|
print(f" Trained model for volatility regime {int(regime)}")
|
|
|
|
self.models = models
|
|
return models
|
|
|
|
def train_meta_model(self, X_val, y_val, X_test=None, y_test=None):
|
|
"""
|
|
Train a meta-model on the predictions of base models
|
|
|
|
Parameters:
|
|
-----------
|
|
X_val, y_val : Validation data for base model predictions
|
|
X_test, y_test : Optional test data
|
|
|
|
Returns:
|
|
--------
|
|
meta_model : trained meta-model
|
|
"""
|
|
print("Training meta-model...")
|
|
|
|
try:
|
|
# Generate predictions from all base models
|
|
base_predictions = self._get_ensemble_predictions(X_val)
|
|
|
|
# Create meta-features for training
|
|
meta_features = pd.DataFrame(base_predictions)
|
|
|
|
# Add volatility regime if available
|
|
if 'volatility_regime' in X_val.columns:
|
|
meta_features['volatility_regime'] = X_val['volatility_regime'].values
|
|
|
|
# Check for NaN or inf values
|
|
nan_count = np.isnan(meta_features.values).sum()
|
|
inf_count = np.isinf(meta_features.values).sum()
|
|
|
|
if nan_count > 0 or inf_count > 0:
|
|
print(f"Warning: Meta-features contain {nan_count} NaN and {inf_count} inf values")
|
|
print("Replacing with zeros...")
|
|
meta_features = meta_features.fillna(0)
|
|
meta_features = meta_features.replace([np.inf, -np.inf], 0)
|
|
|
|
# Get unique classes in validation data
|
|
unique_classes = np.unique(y_val)
|
|
print(f"Classes present in validation data: {unique_classes}")
|
|
|
|
# Create and train meta-model
|
|
meta_model = lgb.LGBMClassifier(
|
|
n_estimators=100,
|
|
learning_rate=0.05,
|
|
num_leaves=31,
|
|
random_state=self.random_state,
|
|
n_jobs=self.n_jobs,
|
|
objective='multiclass',
|
|
num_class=5 # Explicitly set to 5 for all classes (-2 to 2)
|
|
)
|
|
|
|
# Create weights for each class
|
|
class_weights = {}
|
|
for cls in range(-2, 3): # -2, -1, 0, 1, 2
|
|
if cls in unique_classes:
|
|
# Calculate actual weight based on class frequency
|
|
count = np.sum(y_val == cls)
|
|
class_weights[cls] = 1.0 / max(count, 1)
|
|
print(f"Class {cls}: {count} samples, weight: {class_weights[cls]:.4f}")
|
|
else:
|
|
# For missing classes, assign a default weight
|
|
class_weights[cls] = 1.0
|
|
print(f"Class {cls}: 0 samples (missing), weight: 1.0000")
|
|
|
|
# Convert class weights to the format expected by LightGBM (0-4 index)
|
|
indexed_weights = {}
|
|
for cls in range(-2, 3):
|
|
idx = cls + 2 # Convert class value to index: -2->0, -1->1, 0->2, 1->3, 2->4
|
|
indexed_weights[idx] = class_weights[cls]
|
|
|
|
# Normalize weights
|
|
total_weight = sum(indexed_weights.values())
|
|
indexed_weights = {k: (v / total_weight) * 5 for k, v in indexed_weights.items()}
|
|
|
|
print("Adjusted class weights for meta-model:")
|
|
for k, v in indexed_weights.items():
|
|
print(f"Index {k} (Class {k-2}): {v:.4f}")
|
|
|
|
# Set class weights
|
|
meta_model.set_params(class_weight=indexed_weights)
|
|
|
|
# Fit meta-model
|
|
meta_model.fit(meta_features, y_val)
|
|
|
|
# Evaluate meta-model if test data is provided
|
|
if X_test is not None and y_test is not None:
|
|
test_predictions = self._get_ensemble_predictions(X_test)
|
|
test_meta_features = pd.DataFrame(test_predictions)
|
|
|
|
if 'volatility_regime' in X_test.columns:
|
|
test_meta_features['volatility_regime'] = X_test['volatility_regime'].values
|
|
|
|
# Replace NaN or inf values
|
|
test_meta_features = test_meta_features.fillna(0)
|
|
test_meta_features = test_meta_features.replace([np.inf, -np.inf], 0)
|
|
|
|
y_pred = meta_model.predict(test_meta_features)
|
|
accuracy = accuracy_score(y_test, y_pred)
|
|
report = classification_report(y_test, y_pred)
|
|
|
|
print(f"Meta-model test accuracy: {accuracy:.4f}")
|
|
print("Classification report:")
|
|
print(report)
|
|
|
|
self.meta_model = meta_model
|
|
return meta_model
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"Error training meta-model: {str(e)}")
|
|
print(f"Detailed traceback: {traceback.format_exc()}")
|
|
return None
|
|
|
|
def _get_ensemble_predictions(self, X):
|
|
"""
|
|
Get predictions from all base models with improved error handling
|
|
|
|
Parameters:
|
|
-----------
|
|
X : pd.DataFrame
|
|
Feature matrix
|
|
|
|
Returns:
|
|
--------
|
|
predictions : dict
|
|
Dictionary with predictions from each model
|
|
"""
|
|
predictions = {}
|
|
|
|
# Get predictions from each base model
|
|
for name, model_info in self.models.items():
|
|
try:
|
|
model = model_info['model']
|
|
features = model_info['features']
|
|
|
|
# Make sure we're only using features that exist in X
|
|
available_features = [f for f in features if f in X.columns]
|
|
if len(available_features) != len(features):
|
|
missing_features = set(features) - set(available_features)
|
|
print(f"Warning: {len(missing_features)} features missing for model {name}")
|
|
if len(missing_features) <= 5:
|
|
print(f"Missing features: {missing_features}")
|
|
|
|
# For regime models, only predict on matching regime data
|
|
if 'regime' in model_info and 'volatility_regime' in X.columns:
|
|
regime = model_info['regime']
|
|
regime_mask = X['volatility_regime'] == regime
|
|
|
|
# Skip if no data for this regime
|
|
if not regime_mask.any():
|
|
print(f"Skipping regime model {name} - no matching data")
|
|
# Initialize with zeros for this model for all possible classes
|
|
for i in range(5): # 5 classes (-2 to 2)
|
|
predictions[f"{name}_class_{i-2}"] = np.zeros(len(X))
|
|
continue
|
|
|
|
# Initialize predictions with zeros
|
|
proba = np.zeros((len(X), 5)) # Always use 5 classes (-2 to 2)
|
|
|
|
# Get predictions for matching regime
|
|
try:
|
|
regime_proba = model.predict_proba(X.loc[regime_mask, available_features])
|
|
|
|
# Handle case where model has fewer than 5 classes
|
|
if regime_proba.shape[1] < 5:
|
|
temp_proba = np.zeros((regime_proba.shape[0], 5))
|
|
# Map classes to correct positions in the 5-class array
|
|
classes = model.classes_ + 2 # Adjust to 0-4 indices
|
|
for i, cls_idx in enumerate(classes):
|
|
temp_proba[:, cls_idx] = regime_proba[:, i]
|
|
regime_proba = temp_proba
|
|
|
|
# Place predictions in the correct indices
|
|
regime_indices = np.where(regime_mask)[0]
|
|
proba[regime_indices] = regime_proba
|
|
|
|
except Exception as e:
|
|
print(f"Error predicting for regime {regime}: {str(e)}")
|
|
# Keep zeros for this regime
|
|
else:
|
|
# Regular model prediction on all data
|
|
try:
|
|
raw_proba = model.predict_proba(X[available_features])
|
|
|
|
# Initialize with zeros for all 5 classes
|
|
proba = np.zeros((len(X), 5))
|
|
|
|
# Handle case where model has fewer than 5 classes
|
|
if raw_proba.shape[1] < 5:
|
|
# Map classes to correct positions in the 5-class array
|
|
classes = model.classes_ + 2 # Adjust to 0-4 indices
|
|
for i, cls_idx in enumerate(classes):
|
|
proba[:, cls_idx] = raw_proba[:, i]
|
|
else:
|
|
proba = raw_proba
|
|
|
|
except Exception as e:
|
|
print(f"Error predicting with model {name}: {str(e)}")
|
|
# Keep zeros for this model
|
|
|
|
# Store class probabilities
|
|
for i in range(5): # 5 classes (-2 to 2)
|
|
predictions[f"{name}_class_{i-2}"] = proba[:, i]
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"Error processing model {name}: {str(e)}")
|
|
print(f"Traceback: {traceback.format_exc()}")
|
|
|
|
# Initialize with zeros for all classes
|
|
for i in range(5): # 5 classes (-2 to 2)
|
|
predictions[f"{name}_class_{i-2}"] = np.zeros(len(X))
|
|
|
|
return predictions
|
|
|
|
def fit(self, data, remove_cols=None):
|
|
"""
|
|
Complete model training pipeline with improved error handling
|
|
|
|
Parameters:
|
|
-----------
|
|
data : pd.DataFrame
|
|
DataFrame with features and target
|
|
remove_cols : list, optional
|
|
List of columns to remove (not used as features)
|
|
|
|
Returns:
|
|
--------
|
|
self : for method chaining
|
|
"""
|
|
print("Starting model training pipeline...")
|
|
|
|
try:
|
|
# Prepare data
|
|
X, y = self.prepare_data(data, remove_cols)
|
|
|
|
# Basic data validation
|
|
self.validate_data(X, y)
|
|
|
|
# Train/validation/test split
|
|
X_train, X_val, X_test, y_train, y_val, y_test = self._create_time_series_splits(X, y)
|
|
print(f"Data split: train={len(X_train)}, validation={len(X_val)}, test={len(X_test)}")
|
|
|
|
# Feature selection
|
|
try:
|
|
selected_features = self.select_features(X_train, y_train)
|
|
except Exception as e:
|
|
print(f"Feature selection error: {str(e)}")
|
|
print("Using all features as fallback")
|
|
self.selected_features = X_train.columns.tolist()
|
|
|
|
# Train base models
|
|
try:
|
|
self.train_base_models(X_train, y_train, X_val, y_val)
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"Base model training error: {str(e)}")
|
|
print(f"Traceback: {traceback.format_exc()}")
|
|
raise
|
|
|
|
# Train meta-model
|
|
try:
|
|
self.train_meta_model(X_val, y_val, X_test, y_test)
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"Meta-model training error: {str(e)}")
|
|
print(f"Traceback: {traceback.format_exc()}")
|
|
raise
|
|
|
|
# Save models
|
|
try:
|
|
self.save_models()
|
|
except Exception as e:
|
|
print(f"Model saving error: {str(e)}")
|
|
|
|
# Final evaluation
|
|
try:
|
|
self.evaluate(X_test, y_test)
|
|
except Exception as e:
|
|
print(f"Evaluation error: {str(e)}")
|
|
|
|
return self
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"Error in model training pipeline: {str(e)}")
|
|
print(f"Traceback: {traceback.format_exc()}")
|
|
return self
|
|
|
|
def predict(self, X):
|
|
"""
|
|
Generate predictions using the trained ensemble with improved error handling
|
|
|
|
Parameters:
|
|
-----------
|
|
X : pd.DataFrame
|
|
Feature matrix
|
|
|
|
Returns:
|
|
--------
|
|
predictions : pd.Series
|
|
Class predictions
|
|
probabilities : pd.DataFrame
|
|
Class probabilities
|
|
"""
|
|
try:
|
|
if self.meta_model is None:
|
|
raise ValueError("No trained meta-model available for prediction")
|
|
|
|
# Get base model predictions
|
|
base_predictions = self._get_ensemble_predictions(X)
|
|
meta_features = pd.DataFrame(base_predictions)
|
|
|
|
# Add volatility regime if available
|
|
if 'volatility_regime' in X.columns:
|
|
meta_features['volatility_regime'] = X['volatility_regime'].values
|
|
|
|
# Handle missing values
|
|
meta_features = meta_features.fillna(0)
|
|
meta_features = meta_features.replace([np.inf, -np.inf], 0)
|
|
|
|
# Generate predictions
|
|
class_proba = self.meta_model.predict_proba(meta_features)
|
|
|
|
# Convert to DataFrame with appropriate class labels
|
|
class_names = [f'class_{i-2}' for i in range(5)] # class_-2 to class_2
|
|
probabilities = pd.DataFrame(
|
|
class_proba,
|
|
columns=class_names,
|
|
index=X.index
|
|
)
|
|
|
|
# Get class predictions
|
|
raw_predictions = self.meta_model.predict(meta_features)
|
|
predictions = pd.Series(
|
|
raw_predictions,
|
|
index=X.index,
|
|
name='prediction'
|
|
)
|
|
|
|
return predictions, probabilities
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"Error in prediction: {str(e)}")
|
|
print(f"Traceback: {traceback.format_exc()}")
|
|
|
|
# Return empty predictions as fallback
|
|
empty_predictions = pd.Series(index=X.index, name='prediction')
|
|
empty_probabilities = pd.DataFrame(index=X.index)
|
|
|
|
return empty_predictions, empty_probabilities
|
|
|
|
def evaluate(self, X_test, y_test):
|
|
"""
|
|
Evaluate the model on test data
|
|
|
|
Parameters:
|
|
-----------
|
|
X_test : pd.DataFrame
|
|
Test feature matrix
|
|
y_test : pd.Series
|
|
Test target
|
|
|
|
Returns:
|
|
--------
|
|
metrics : dict
|
|
Dictionary of evaluation metrics
|
|
"""
|
|
print("\nEvaluating model performance...")
|
|
|
|
try:
|
|
# Generate predictions
|
|
y_pred, y_proba = self.predict(X_test)
|
|
|
|
# Check for unique classes in predictions and actual
|
|
print(f"Unique classes in test data: {np.unique(y_test)}")
|
|
print(f"Unique classes in predictions: {np.unique(y_pred)}")
|
|
|
|
# Calculate metrics
|
|
accuracy = accuracy_score(y_test, y_pred)
|
|
|
|
# Handle case where some classes might be missing
|
|
# Get all possible classes from both actual and predicted
|
|
all_classes = sorted(set(np.unique(y_test)) | set(np.unique(y_pred)))
|
|
|
|
# Generate report with all possible classes
|
|
report = classification_report(y_test, y_pred, labels=all_classes, output_dict=True)
|
|
|
|
# For confusion matrix, we need to use the same classes
|
|
conf_matrix = confusion_matrix(y_test, y_pred, labels=all_classes)
|
|
|
|
# Print results
|
|
print(f"Test accuracy: {accuracy:.4f}")
|
|
print("Classification report:")
|
|
print(classification_report(y_test, y_pred, labels=all_classes))
|
|
|
|
# Plot confusion matrix
|
|
plt.figure(figsize=(10, 8))
|
|
class_labels = [f"Class {cls}" for cls in all_classes]
|
|
sns.heatmap(
|
|
conf_matrix,
|
|
annot=True,
|
|
fmt='d',
|
|
cmap='Blues',
|
|
xticklabels=class_labels,
|
|
yticklabels=class_labels
|
|
)
|
|
plt.title('Confusion Matrix')
|
|
plt.xlabel('Predicted')
|
|
plt.ylabel('Actual')
|
|
plt.tight_layout()
|
|
plt.savefig(self.model_path / 'confusion_matrix.png')
|
|
|
|
# Create metrics dictionary
|
|
metrics = {
|
|
'accuracy': accuracy,
|
|
'report': report,
|
|
'confusion_matrix': conf_matrix
|
|
}
|
|
|
|
return metrics
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"Error in evaluation: {str(e)}")
|
|
print(f"Traceback: {traceback.format_exc()}")
|
|
|
|
# Return basic metrics
|
|
return {
|
|
'accuracy': 0.0,
|
|
'report': {},
|
|
'confusion_matrix': np.array([[0]])
|
|
}
|
|
|
|
def save_models(self):
|
|
"""Save trained models and metadata to disk"""
|
|
# Create model directory if it doesn't exist
|
|
self.model_path.mkdir(exist_ok=True)
|
|
|
|
# Save base models
|
|
for name, model_info in self.models.items():
|
|
model = model_info['model']
|
|
features = model_info['features']
|
|
|
|
# Save model
|
|
dump(model, self.model_path / f'{name}.joblib')
|
|
|
|
# Save feature list
|
|
with open(self.model_path / f'{name}_features.txt', 'w') as f:
|
|
f.write('\n'.join(features))
|
|
|
|
# Save meta-model
|
|
if self.meta_model is not None:
|
|
dump(self.meta_model, self.model_path / 'meta_model.joblib')
|
|
|
|
# Save feature selector or PCA
|
|
if self.feature_selector is not None:
|
|
dump(self.feature_selector, self.model_path / 'feature_selector.joblib')
|
|
|
|
if self.pca is not None:
|
|
dump(self.pca, self.model_path / 'pca.joblib')
|
|
|
|
# Save selected features
|
|
if self.selected_features is not None:
|
|
with open(self.model_path / 'selected_features.txt', 'w') as f:
|
|
f.write('\n'.join(self.selected_features))
|
|
|
|
# Save feature importance if available
|
|
if self.feature_importance is not None:
|
|
self.feature_importance.to_csv(self.model_path / 'feature_importance.csv', index=False)
|
|
|
|
print(f"Models and metadata saved to {self.model_path}")
|
|
|
|
def load_models(self):
|
|
"""Load trained models and metadata from disk"""
|
|
# Check if model directory exists
|
|
if not self.model_path.exists():
|
|
raise FileNotFoundError(f"Model directory {self.model_path} not found")
|
|
|
|
# Load base models
|
|
self.models = {}
|
|
for model_file in self.model_path.glob('base_model_*.joblib'):
|
|
name = model_file.stem
|
|
features_file = self.model_path / f'{name}_features.txt'
|
|
|
|
if features_file.exists():
|
|
with open(features_file, 'r') as f:
|
|
features = f.read().splitlines()
|
|
|
|
model = load(model_file)
|
|
self.models[name] = {
|
|
'model': model,
|
|
'features': features
|
|
}
|
|
|
|
# Load regime models
|
|
for model_file in self.model_path.glob('regime_model_*.joblib'):
|
|
name = model_file.stem
|
|
features_file = self.model_path / f'{name}_features.txt'
|
|
|
|
if features_file.exists():
|
|
with open(features_file, 'r') as f:
|
|
features = f.read().splitlines()
|
|
|
|
model = load(model_file)
|
|
regime = int(name.split('_')[-1])
|
|
self.models[name] = {
|
|
'model': model,
|
|
'features': features,
|
|
'regime': regime
|
|
}
|
|
|
|
# Load meta-model
|
|
meta_model_file = self.model_path / 'meta_model.joblib'
|
|
if meta_model_file.exists():
|
|
self.meta_model = load(meta_model_file)
|
|
|
|
# Load feature selector or PCA
|
|
feature_selector_file = self.model_path / 'feature_selector.joblib'
|
|
if feature_selector_file.exists():
|
|
self.feature_selector = load(feature_selector_file)
|
|
|
|
pca_file = self.model_path / 'pca.joblib'
|
|
if pca_file.exists():
|
|
self.pca = load(pca_file)
|
|
|
|
# Load selected features
|
|
selected_features_file = self.model_path / 'selected_features.txt'
|
|
if selected_features_file.exists():
|
|
with open(selected_features_file, 'r') as f:
|
|
self.selected_features = f.read().splitlines()
|
|
|
|
# Load feature importance
|
|
feature_importance_file = self.model_path / 'feature_importance.csv'
|
|
if feature_importance_file.exists():
|
|
self.feature_importance = pd.read_csv(feature_importance_file)
|
|
|
|
print(f"Loaded {len(self.models)} models and metadata from {self.model_path}")
|