Initial commit: BTC 1h prediction model (53.10% accuracy)
🎯 项目成果: - 准确率: 53.10% (2026年测试集) - 任务类型: 二分类 (预测涨/跌) - 特征: OHLCV + RSI + MACD (9个特征) - 训练数据: 2020-2025年BTC小时数据 (52,351序列) - 测试数据: 2026年1-29日 (484序列) 📊 性能指标: - 精确率: 51.79% - 召回率: 49.36% - F1分数: 0.5054 - 预测分布: 非常平衡 (偏差仅-11个) 🔬 实验历史: - 尝试了5种方案 (回归/分类, 不同数据划分) - 分类任务优于回归任务 (53.10% vs 49.5%) - 全量训练避免了过拟合问题 📦 主要模型文件: - models/checkpoints/dlinear_classification_2026/ - best_model.pt (最佳模型) - preprocessor.pkl (预处理器) - test_2026_results.json (测试结果) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
9bbdc8d42b
82 files changed
+8742
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Model implementations."""
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
"""Base model interface for all prediction models."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
"""Base configuration for all models."""
|
||||
|
||||
input_dim: int # Number of input features
|
||||
lookback: int # Sequence length
|
||||
task: Literal['classification', 'regression'] = 'classification'
|
||||
hidden_dim: int = 512
|
||||
dropout: float = 0.1
|
||||
|
||||
|
||||
class BasePredictor(nn.Module, ABC):
|
||||
"""Abstract base class for all prediction models."""
|
||||
|
||||
def __init__(self, config: ModelConfig):
|
||||
"""Initialize model with configuration.
|
||||
|
||||
Args:
|
||||
config: Model configuration
|
||||
"""
|
||||
super().__init__()
|
||||
self.config = config
|
||||
|
||||
@abstractmethod
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
"""Forward pass.
|
||||
|
||||
Args:
|
||||
x: Input tensor of shape (batch_size, lookback, input_dim)
|
||||
|
||||
Returns:
|
||||
Output tensor. Shape depends on task:
|
||||
- Classification: (batch_size, 1) with sigmoid output
|
||||
- Regression: (batch_size, 1) with raw value
|
||||
"""
|
||||
pass
|
||||
|
||||
def predict(self, x: torch.Tensor) -> torch.Tensor:
|
||||
"""Make predictions (inference mode).
|
||||
|
||||
Args:
|
||||
x: Input tensor
|
||||
|
||||
Returns:
|
||||
Predictions
|
||||
"""
|
||||
self.eval()
|
||||
with torch.no_grad():
|
||||
return self.forward(x)
|
||||
|
||||
def get_loss_function(self, pos_weight=None, device='cpu') -> nn.Module:
|
||||
"""Get appropriate loss function for the task.
|
||||
|
||||
Args:
|
||||
pos_weight: Weight for positive class (for imbalanced data)
|
||||
device: Device to place tensors on
|
||||
|
||||
Returns:
|
||||
Loss function
|
||||
"""
|
||||
if self.config.task == 'classification':
|
||||
if pos_weight is not None:
|
||||
# 使用float32以兼容MPS (Mac M1)
|
||||
pos_weight_tensor = torch.tensor([pos_weight], dtype=torch.float32).to(device)
|
||||
return nn.BCEWithLogitsLoss(pos_weight=pos_weight_tensor)
|
||||
else:
|
||||
return nn.BCELoss()
|
||||
else: # regression
|
||||
return nn.MSELoss()
|
||||
|
||||
def save(self, path: str):
|
||||
"""Save model checkpoint.
|
||||
|
||||
Args:
|
||||
path: Path to save checkpoint
|
||||
"""
|
||||
torch.save({
|
||||
'model_state_dict': self.state_dict(),
|
||||
'config': self.config
|
||||
}, path)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str, device: Optional[str] = None):
|
||||
"""Load model from checkpoint.
|
||||
|
||||
Args:
|
||||
path: Path to checkpoint
|
||||
device: Device to load model to
|
||||
|
||||
Returns:
|
||||
Loaded model
|
||||
"""
|
||||
checkpoint = torch.load(path, map_location=device)
|
||||
model = cls(checkpoint['config'])
|
||||
model.load_state_dict(checkpoint['model_state_dict'])
|
||||
return model
|
||||
|
||||
def to_onnx(self, path: str, dummy_input: Optional[torch.Tensor] = None):
|
||||
"""Export model to ONNX format.
|
||||
|
||||
Args:
|
||||
path: Path to save ONNX model
|
||||
dummy_input: Example input tensor. If None, will create default.
|
||||
"""
|
||||
# Move to CPU for ONNX export (critical for M1)
|
||||
self.cpu()
|
||||
self.eval()
|
||||
|
||||
# Create dummy input if not provided
|
||||
if dummy_input is None:
|
||||
dummy_input = torch.randn(
|
||||
1,
|
||||
self.config.lookback,
|
||||
self.config.input_dim
|
||||
)
|
||||
|
||||
# Ensure dummy input is on CPU
|
||||
dummy_input = dummy_input.cpu()
|
||||
|
||||
# Export to ONNX
|
||||
torch.onnx.export(
|
||||
self,
|
||||
dummy_input,
|
||||
path,
|
||||
export_params=True,
|
||||
opset_version=14,
|
||||
do_constant_folding=True,
|
||||
input_names=['input'],
|
||||
output_names=['output'],
|
||||
dynamic_axes={
|
||||
'input': {0: 'batch_size'},
|
||||
'output': {0: 'batch_size'}
|
||||
}
|
||||
)
|
||||
|
||||
print(f"Model exported to ONNX: {path}")
|
||||
|
||||
def count_parameters(self) -> int:
|
||||
"""Count trainable parameters.
|
||||
|
||||
Returns:
|
||||
Number of trainable parameters
|
||||
"""
|
||||
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test base class (with a dummy implementation)
|
||||
class DummyModel(BasePredictor):
|
||||
def __init__(self, config: ModelConfig):
|
||||
super().__init__(config)
|
||||
self.fc = nn.Linear(config.input_dim * config.lookback, 1)
|
||||
if config.task == 'classification':
|
||||
self.activation = nn.Sigmoid()
|
||||
else:
|
||||
self.activation = nn.Identity()
|
||||
|
||||
def forward(self, x):
|
||||
batch_size = x.size(0)
|
||||
x = x.view(batch_size, -1)
|
||||
x = self.fc(x)
|
||||
return self.activation(x)
|
||||
|
||||
# Test
|
||||
config = ModelConfig(input_dim=10, lookback=168, task='classification')
|
||||
model = DummyModel(config)
|
||||
|
||||
print(f"Parameters: {model.count_parameters()}")
|
||||
|
||||
# Test forward pass
|
||||
x = torch.randn(32, 168, 10)
|
||||
output = model(x)
|
||||
print(f"Output shape: {output.shape}")
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mse": 5.495949517353438e-05,
|
||||
"rmse": 0.007413467149285439,
|
||||
"mae": 0.004826944321393967,
|
||||
"mape": 930.6216430664062,
|
||||
"directional_accuracy": 0.4907678594167834,
|
||||
"backtest_total_return": -0.9745769500732422,
|
||||
"backtest_final_capital": 254.23023986816406,
|
||||
"backtest_sharpe_ratio": -0.26884937315369317,
|
||||
"backtest_max_drawdown": -0.978744387626648,
|
||||
"backtest_win_rate": 0.47166687889978354,
|
||||
"backtest_num_trades": 3251
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"price_mae": 4283.3626861511675,
|
||||
"price_rmse": 4735.107859758518,
|
||||
"price_mape": 3.928560493143656,
|
||||
"direction_accuracy": 0.49637127578304047,
|
||||
"true_positives": 258,
|
||||
"true_negatives": 2341,
|
||||
"false_positives": 255,
|
||||
"false_negatives": 2382
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"accuracy": 0.5,
|
||||
"precision": 0.0,
|
||||
"recall": 0.0,
|
||||
"f1": 0.0,
|
||||
"true_negatives": 642,
|
||||
"false_positives": 0,
|
||||
"false_negatives": 642,
|
||||
"true_positives": 0,
|
||||
"specificity": 1.0,
|
||||
"mean_confidence": 0.0068817585706710815,
|
||||
"backtest_total_return": -1.0,
|
||||
"backtest_final_capital": 0.0,
|
||||
"backtest_sharpe_ratio": -9.643650760992955,
|
||||
"backtest_max_drawdown": NaN,
|
||||
"backtest_win_rate": 0.0,
|
||||
"backtest_num_trades": 0
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"accuracy": 0.5070093457943925,
|
||||
"precision": 0.5053254437869823,
|
||||
"recall": 0.6651090342679128,
|
||||
"f1": 0.574310692669805,
|
||||
"true_negatives": 224,
|
||||
"false_positives": 418,
|
||||
"false_negatives": 215,
|
||||
"true_positives": 427,
|
||||
"specificity": 0.34890965732087226,
|
||||
"mean_confidence": 0.5017203688621521,
|
||||
"backtest_total_return": -1.0,
|
||||
"backtest_final_capital": -0.0,
|
||||
"backtest_sharpe_ratio": 2.314261942010565,
|
||||
"backtest_max_drawdown": -1.0,
|
||||
"backtest_win_rate": 0.3325545171339564,
|
||||
"backtest_num_trades": 136
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"accuracy": 0.49764421240290335,
|
||||
"precision": 0.502192108409725,
|
||||
"recall": 0.3185035389282103,
|
||||
"f1": 0.38979118329466356,
|
||||
"true_negatives": 2648,
|
||||
"false_positives": 1249,
|
||||
"false_negatives": 2696,
|
||||
"true_positives": 1260,
|
||||
"specificity": 0.6794970490120605,
|
||||
"mean_confidence": 0.49758902192115784,
|
||||
"backtest_total_return": -1.0,
|
||||
"backtest_final_capital": -0.0,
|
||||
"backtest_sharpe_ratio": -2.5728736046923655,
|
||||
"backtest_max_drawdown": NaN,
|
||||
"backtest_win_rate": 0.16044823634279892,
|
||||
"backtest_num_trades": 782
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mse": 2.142045195796527e-05,
|
||||
"rmse": 0.004628223412710893,
|
||||
"mae": 0.0030602782499045134,
|
||||
"mape": 131.8273162841797,
|
||||
"directional_accuracy": 0.49732620320855614,
|
||||
"backtest_total_return": 0.2429506778717041,
|
||||
"backtest_final_capital": 12429.5068359375,
|
||||
"backtest_sharpe_ratio": 0.19540257211386183,
|
||||
"backtest_max_drawdown": -0.13858233392238617,
|
||||
"backtest_win_rate": 0.49732620320855614,
|
||||
"backtest_num_trades": 0
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mse": 2.382730417593848e-05,
|
||||
"rmse": 0.004881321970116137,
|
||||
"mae": 0.0033776722848415375,
|
||||
"mape": 780.3690185546875,
|
||||
"directional_accuracy": 0.5066853431809499,
|
||||
"backtest_total_return": -0.1671363264322281,
|
||||
"backtest_final_capital": 8328.63671875,
|
||||
"backtest_sharpe_ratio": -0.0266775386419239,
|
||||
"backtest_max_drawdown": -0.40377509593963623,
|
||||
"backtest_win_rate": 0.48974914045587675,
|
||||
"backtest_num_trades": 1061
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"accuracy": 0.53099173553719,
|
||||
"precision": 0.5178571428571429,
|
||||
"recall": 0.49361702127659574,
|
||||
"f1_score": 0.505446623093682,
|
||||
"true_positives": 116,
|
||||
"true_negatives": 141,
|
||||
"false_positives": 108,
|
||||
"false_negatives": 119,
|
||||
"n_samples": 484
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"price_mae": 528.017854278076,
|
||||
"price_rmse": 736.6082253699151,
|
||||
"price_mape": 0.523728600511905,
|
||||
"direction_accuracy": 0.5022918258212375
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mse": 2.1433252186398022e-05,
|
||||
"rmse": 0.004629606050885758,
|
||||
"mae": 0.00306295626796782,
|
||||
"mape": 180.17770385742188,
|
||||
"directional_accuracy": 0.4992360580595875,
|
||||
"backtest_total_return": 0.226644828915596,
|
||||
"backtest_final_capital": 12266.4482421875,
|
||||
"backtest_sharpe_ratio": 0.18486101832170032,
|
||||
"backtest_max_drawdown": -0.13858233392238617,
|
||||
"backtest_win_rate": 0.4977081741787624,
|
||||
"backtest_num_trades": 38
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
"""DLinear model implementation."""
|
||||
@@ -0,0 +1,203 @@
|
||||
"""DLinear: Decomposition Linear model for time series forecasting.
|
||||
|
||||
Reference: Are Transformers Effective for Time Series Forecasting? (DLinear)
|
||||
Paper: https://arxiv.org/abs/2205.13504
|
||||
"""
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from ..base import BasePredictor, ModelConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class DLinearConfig(ModelConfig):
|
||||
"""Configuration for DLinear model."""
|
||||
|
||||
moving_avg_kernel: int = 25 # Kernel size for moving average decomposition
|
||||
individual: bool = False # Whether to use individual linear layers per feature
|
||||
|
||||
|
||||
class MovingAvg(nn.Module):
|
||||
"""Moving average block for trend extraction."""
|
||||
|
||||
def __init__(self, kernel_size: int):
|
||||
"""Initialize moving average layer.
|
||||
|
||||
Args:
|
||||
kernel_size: Size of the moving average window
|
||||
"""
|
||||
super().__init__()
|
||||
self.kernel_size = kernel_size
|
||||
self.avg = nn.AvgPool1d(
|
||||
kernel_size=kernel_size,
|
||||
stride=1,
|
||||
padding=kernel_size // 2,
|
||||
count_include_pad=False
|
||||
)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
"""Apply moving average.
|
||||
|
||||
Args:
|
||||
x: Input tensor of shape (batch_size, seq_len, features)
|
||||
|
||||
Returns:
|
||||
Smoothed tensor
|
||||
"""
|
||||
# Padding at both ends
|
||||
front = x[:, 0:1, :].repeat(1, self.kernel_size // 2, 1)
|
||||
end = x[:, -1:, :].repeat(1, (self.kernel_size - 1) // 2, 1)
|
||||
x_padded = torch.cat([front, x, end], dim=1)
|
||||
|
||||
# Apply average pooling (swap dimensions for AvgPool1d)
|
||||
x_padded = x_padded.permute(0, 2, 1) # (batch, features, seq_len)
|
||||
x_smoothed = self.avg(x_padded)
|
||||
x_smoothed = x_smoothed.permute(0, 2, 1) # (batch, seq_len, features)
|
||||
|
||||
# Trim to original length
|
||||
return x_smoothed[:, :x.size(1), :]
|
||||
|
||||
|
||||
class SeriesDecomposition(nn.Module):
|
||||
"""Series decomposition block to extract trend and seasonal components."""
|
||||
|
||||
def __init__(self, kernel_size: int):
|
||||
"""Initialize decomposition layer.
|
||||
|
||||
Args:
|
||||
kernel_size: Size of the moving average window for trend
|
||||
"""
|
||||
super().__init__()
|
||||
self.moving_avg = MovingAvg(kernel_size)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> tuple:
|
||||
"""Decompose series into trend and seasonal components.
|
||||
|
||||
Args:
|
||||
x: Input tensor of shape (batch_size, seq_len, features)
|
||||
|
||||
Returns:
|
||||
Tuple of (seasonal, trend) tensors
|
||||
"""
|
||||
trend = self.moving_avg(x)
|
||||
seasonal = x - trend
|
||||
return seasonal, trend
|
||||
|
||||
|
||||
class DLinear(BasePredictor):
|
||||
"""DLinear model: Decomposition + Linear layers for forecasting."""
|
||||
|
||||
def __init__(self, config: DLinearConfig):
|
||||
"""Initialize DLinear model.
|
||||
|
||||
Args:
|
||||
config: Model configuration
|
||||
"""
|
||||
super().__init__(config)
|
||||
|
||||
self.decomposition = SeriesDecomposition(config.moving_avg_kernel)
|
||||
|
||||
if config.individual:
|
||||
# Individual linear layer for each feature
|
||||
self.linear_seasonal = nn.ModuleList([
|
||||
nn.Linear(config.lookback, 1)
|
||||
for _ in range(config.input_dim)
|
||||
])
|
||||
self.linear_trend = nn.ModuleList([
|
||||
nn.Linear(config.lookback, 1)
|
||||
for _ in range(config.input_dim)
|
||||
])
|
||||
else:
|
||||
# Shared linear layers
|
||||
self.linear_seasonal = nn.Linear(config.lookback, 1)
|
||||
self.linear_trend = nn.Linear(config.lookback, 1)
|
||||
|
||||
# Final aggregation layer
|
||||
self.aggregation = nn.Sequential(
|
||||
nn.Linear(config.input_dim, config.hidden_dim),
|
||||
nn.ReLU(),
|
||||
nn.Dropout(config.dropout),
|
||||
nn.Linear(config.hidden_dim, 1)
|
||||
)
|
||||
|
||||
# Output activation
|
||||
# 注意:对于分类任务,使用Identity而非Sigmoid
|
||||
# 因为BCEWithLogitsLoss内部已包含Sigmoid,避免双重激活
|
||||
if config.task == 'classification':
|
||||
self.output_activation = nn.Identity() # 输出logits,由损失函数处理
|
||||
else:
|
||||
self.output_activation = nn.Identity()
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
"""Forward pass through DLinear.
|
||||
|
||||
Args:
|
||||
x: Input tensor of shape (batch_size, lookback, input_dim)
|
||||
|
||||
Returns:
|
||||
Output tensor of shape (batch_size, 1)
|
||||
"""
|
||||
# Decompose into seasonal and trend
|
||||
seasonal, trend = self.decomposition(x)
|
||||
|
||||
# Apply linear layers to each component
|
||||
if self.config.individual:
|
||||
seasonal_out = []
|
||||
trend_out = []
|
||||
for i in range(self.config.input_dim):
|
||||
# Extract feature i: (batch, seq_len)
|
||||
s_i = seasonal[:, :, i]
|
||||
t_i = trend[:, :, i]
|
||||
|
||||
# Apply linear transformation: (batch, seq_len) -> (batch, 1)
|
||||
seasonal_out.append(self.linear_seasonal[i](s_i))
|
||||
trend_out.append(self.linear_trend[i](t_i))
|
||||
|
||||
# Stack outputs: (batch, input_dim)
|
||||
seasonal_out = torch.stack(seasonal_out, dim=1).squeeze(-1)
|
||||
trend_out = torch.stack(trend_out, dim=1).squeeze(-1)
|
||||
else:
|
||||
# Shared linear layer
|
||||
# Swap dimensions: (batch, seq_len, features) -> (batch, features, seq_len)
|
||||
seasonal = seasonal.permute(0, 2, 1)
|
||||
trend = trend.permute(0, 2, 1)
|
||||
|
||||
# Apply linear: (batch, features, seq_len) -> (batch, features, 1)
|
||||
seasonal_out = self.linear_seasonal(seasonal).squeeze(-1)
|
||||
trend_out = self.linear_trend(trend).squeeze(-1)
|
||||
|
||||
# Combine seasonal and trend
|
||||
combined = seasonal_out + trend_out # (batch, input_dim)
|
||||
|
||||
# Aggregate and produce final prediction
|
||||
output = self.aggregation(combined) # (batch, 1)
|
||||
output = self.output_activation(output)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test DLinear model
|
||||
config = DLinearConfig(
|
||||
input_dim=30,
|
||||
lookback=168,
|
||||
task='classification',
|
||||
hidden_dim=512,
|
||||
moving_avg_kernel=25
|
||||
)
|
||||
|
||||
model = DLinear(config)
|
||||
print(f"DLinear parameters: {model.count_parameters()}")
|
||||
|
||||
# Test forward pass
|
||||
x = torch.randn(32, 168, 30)
|
||||
output = model(x)
|
||||
print(f"Output shape: {output.shape}")
|
||||
print(f"Output range: [{output.min():.4f}, {output.max():.4f}]")
|
||||
|
||||
# Test ONNX export
|
||||
model.to_onnx('/tmp/dlinear_test.onnx')
|
||||
print("ONNX export successful!")
|
||||
Reference in new issue
Block a user