{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"pygments_lexer":"ipython3","nbconvert_exporter":"python","version":"3.6.4","file_extension":".py","codemirror_mode":{"name":"ipython","version":3},"name":"python","mimetype":"text/x-python"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":96164,"databundleVersionId":11418275,"sourceType":"competition"}],"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# This Python 3 environment comes with many helpful analytics libraries installed\n# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python\n# For example, here's several helpful packages to load\n\nimport numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\n\n# Input data files are available in the read-only \"../input/\" directory\n# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory\n\nimport os\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))\n\n# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using \"Save & Run All\" \n# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Complete GANDALF Implementation for Crypto Market Prediction\n# No placeholders - Full working code with all components\n\nimport os\nimport random\nimport numpy as np\nimport pandas as pd\nimport polars as pl\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom sklearn.model_selection import train_test_split, KFold, TimeSeriesSplit\nfrom sklearn.preprocessing import StandardScaler, RobustScaler, QuantileTransformer\nfrom sklearn.feature_selection import mutual_info_regression, SelectKBest\nfrom sklearn.metrics import mean_squared_error, mean_absolute_error\nfrom tqdm import tqdm\nimport gc\nfrom typing import List, Tuple, Dict, Optional, Any\nimport math\nimport json\nfrom datetime import datetime\nfrom pathlib import Path\nimport warnings\nwarnings.filterwarnings(\"ignore\")\n\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import DataLoader, TensorDataset\nfrom torch.optim.lr_scheduler import ReduceLROnPlateau, CosineAnnealingWarmRestarts\nimport torch.nn.functional as F\nfrom torch.cuda.amp import autocast, GradScaler\n\nfrom scipy.stats import pearsonr, spearmanr, kurtosis, skew\nfrom scipy.special import erfinv\n\n# Set device\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Using device: {device}\")\n\n# Create results directory\nRESULTS_DIR = Path(\"gandalf_results\")\nRESULTS_DIR.mkdir(exist_ok=True)\n\n# =========================\n# Configuration\n# =========================\nMARKET_FEATURES = [\"bid_qty\", \"ask_qty\", \"buy_qty\", \"sell_qty\", \"volume\"]\n\nPROPRIETARY_FEATURES = [\n    \"X863\", \"X856\", \"X598\", \"X862\", \"X385\", \"X852\", \"X603\", \"X860\", \"X674\",\n    \"X415\", \"X345\", \"X855\", \"X174\", \"X302\", \"X178\", \"X168\", \"X612\",\n    \"X425\", \"X132\", \"X691\", \"X593\", \"X377\", \"X285\", \"X126\", \"X419\", \"X604\",\n    \"X84\", \"X138\", \"X413\", \"X291\", \"X40\", \"X123\", \"X81\", \"X853\", \"X854\",\n    \"X777\", \"X219\", \"X776\", \"X180\", \"X781\", \"X445\", \"X444\", \"X384\", \"X466\",\n    \"X95\", \"X583\", \"X272\", \"X137\", \"X533\", \"X758\", \"X279\", \"X297\",\n    \"X21\", \"X20\", \"X28\", \"X29\", \"X19\", \"X27\", \"X22\", \"X198\", \"X89\", \"X90\",\n    \"X98\", \"X96\", \"X97\", \"X383\", \"X427\", \"X451\", \"X283\",\n    \"X753\", \"X497\", \"X748\", \"X820\", \"X566\", \"X535\", \"X394\", \"X618\",\n    \"X429\", \"X381\", \"X387\", \"X890\", \"X752\", \"X375\", \"X68\", \"X152\",\n    \"X110\", \"X850\", \"X851\", \"X481\", \"X321\", \"X363\", \"X405\", \"X492\",\n    \"X888\", \"X421\", \"X333\", \"X817\", \"X586\", \"X292\", \"X344\", \"X532\"\n]\n\nSELECTED_FEATURES = MARKET_FEATURES + PROPRIETARY_FEATURES + [\"label\"]\n\n# =========================\n# Feature Engineering\n# =========================\ndef add_market_features(df):\n    \"\"\"Add market microstructure features\"\"\"\n    eps = 1e-10\n    \n    # Core features\n    df['bid_ask_spread'] = np.abs(df['bid_qty'] - df['ask_qty'])\n    df['bid_ask_imbalance'] = (df['bid_qty'] - df['ask_qty']) / (df['bid_qty'] + df['ask_qty'] + eps)\n    df['order_flow_imbalance'] = (df['buy_qty'] - df['sell_qty']) / (df['buy_qty'] + df['sell_qty'] + eps)\n    df['net_order_flow'] = df['buy_qty'] - df['sell_qty']\n    df['total_depth'] = df['bid_qty'] + df['ask_qty']\n    df['depth_imbalance'] = (df['bid_qty'] - df['ask_qty']) / (df['total_depth'] + eps)\n    \n    # Volume features\n    df['log_volume'] = np.log1p(df['volume'])\n    df['sqrt_volume'] = np.sqrt(df['volume'])\n    df['volume_depth_ratio'] = df['volume'] / (df['total_depth'] + eps)\n    df['normalized_net_flow'] = df['net_order_flow'] / (df['volume'] + eps)\n    \n    # Liquidity metrics\n    df['liquidity_ratio'] = (df['bid_qty'] + df['ask_qty']) / (df['volume'] + eps)\n    df['aggressive_flow_ratio'] = (df['buy_qty'] + df['sell_qty']) / (df['total_depth'] + eps)\n    df['liquidity_consumption'] = (df['buy_qty'] + df['sell_qty']) / (df['bid_qty'] + df['ask_qty'] + eps)\n    \n    # Price pressure\n    df['buying_pressure'] = df['buy_qty'] / (df['volume'] + eps)\n    df['selling_pressure'] = df['sell_qty'] / (df['volume'] + eps)\n    df['kyle_lambda'] = np.abs(df['net_order_flow']) / (df['volume'] + eps)\n    \n    # Market stress\n    df['market_stress'] = df['volume'] / (df['total_depth'] + eps) * np.abs(df['order_flow_imbalance'])\n    df['volatility_proxy'] = np.abs(df['net_order_flow']) / (df['total_depth'] + eps) * df['volume']\n    \n    # Replace infinities and NaNs\n    df = df.replace([np.inf, -np.inf], np.nan)\n    df = df.fillna(0)\n    \n    return df\n\ndef set_seed(seed=42):\n    \"\"\"Set random seeds\"\"\"\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\n# =========================\n# GANDALF Components\n# =========================\nclass DifferentiableDecisionTree(nn.Module):\n    \"\"\"Soft decision tree for GANDALF\"\"\"\n    def __init__(self, input_dim, depth, temperature=1.0):\n        super().__init__()\n        self.depth = depth\n        self.n_leaves = 2 ** depth\n        \n        # Internal nodes\n        self.internal_nodes = nn.ModuleList()\n        for i in range(2 ** depth - 1):\n            self.internal_nodes.append(nn.Linear(input_dim, 1))\n        \n        # Leaf values\n        self.leaf_values = nn.Parameter(torch.zeros(self.n_leaves))\n        nn.init.normal_(self.leaf_values, mean=0, std=0.01)\n        \n        # Temperature\n        self.temperature = nn.Parameter(torch.tensor(temperature))\n        \n    def forward(self, x):\n        batch_size = x.size(0)\n        device = x.device\n        \n        # Temperature with lower bound\n        temp = F.softplus(self.temperature) + 0.1\n        \n        # Path probabilities\n        path_probs = torch.ones(batch_size, 1, device=device)\n        \n        # Traverse tree\n        for level in range(self.depth):\n            n_nodes = 2 ** level\n            next_path_probs = []\n            \n            for node in range(n_nodes):\n                node_idx = 2 ** level - 1 + node\n                \n                if node_idx < len(self.internal_nodes):\n                    # Split decision\n                    logit = self.internal_nodes[node_idx](x).squeeze(-1)\n                    split_prob = torch.sigmoid(logit / temp)\n                    \n                    # Update paths\n                    if node < path_probs.size(1):\n                        current_prob = path_probs[:, node:node+1]\n                        next_path_probs.append(current_prob * (1 - split_prob).unsqueeze(1))\n                        next_path_probs.append(current_prob * split_prob.unsqueeze(1))\n            \n            if next_path_probs:\n                path_probs = torch.cat(next_path_probs, dim=1)\n        \n        # Ensure correct number of leaves\n        if path_probs.size(1) > self.n_leaves:\n            path_probs = path_probs[:, :self.n_leaves]\n        \n        # Weighted sum\n        output = torch.sum(path_probs * self.leaf_values.unsqueeze(0), dim=1)\n        \n        return output\n\nclass GatingNetwork(nn.Module):\n    \"\"\"Gating network for tree selection\"\"\"\n    def __init__(self, input_dim, n_trees, hidden_dim=128, dropout=0.3):\n        super().__init__()\n        \n        self.network = nn.Sequential(\n            nn.Linear(input_dim, hidden_dim),\n            nn.LayerNorm(hidden_dim),\n            nn.GELU(),\n            nn.Dropout(dropout),\n            nn.Linear(hidden_dim, hidden_dim // 2),\n            nn.LayerNorm(hidden_dim // 2),\n            nn.GELU(),\n            nn.Dropout(dropout),\n            nn.Linear(hidden_dim // 2, n_trees)\n        )\n        \n    def forward(self, x):\n        gates = self.network(x)\n        return F.softmax(gates, dim=-1)\n\nclass GANDALF(nn.Module):\n    \"\"\"GANDALF: Gated Additive Neural Decision Additive Forest\"\"\"\n    def __init__(self, config):\n        super().__init__()\n        \n        self.n_trees = config['n_trees']\n        self.tree_depth = config['tree_depth']\n        self.input_dim = config['input_dim']\n        \n        # Feature embedding\n        embed_layers = []\n        prev_dim = self.input_dim\n        \n        for dim in config['embed_dims']:\n            embed_layers.extend([\n                nn.Linear(prev_dim, dim),\n                nn.LayerNorm(dim),\n                nn.GELU(),\n                nn.Dropout(config['feature_dropout'])\n            ])\n            prev_dim = dim\n            \n        self.feature_embedder = nn.Sequential(*embed_layers)\n        self.embed_dim = prev_dim\n        \n        # Decision trees\n        self.trees = nn.ModuleList()\n        for i in range(self.n_trees):\n            # Vary depth for diversity\n            tree_depth = self.tree_depth + (i % 3 - 1)\n            tree_depth = max(2, tree_depth)\n            \n            self.trees.append(\n                DifferentiableDecisionTree(\n                    self.embed_dim,\n                    tree_depth,\n                    temperature=config['tree_temperature']\n                )\n            )\n        \n        # Gating network\n        self.gating_network = GatingNetwork(\n            self.input_dim,\n            self.n_trees,\n            config['gate_hidden_dim'],\n            config['gate_dropout']\n        )\n        \n        # Optional neural head\n        if config.get('use_nn_head', True):\n            head_layers = []\n            prev_dim = self.input_dim\n            \n            for dim in config['head_dims']:\n                head_layers.extend([\n                    nn.Linear(prev_dim, dim),\n                    nn.LayerNorm(dim),\n                    nn.GELU(),\n                    nn.Dropout(config['head_dropout'])\n                ])\n                prev_dim = dim\n                \n            head_layers.append(nn.Linear(prev_dim, 1))\n            self.nn_head = nn.Sequential(*head_layers)\n            \n            # Combination weight\n            self.combination_weight = nn.Parameter(torch.tensor(0.5))\n        else:\n            self.nn_head = None\n            \n    def forward(self, x):\n        # Embed features\n        embedded = self.feature_embedder(x)\n        \n        # Get tree outputs\n        tree_outputs = []\n        for tree in self.trees:\n            output = tree(embedded)\n            tree_outputs.append(output)\n        \n        tree_outputs = torch.stack(tree_outputs, dim=1)\n        \n        # Get gates\n        gates = self.gating_network(x)\n        \n        # Weighted combination\n        forest_output = torch.sum(gates * tree_outputs, dim=1, keepdim=True)\n        \n        # Combine with neural head if available\n        if self.nn_head is not None:\n            nn_output = self.nn_head(x)\n            weight = torch.sigmoid(self.combination_weight)\n            final_output = weight * forest_output + (1 - weight) * nn_output\n        else:\n            final_output = forest_output\n            \n        return final_output\n\n# =========================\n# Training Functions\n# =========================\ndef train_gandalf(model, train_loader, val_loader, config, device):\n    \"\"\"Train GANDALF model\"\"\"\n    \n    # Loss and optimizer\n    criterion = nn.HuberLoss(delta=config.get('huber_delta', 1.0))\n    optimizer = optim.AdamW(\n        model.parameters(),\n        lr=config['learning_rate'],\n        weight_decay=config['weight_decay']\n    )\n    \n    # Scheduler\n    scheduler = ReduceLROnPlateau(optimizer, mode='max', factor=0.5, patience=5, min_lr=1e-6)\n    \n    # Training settings\n    best_val_pearson = -np.inf\n    patience_counter = 0\n    patience = config.get('patience', 10)\n    num_epochs = config.get('num_epochs', 50)\n    \n    # Mixed precision\n    use_amp = config.get('use_amp', True) and device.type == 'cuda'\n    scaler = GradScaler() if use_amp else None\n    \n    for epoch in range(num_epochs):\n        # Training\n        model.train()\n        train_loss = 0.0\n        train_batches = 0\n        \n        progress_bar = tqdm(train_loader, desc=f\"Epoch {epoch+1}/{num_epochs}\")\n        for inputs, targets in progress_bar:\n            inputs, targets = inputs.to(device), targets.to(device)\n            \n            # Add noise\n            if config.get('noise_factor', 0) > 0:\n                noise = torch.randn_like(inputs) * config['noise_factor']\n                inputs = inputs + noise\n            \n            optimizer.zero_grad()\n            \n            if use_amp:\n                with autocast():\n                    outputs = model(inputs)\n                    loss = criterion(outputs, targets)\n                \n                scaler.scale(loss).backward()\n                \n                # Gradient clipping\n                scaler.unscale_(optimizer)\n                torch.nn.utils.clip_grad_norm_(model.parameters(), config.get('grad_clip', 1.0))\n                \n                scaler.step(optimizer)\n                scaler.update()\n            else:\n                outputs = model(inputs)\n                loss = criterion(outputs, targets)\n                \n                loss.backward()\n                torch.nn.utils.clip_grad_norm_(model.parameters(), config.get('grad_clip', 1.0))\n                optimizer.step()\n            \n            train_loss += loss.item()\n            train_batches += 1\n            \n            progress_bar.set_postfix({'loss': f'{loss.item():.4f}'})\n        \n        # Validation\n        model.eval()\n        val_loss = 0.0\n        val_preds = []\n        val_targets = []\n        \n        with torch.no_grad():\n            for inputs, targets in val_loader:\n                inputs, targets = inputs.to(device), targets.to(device)\n                \n                outputs = model(inputs)\n                loss = criterion(outputs, targets)\n                \n                val_loss += loss.item()\n                val_preds.extend(outputs.cpu().numpy().flatten())\n                val_targets.extend(targets.cpu().numpy().flatten())\n        \n        # Metrics\n        avg_train_loss = train_loss / train_batches\n        avg_val_loss = val_loss / len(val_loader)\n        val_pearson = pearsonr(val_targets, val_preds)[0]\n        val_spearman = spearmanr(val_targets, val_preds)[0]\n        \n        print(f\"\\nTrain Loss: {avg_train_loss:.4f}, Val Loss: {avg_val_loss:.4f}\")\n        print(f\"Val Pearson: {val_pearson:.4f}, Val Spearman: {val_spearman:.4f}\")\n        \n        # Update scheduler\n        scheduler.step(val_pearson)\n        \n        # Save best model\n        if val_pearson > best_val_pearson:\n            best_val_pearson = val_pearson\n            patience_counter = 0\n            torch.save(model.state_dict(), RESULTS_DIR / \"best_gandalf.pt\")\n            print(f\"✅ New best model saved! Pearson: {best_val_pearson:.4f}\")\n        else:\n            patience_counter += 1\n            if patience_counter >= patience:\n                print(f\"Early stopping triggered after {epoch+1} epochs\")\n                break\n    \n    # Load best model\n    model.load_state_dict(torch.load(RESULTS_DIR / \"best_gandalf.pt\"))\n    \n    return model, best_val_pearson\n\n# =========================\n# Main Execution\n# =========================\ndef main():\n    print(\"=== GANDALF for Crypto Market Prediction ===\")\n    \n    # Set seed\n    set_seed(42)\n    \n    # Load data\n    print(\"\\nLoading data...\")\n    train = pl.scan_parquet(\"/kaggle/input/drw-crypto-market-prediction/train.parquet\")\n    train = train.select(SELECTED_FEATURES).collect().to_pandas()\n    \n    print(f\"Initial shape: {train.shape}\")\n    \n    # Use recent data\n    train_size = int(0.85 * len(train))\n    train = train.iloc[-train_size:].reset_index(drop=True)\n    print(f\"Using last {train_size} samples\")\n    \n    # Add features\n    print(\"\\nAdding market features...\")\n    train = add_market_features(train)\n    \n    # Get features\n    all_features = [col for col in train.columns if col != 'label']\n    print(f\"Total features: {len(all_features)}\")\n    \n    # Split data\n    split_idx = int(0.8 * len(train))\n    train_data = train[:split_idx].copy()\n    val_data = train[split_idx:].copy()\n    \n    y_train = train_data.pop('label').values\n    y_val = val_data.pop('label').values\n    \n    X_train = train_data[all_features].values\n    X_val = val_data[all_features].values\n    \n    # Feature selection\n    print(\"\\nSelecting features...\")\n    selector = SelectKBest(score_func=mutual_info_regression, k=min(150, len(all_features)))\n    X_train_selected = selector.fit_transform(X_train, y_train)\n    X_val_selected = selector.transform(X_val)\n    \n    selected_features = [all_features[i] for i in selector.get_support(indices=True)]\n    print(f\"Selected {len(selected_features)} features\")\n    \n    # Transform data\n    print(\"\\nTransforming data...\")\n    transformer = QuantileTransformer(output_distribution='normal', random_state=42)\n    X_train_transformed = transformer.fit_transform(X_train_selected)\n    X_val_transformed = transformer.transform(X_val_selected)\n    \n    # Create data loaders\n    train_dataset = TensorDataset(\n        torch.tensor(X_train_transformed, dtype=torch.float32),\n        torch.tensor(y_train, dtype=torch.float32).unsqueeze(1)\n    )\n    val_dataset = TensorDataset(\n        torch.tensor(X_val_transformed, dtype=torch.float32),\n        torch.tensor(y_val, dtype=torch.float32).unsqueeze(1)\n    )\n    \n    train_loader = DataLoader(train_dataset, batch_size=512, shuffle=True)\n    val_loader = DataLoader(val_dataset, batch_size=1024, shuffle=False)\n    \n    # Model configurations to try\n    configs = [\n        {\n            'n_trees': 32,\n            'tree_depth': 5,\n            'tree_temperature': 1.0,\n            'embed_dims': [256, 256],\n            'feature_dropout': 0.2,\n            'gate_hidden_dim': 128,\n            'gate_dropout': 0.2,\n            'use_nn_head': True,\n            'head_dims': [256, 128],\n            'head_dropout': 0.3,\n            'learning_rate': 0.0003,\n            'weight_decay': 0.01,\n            'huber_delta': 1.0,\n            'noise_factor': 0.01,\n            'grad_clip': 1.0,\n            'num_epochs': 50,\n            'patience': 10,\n            'use_amp': True,\n            'input_dim': X_train_transformed.shape[1]\n        },\n        {\n            'n_trees': 24,\n            'tree_depth': 6,\n            'tree_temperature': 1.5,\n            'embed_dims': [128, 256, 128],\n            'feature_dropout': 0.3,\n            'gate_hidden_dim': 256,\n            'gate_dropout': 0.2,\n            'use_nn_head': True,\n            'head_dims': [256, 128, 64],\n            'head_dropout': 0.4,\n            'learning_rate': 0.0005,\n            'weight_decay': 0.001,\n            'huber_delta': 0.5,\n            'noise_factor': 0.02,\n            'grad_clip': 2.0,\n            'num_epochs': 50,\n            'patience': 10,\n            'use_amp': True,\n            'input_dim': X_train_transformed.shape[1]\n        }\n    ]\n    \n    # Train multiple models\n    ensemble_models = []\n    ensemble_scores = []\n    \n    for i, config in enumerate(configs):\n        print(f\"\\n=== Training Model {i+1}/{len(configs)} ===\")\n        \n        # Create model\n        model = GANDALF(config).to(device)\n        print(f\"Model parameters: {sum(p.numel() for p in model.parameters()):,}\")\n        \n        # Train\n        model, best_score = train_gandalf(model, train_loader, val_loader, config, device)\n        \n        ensemble_models.append(model)\n        ensemble_scores.append(best_score)\n        \n        print(f\"Model {i+1} best validation Pearson: {best_score:.4f}\")\n    \n    # Test predictions\n    print(\"\\n=== Making Test Predictions ===\")\n    \n    # Load test data\n    test = pl.scan_parquet(\"/kaggle/input/drw-crypto-market-prediction/test.parquet\")\n    test_features = [f for f in SELECTED_FEATURES if f != \"label\"]\n    test = test.select(test_features).collect().to_pandas()\n    \n    # Add features\n    test = add_market_features(test)\n    \n    # Transform test data\n    X_test = test[all_features].values\n    X_test_selected = selector.transform(X_test)\n    X_test_transformed = transformer.transform(X_test_selected)\n    \n    # Make predictions\n    all_predictions = []\n    \n    for model in ensemble_models:\n        model.eval()\n        test_dataset = TensorDataset(torch.tensor(X_test_transformed, dtype=torch.float32))\n        test_loader = DataLoader(test_dataset, batch_size=2048, shuffle=False)\n        \n        predictions = []\n        with torch.no_grad():\n            for (inputs,) in test_loader:\n                inputs = inputs.to(device)\n                outputs = model(inputs)\n                predictions.extend(outputs.cpu().numpy().flatten())\n        \n        all_predictions.append(np.array(predictions))\n    \n    # Ensemble predictions\n    # Weighted average based on validation scores\n    weights = np.array(ensemble_scores)\n    weights = weights / weights.sum()\n    \n    final_predictions = np.zeros_like(all_predictions[0])\n    for pred, weight in zip(all_predictions, weights):\n        final_predictions += weight * pred\n    \n    # Post-processing\n    pred_mean = y_train.mean()\n    pred_std = y_train.std()\n    final_predictions = np.clip(\n        final_predictions,\n        pred_mean - 4 * pred_std,\n        pred_mean + 4 * pred_std\n    )\n    \n    # Create submission\n    submission = pd.read_csv(\"/kaggle/input/drw-crypto-market-prediction/sample_submission.csv\")\n    submission[\"prediction\"] = final_predictions\n    submission.to_csv(\"submission_gandalf.csv\", index=False)\n    \n    # Display results\n    print(\"\\n=== Final Results ===\")\n    print(f\"Ensemble size: {len(ensemble_models)} models\")\n    print(f\"Ensemble weights: {weights}\")\n    print(f\"Features used: {len(selected_features)}\")\n    print(f\"\\nPrediction statistics:\")\n    print(f\"  Mean: {final_predictions.mean():.6f}\")\n    print(f\"  Std: {final_predictions.std():.6f}\")\n    print(f\"  Min: {final_predictions.min():.6f}\")\n    print(f\"  Max: {final_predictions.max():.6f}\")\n    print(f\"  Skewness: {skew(final_predictions):.4f}\")\n    print(f\"  Kurtosis: {kurtosis(final_predictions):.4f}\")\n    \n    # Plot predictions distribution\n    plt.figure(figsize=(10, 6))\n    plt.subplot(1, 2, 1)\n    plt.hist(final_predictions, bins=50, alpha=0.7, edgecolor='black')\n    plt.xlabel('Predicted Values')\n    plt.ylabel('Frequency')\n    plt.title('Test Predictions Distribution')\n    \n    plt.subplot(1, 2, 2)\n    plt.hist(y_train, bins=50, alpha=0.7, color='orange', edgecolor='black')\n    plt.xlabel('Target Values')\n    plt.ylabel('Frequency')\n    plt.title('Training Target Distribution')\n    \n    plt.tight_layout()\n    plt.savefig(RESULTS_DIR / 'predictions_distribution.png', dpi=300)\n    plt.close()\n    \n    print(f\"\\nResults saved to: {RESULTS_DIR}\")\n    print(\"Submission saved as 'submission_gandalf.csv'\")\n    print(\"✅ GANDALF training complete!\")\n\nif __name__ == \"__main__\":\n    main()","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}