{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":104854,"databundleVersionId":12637092,"sourceType":"competition"}],"dockerImageVersionId":31090,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Прогнозирование группы минимального баланса клиента\n\nЦель проекта — построить модель, которая предсказывает группу минимального баланса клиента в следующем месяце. Целевая переменная принимает значения от `0` (нулевой баланс) до `6` (высокий баланс).\n\nПайплайн включает:\n- Предобработку транзакционных данных\n- Агрегации и генерацию признаков\n- Объединение с глобальной статистикой по клиентам\n- Обучение LSTM-модели на последовательностях\n- Генерацию предсказаний для новых клиентов\n","metadata":{}},{"cell_type":"markdown","source":"## Загрузка данных\n\nИмпортируем необходимые библиотеки и загружаем:\n- `df_train` — информация по клиентам и их целевой переменной;\n- `df_txn` — история транзакций, которая будет основой для фичей.\n","metadata":{}},{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport gc\nimport os\nfrom sklearn.model_selection import train_test_split\nimport copy\nfrom sklearn.preprocessing import StandardScaler\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom tqdm import tqdm\n\n# Загрузка данных\ndf_train = pd.read_parquet(\"/kaggle/input/alpha-summer-challenge/train.pa\")\ndf_txn   = pd.read_parquet(\"/kaggle/input/alpha-summer-challenge/df_transaction.pa\")\n\nt_min = df_txn.date_time.astype(int).min()\nt_max = df_txn.date_time.astype(int).max()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:34:45.349421Z","iopub.execute_input":"2025-07-20T13:34:45.350038Z","iopub.status.idle":"2025-07-20T13:34:59.313929Z","shell.execute_reply.started":"2025-07-20T13:34:45.350007Z","shell.execute_reply":"2025-07-20T13:34:59.313296Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Глобальные агрегаты по клиентам\n\nСоздаём обобщённые числовые признаки на уровне клиента: средние, суммы, стандартные отклонения по `amount`, количество уникальных MCC-кодов и мерчантов и т.п.","metadata":{}},{"cell_type":"code","source":"client_aggregates = pd.DataFrame({\n    \"client_num\": np.sort(df_txn.client_num.unique()),\n    \"mean_amount_by_client_numeric\": np.log1p(df_txn.groupby([\"client_num\"])[\"amount\"].mean().sort_index().values),\n    \"max_amount_by_client_numeric\": np.log1p(df_txn.groupby([\"client_num\"])[\"amount\"].max().sort_index().values),\n    \"std_amount_by_client_numeric\": np.log1p(df_txn.groupby([\"client_num\"])[\"amount\"].std().sort_index().values),\n    \"sum_amount_by_client_numeric\": np.log1p(df_txn.groupby([\"client_num\"])[\"amount\"].sum().sort_index().values),\n    \"nunique_mcc_code_by_client_numeric\": np.log1p(df_txn.groupby([\"client_num\"])[\"mcc_code\"].nunique().sort_index().values),\n    \"nunique_merchant_name_by_client_numeric\": np.log1p(df_txn.groupby([\"client_num\"])[\"merchant_name\"].nunique().sort_index().values),\n    \"min_date_time_by_client_numeric\": (df_txn.groupby([\"client_num\"])[\"date_time\"].min().sort_index().astype(int).values - t_min)/(t_max - t_min),\n    \"max_date_time_by_client_numeric\": (df_txn.groupby([\"client_num\"])[\"date_time\"].max().sort_index().astype(int).values - t_min)/(t_max - t_min),\n})","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:34:59.314990Z","iopub.execute_input":"2025-07-20T13:34:59.315721Z","iopub.status.idle":"2025-07-20T13:35:06.143723Z","shell.execute_reply.started":"2025-07-20T13:34:59.315699Z","shell.execute_reply":"2025-07-20T13:35:06.142971Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Предобработка транзакционных данных\n\n- Кодируем `mcc_code` и `merchant_name`;\n- Добавляем колонку `date` — без времени.\n","metadata":{}},{"cell_type":"code","source":"df = df_txn\n\ndf['date_time'] = pd.to_datetime(df['date_time'])\n\n# Map mcc_code\nmcc_code_mapping = {v: i+1 for i,v in enumerate(df['mcc_code'].unique())}\ndf['mcc_code'] = df['mcc_code'].map(mcc_code_mapping)\ndf['mcc_code'] = df['mcc_code'].astype(int)\n\n# Map merchant_name\nprint(\"Mapping merchant_name to integer codes...\")\ntop_999_merchants = df['merchant_name'].value_counts().nlargest(999).index\nmerchant_mapping = {v: i+1 for i,v in enumerate(top_999_merchants)}\ndf['merchant_name'] = df['merchant_name'].map(merchant_mapping).fillna(1000).astype(int)\n\n# Add daily column\ndf['date'] = df['date_time'].dt.floor('D')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:35:06.144568Z","iopub.execute_input":"2025-07-20T13:35:06.144820Z","iopub.status.idle":"2025-07-20T13:35:10.587967Z","shell.execute_reply.started":"2025-07-20T13:35:06.144797Z","shell.execute_reply":"2025-07-20T13:35:10.587207Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Агрегации по клиенту и дате\n\nСчитаем ежедневные показатели для каждого клиента: суммы, количества, средние значения транзакций, а также уникальность MCC и мерчантов.\n","metadata":{}},{"cell_type":"code","source":"agg_amount = df.groupby(['client_num', 'date']).agg(\n    amount_sum=('amount', 'sum'),\n    amount_count=('amount', 'count'),\n    amount_std=('amount', 'std'),\n    amount_max=('amount', 'max'),\n    amount_min=('amount', 'min'),\n    amount_mean=('amount', 'mean'),\n    merchant_nunique=('merchant_name', 'nunique'),\n    mcc_code_nunique=('mcc_code', 'nunique'),\n).reset_index()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:35:10.589707Z","iopub.execute_input":"2025-07-20T13:35:10.590133Z","iopub.status.idle":"2025-07-20T13:35:17.754596Z","shell.execute_reply.started":"2025-07-20T13:35:10.590112Z","shell.execute_reply":"2025-07-20T13:35:17.753649Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Выделение топ-N категорий\n\nСтроим признаки на основе самых часто используемых категорий `mcc_code` и `merchant_name` — топ-5 по количеству операций. Для каждого клиента по каждому дню — сумма, количество и доля для каждой позиции в топе.\n","metadata":{}},{"cell_type":"code","source":"def top_n_category_stats(df, category_col, n=5):\n    print(f\"Computing top-{n} stats for '{category_col}'...\")\n    \n    grouped = df.groupby(['client_num', 'date', category_col])['amount'].agg(['count', 'sum']).reset_index()\n    total = df.groupby(['client_num', 'date'])['amount'].sum().rename('total_amount').reset_index()\n    grouped = grouped.merge(total, on=['client_num', 'date'])\n    \n    grouped['ratio'] = grouped['sum'] / grouped['total_amount']\n    grouped['rank'] = grouped.groupby(['client_num', 'date'])['count'].rank(method='first', ascending=False)\n    \n    top_n = grouped[grouped['rank'] <= n]\n    \n    pivot = top_n.pivot_table(\n        index=['client_num', 'date'],\n        columns='rank',\n        values=['count', 'sum', 'ratio', category_col],\n        aggfunc='first'\n    )\n    pivot.columns = [f'{category_col}_top{int(col[1])}_{col[0]}' for col in pivot.columns]\n    pivot = pivot.reset_index()\n\n    # Fill missing values\n    for col in pivot.columns:\n        if 'ratio' in col or 'sum' in col or 'count' in col:\n            pivot[col] = pivot[col].fillna(0)\n        elif category_col in col:\n            pivot[col] = pivot[col].fillna(0).astype('int32')\n\n    return pivot\n\ntop_mcc = top_n_category_stats(df, 'mcc_code', n=5)\ntop_merchant = top_n_category_stats(df, 'merchant_name', n=5)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:35:17.755675Z","iopub.execute_input":"2025-07-20T13:35:17.755922Z","iopub.status.idle":"2025-07-20T13:35:57.603573Z","shell.execute_reply.started":"2025-07-20T13:35:17.755901Z","shell.execute_reply":"2025-07-20T13:35:57.602894Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Объединение признаков\n\nСклеиваем агрегаты, топ-N признаки по MCC и мерчантам в один датафрейм.\n","metadata":{}},{"cell_type":"code","source":"features = (\n    agg_amount\n    .merge(top_mcc, on=['client_num', 'date'], how='left')\n    .merge(top_merchant, on=['client_num', 'date'], how='left')\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:35:57.604467Z","iopub.execute_input":"2025-07-20T13:35:57.604736Z","iopub.status.idle":"2025-07-20T13:36:03.835761Z","shell.execute_reply.started":"2025-07-20T13:35:57.604708Z","shell.execute_reply":"2025-07-20T13:36:03.835204Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Построение сетки всех клиент-дней\n\nГенерируем полную сетку `client_num × date`, чтобы не потерять пропущенные дни и корректно обучить модель на равномерном временном ряде.\n","metadata":{}},{"cell_type":"code","source":"all_clients = df['client_num'].unique()\nall_dates = pd.date_range(df['date'].min(), df['date'].max(), freq='D')\nfull_grid = pd.MultiIndex.from_product(\n    [all_clients, all_dates],\n    names=['client_num', 'date']\n).to_frame(index=False)\n\nfinal_df = full_grid.merge(features, on=['client_num', 'date'], how='left')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:03.836384Z","iopub.execute_input":"2025-07-20T13:36:03.836587Z","iopub.status.idle":"2025-07-20T13:36:11.597291Z","shell.execute_reply.started":"2025-07-20T13:36:03.836570Z","shell.execute_reply":"2025-07-20T13:36:11.596701Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Финальная очистка и дополнение признаками времени\n\n- Заполняем пропуски;\n- Добавляем признаки: день недели, день года, неделя, месяц и относительное положение во времени (`timestep_ratio`).\n","metadata":{}},{"cell_type":"code","source":"numeric_aggregates = [\n    'amount_sum', 'amount_count', 'amount_std', 'amount_max',\n    'amount_min', 'amount_mean', 'merchant_nunique', 'mcc_code_nunique'\n]\n\n# Fill numeric aggregates with 0\nfinal_df[numeric_aggregates] = final_df[numeric_aggregates].fillna(0)\n\n# Fill top-N feature values\nfor col in final_df.columns:\n    if any(stat in col for stat in ['ratio', 'sum', 'count']):\n        final_df[col] = final_df[col].fillna(0)\n    elif 'mcc_code_top' in col:\n        final_df[col] = final_df[col].fillna(0).astype('int32')\n    elif 'merchant_name_top' in col: \n        final_df[col] = final_df[col].fillna(0).astype('int32')\n\n# Add time features\nfinal_df['day_of_week'] = final_df['date'].dt.dayofweek\nfinal_df['day_of_year'] = final_df['date'].dt.dayofyear\nfinal_df['month'] = final_df['date'].dt.month\nfinal_df['week'] = final_df['date'].dt.isocalendar().week.astype('int32')\n\nmin_date = final_df['date'].min()\nmax_date = final_df['date'].max()\ndate_range = (max_date - min_date).days\nfinal_df['timestep_ratio'] = (final_df['date'] - min_date).dt.days / date_range\n\ngc.collect()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:11.597980Z","iopub.execute_input":"2025-07-20T13:36:11.598188Z","iopub.status.idle":"2025-07-20T13:36:18.901910Z","shell.execute_reply.started":"2025-07-20T13:36:11.598170Z","shell.execute_reply":"2025-07-20T13:36:18.901205Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Постобработка признаков\n\n- Применяем логарифмирование к агрегатам;\n- Объединяем `final_df` с `client_aggregates` (глобальные признаки клиента).\n","metadata":{}},{"cell_type":"code","source":"final_df[numeric_aggregates] = np.log(1+final_df[numeric_aggregates])\nfinal_df = pd.merge(final_df, client_aggregates, on=[\"client_num\"], how=\"left\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:18.902652Z","iopub.execute_input":"2025-07-20T13:36:18.902844Z","iopub.status.idle":"2025-07-20T13:36:24.415217Z","shell.execute_reply.started":"2025-07-20T13:36:18.902829Z","shell.execute_reply":"2025-07-20T13:36:24.414425Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Подготовка к обучению\n\n- Делим `df_train` на train/test в стратифицированном виде;\n- Определяем списки числовых и категориальных признаков;\n- Определяем клиентов, по которым нужно делать предсказание.","metadata":{}},{"cell_type":"code","source":"# Clients in df_txn but not in df_train\nclients_to_predict = set(df_txn['client_num'].unique()) - set(df_train['client_num'].unique())\n\n# Stratified split\ntrain, test = train_test_split(\n    df_train,\n    test_size=0.2,\n    stratify=df_train['target'],\n    random_state=42\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:24.417320Z","iopub.execute_input":"2025-07-20T13:36:24.417578Z","iopub.status.idle":"2025-07-20T13:36:24.536790Z","shell.execute_reply.started":"2025-07-20T13:36:24.417554Z","shell.execute_reply":"2025-07-20T13:36:24.536267Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"numeric_cols, categorical_cols = (['mcc_code_top3_sum',\n  'merchant_name_top2_count',\n  'mcc_code_top3_count',\n  'mcc_code_top2_sum',\n  'merchant_name_top5_count',\n  'amount_sum',\n  'amount_min',\n  'amount_max',\n  'mcc_code_top3_ratio',\n  'merchant_name_top3_sum',\n  'std_amount_by_client_numeric',\n  'mcc_code_top2_ratio',\n  'mcc_code_top5_ratio',\n  'merchant_name_top4_ratio',\n  'mcc_code_top4_count',\n  'merchant_name_top2_sum',\n  'nunique_mcc_code_by_client_numeric',\n  'amount_count',\n  'mcc_code_nunique',\n  'amount_mean',\n  'max_date_time_by_client_numeric',\n  'merchant_name_top4_count',\n  'amount_std',\n  'mcc_code_top1_sum',\n  'mcc_code_top4_sum',\n  'mean_amount_by_client_numeric',\n  'nunique_merchant_name_by_client_numeric',\n  'sum_amount_by_client_numeric',\n  'max_amount_by_client_numeric',\n  'merchant_name_top5_sum',\n  'mcc_code_top1_count',\n  'merchant_name_top4_sum',\n  'min_date_time_by_client_numeric',\n  'merchant_name_top2_ratio',\n  'mcc_code_top2_count',\n  'merchant_name_top1_ratio',\n  'mcc_code_top5_count',\n  'mcc_code_top5_sum',\n  'merchant_name_top3_ratio',\n  'merchant_name_top5_ratio',\n  'mcc_code_top4_ratio',\n  'merchant_name_top1_sum',\n  'merchant_name_top1_count',\n  'mcc_code_top1_ratio',\n  'merchant_nunique',\n  'merchant_name_top3_count',\n  'timestep_ratio'],\n ['mcc_code_top1_mcc_code',\n  'mcc_code_top2_mcc_code',\n  'mcc_code_top3_mcc_code',\n  'mcc_code_top4_mcc_code',\n  'mcc_code_top5_mcc_code',\n  'merchant_name_top1_merchant_name',\n  'merchant_name_top2_merchant_name',\n  'merchant_name_top3_merchant_name',\n  'merchant_name_top4_merchant_name',\n  'merchant_name_top5_merchant_name',\n  'day_of_week',\n  'day_of_year',\n  'month',\n  'week'])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:24.537381Z","iopub.execute_input":"2025-07-20T13:36:24.537569Z","iopub.status.idle":"2025-07-20T13:36:24.542394Z","shell.execute_reply.started":"2025-07-20T13:36:24.537555Z","shell.execute_reply":"2025-07-20T13:36:24.541688Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Нормализация числовых признаков\n\nИспользуем `StandardScaler` для стандартизации: каждое числовое значение приводим к нулевому среднему и единичному стандартному отклонению.\n","metadata":{}},{"cell_type":"code","source":"# Standardize numeric features\nscaler = StandardScaler()\nfinal_df[numeric_cols] = scaler.fit_transform(final_df[numeric_cols])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:24.543086Z","iopub.execute_input":"2025-07-20T13:36:24.543272Z","iopub.status.idle":"2025-07-20T13:36:36.302366Z","shell.execute_reply.started":"2025-07-20T13:36:24.543258Z","shell.execute_reply":"2025-07-20T13:36:36.301773Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## WMAE и функция потерь\n\nИспользуем WMAE (взвешенную MAE), где ошибки по классам с низким балансом штрафуются сильнее. Это соответствует бизнес-логике: ошибки для обнулённых клиентов критичнее.\n","metadata":{}},{"cell_type":"code","source":"class_weights = {\n    0: 1.00, 1: 0.72, 2: 0.52, 3: 0.37,\n    4: 0.27, 5: 0.19, 6: 0.14, 7: 0.10\n}\n\ndef wmae(y_true, y_pred, weights_map):\n    weights = np.array([weights_map.get(y, 0.0) for y in y_true])\n    abs_errors = np.abs(y_true - y_pred)\n    return np.sum(weights * abs_errors) / np.sum(weights)\n\n\nclass WeightedMAELoss(nn.Module):\n    def __init__(self, class_weights):\n        super().__init__()\n        self.class_weights = class_weights\n\n    def forward(self, y_pred, y_true):\n        weights = torch.tensor(\n            [self.class_weights.get(int(y.item()), 0.0) for y in y_true],\n            dtype=y_pred.dtype,\n            device=y_pred.device\n        )\n        loss = torch.abs(y_pred - y_true)\n        return torch.mean(weights * loss)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:36.303023Z","iopub.execute_input":"2025-07-20T13:36:36.303261Z","iopub.status.idle":"2025-07-20T13:36:36.310885Z","shell.execute_reply.started":"2025-07-20T13:36:36.303243Z","shell.execute_reply":"2025-07-20T13:36:36.310208Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Подготовка PyTorch Dataset\n\nСоздаём датасет для подачи временных рядов на вход LSTM. Каждый клиент представлен как последовательность признаков длины `SEQ_LEN`.\n","metadata":{}},{"cell_type":"code","source":"class ClientSequenceDataset(Dataset):\n    def __init__(self, df, client_targets, seq_len, numeric_cols, categorical_cols):\n        self.seq_len = seq_len\n        self.numeric_cols = numeric_cols\n        self.categorical_cols = categorical_cols\n        self.client_targets = client_targets.set_index('client_num')['target'].to_dict()\n        self.client_ids = list(self.client_targets.keys())\n\n        df_sorted = df.sort_values(['client_num', 'date'])\n        grouped = df_sorted.groupby('client_num')\n\n        # Precompute fixed-length sequences\n        self.sequences = {\n            client_id: (\n                group[numeric_cols].values.astype(np.float32),\n                group[categorical_cols].values.astype(np.int64)\n            )\n            for client_id, group in grouped if client_id in self.client_targets\n        }\n\n    def __len__(self):\n        return len(self.client_ids)\n\n    def __getitem__(self, idx):\n        client_id = self.client_ids[idx]\n        numeric, categorical = self.sequences[client_id]\n        target = self.client_targets[client_id]\n        return (\n            torch.tensor(numeric),\n            torch.tensor(categorical),\n            torch.tensor(target).float()\n        )\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:36.311581Z","iopub.execute_input":"2025-07-20T13:36:36.311904Z","iopub.status.idle":"2025-07-20T13:36:36.332188Z","shell.execute_reply.started":"2025-07-20T13:36:36.311876Z","shell.execute_reply":"2025-07-20T13:36:36.331559Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Модель: LSTM-регрессор\n\nBidirectional LSTM с эмбеддингами для категориальных признаков и полносвязным выходом. Модель возвращает одно числовое значение — предсказанную группу баланса.\n","metadata":{}},{"cell_type":"code","source":"class LSTMRegressor(nn.Module):\n    def __init__(self, numeric_dim, cat_cardinalities, hidden_size, emb_dim=16, num_layers=3):\n        super().__init__()\n        self.embeddings = nn.ModuleList([\n            nn.Embedding(num_embeddings=card, embedding_dim=emb_dim)\n            for card in cat_cardinalities\n        ])\n\n        input_dim = numeric_dim + emb_dim * len(cat_cardinalities)\n        self.lstm = nn.LSTM(\n            input_size=input_dim,\n            hidden_size=hidden_size,\n            num_layers=num_layers,\n            batch_first=True,\n            bidirectional=True\n        )\n\n        self.fc = nn.Linear(hidden_size * 2, 1)\n\n    def forward(self, numeric_x, cat_x):\n        # Embed categorical inputs\n        cat_embedded = torch.cat(\n            [emb(cat_x[:, :, i]) for i, emb in enumerate(self.embeddings)],\n            dim=-1\n        )\n\n        # Concatenate numerical and categorical embeddings\n        x = torch.cat([numeric_x, cat_embedded], dim=-1)\n\n        # Pass through LSTM\n        x, _ = self.lstm(x)\n\n        # Mean pooling over the last 20 timesteps\n        x_last20 = x[:, -20:, :]\n        pooled = x_last20.mean(dim=1)\n\n        # Output\n        return self.fc(pooled).squeeze(1)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:36.332881Z","iopub.execute_input":"2025-07-20T13:36:36.333118Z","iopub.status.idle":"2025-07-20T13:36:36.352768Z","shell.execute_reply.started":"2025-07-20T13:36:36.333102Z","shell.execute_reply":"2025-07-20T13:36:36.352021Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"SEQ_LEN = 93\nBATCH_SIZE = 32\nHIDDEN_SIZE = 1024\nLEARNING_RATE = 1e-5\nNUM_EPOCHS = 100\n\ncat_cardinalities = [int(final_df[col].max()) + 1 for col in categorical_cols]\n\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n# device = torch.device(\"mps\")\n\nmodel = LSTMRegressor(\n    numeric_dim=len(numeric_cols),\n    cat_cardinalities=cat_cardinalities,\n    hidden_size=HIDDEN_SIZE\n).to(device)\n\ntrain_dataset = ClientSequenceDataset(final_df, train, SEQ_LEN, numeric_cols, categorical_cols)\ntest_dataset = ClientSequenceDataset(final_df, test, SEQ_LEN, numeric_cols, categorical_cols)\n\ntrain_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4, pin_memory=True)\ntest_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, num_workers=4, pin_memory=True)\n\noptimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE, weight_decay=5e-4)\ncriterion = WeightedMAELoss(class_weights).to(device)\n\ngc.collect()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:36:36.353552Z","iopub.execute_input":"2025-07-20T13:36:36.353775Z","iopub.status.idle":"2025-07-20T13:37:44.015103Z","shell.execute_reply.started":"2025-07-20T13:36:36.353744Z","shell.execute_reply":"2025-07-20T13:37:44.014535Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Обучение модели\n\nЗапускаем обучение модели с логированием потерь и WMAE. Сохраняем лучшие веса модели по raw и rounded WMAE.","metadata":{}},{"cell_type":"code","source":"best_val_wmae_raw = float(\"inf\")\nbest_val_wmae_rounded = float(\"inf\")\n\nfor epoch in range(NUM_EPOCHS):\n    model.train()\n    train_loss = 0\n    print(f\"\\nEpoch {epoch+1}/{NUM_EPOCHS}\")\n\n    for batch_idx, (num_x, cat_x, y) in tqdm(enumerate(train_loader), total=len(train_loader), desc=\"Training\"):\n        num_x, cat_x, y = num_x.to(device), cat_x.to(device), y.to(device)\n        optimizer.zero_grad()\n        output = model(num_x, cat_x)\n        loss = criterion(output, y)\n        loss.backward()\n        optimizer.step()\n        train_loss += loss.item()\n\n    # Validation\n    model.eval()\n    val_loss = 0\n    preds, actuals = [], []\n\n    with torch.no_grad():\n        for num_x, cat_x, y in tqdm(test_loader, desc=\"Validating\"):\n            num_x, cat_x, y = num_x.to(device), cat_x.to(device), y.to(device)\n            output = model(num_x, cat_x)\n            loss = criterion(output, y)\n            val_loss += loss.item()\n            preds.extend(output.cpu().numpy())\n            actuals.extend(y.cpu().numpy())\n\n    val_wmae_raw = wmae(np.array(actuals), np.array(preds), class_weights)\n    preds_rounded = np.round(np.clip(preds, 0, 6)).astype(int)\n    val_wmae_rounded = wmae(np.array(actuals), preds_rounded, class_weights)\n\n    print(f\"Epoch {epoch+1:02d} | \"\n          f\"Train Loss: {train_loss/len(train_loader):.4f} | \"\n          f\"Val Loss: {val_loss/len(test_loader):.4f} | \"\n          f\"Val WMAE (raw): {val_wmae_raw:.4f} | \"\n          f\"Val WMAE (rounded): {val_wmae_rounded:.4f}\")\n\n    print(f\"Current LR: {optimizer.param_groups[0]['lr']:.6e}\")\n\n    if val_wmae_raw < best_val_wmae_raw:\n        best_val_wmae_raw = val_wmae_raw\n        torch.save(model.state_dict(), \"model_best_raw.pt\")\n        print(\"✅ Checkpoint saved (best raw).\")\n\n    if val_wmae_rounded < best_val_wmae_rounded:\n        best_val_wmae_rounded = val_wmae_rounded\n        torch.save(model.state_dict(), \"model_best_rounded.pt\")\n        print(\"✅ Checkpoint saved (best rounded).\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-07-20T13:37:44.015790Z","iopub.execute_input":"2025-07-20T13:37:44.016192Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Финальная валидация\n\nЗагружаем модель с лучшими весами и оцениваем качество на валидационной выборке.\n","metadata":{}},{"cell_type":"code","source":"model.load_state_dict(torch.load('model_best_raw.pt'))","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from tqdm import tqdm\n\nmodel.eval()\nval_loss = 0\npreds, actuals = [], []\n\nwith torch.no_grad():\n    val_iterator = tqdm(test_loader, desc=\"Validating\")\n    for num_x, cat_x, y in val_iterator:\n        num_x, cat_x, y = num_x.to(device), cat_x.to(device), y.to(device)\n        output = model(num_x, cat_x)\n        loss = criterion(output, y)\n        val_loss += loss.item()\n        preds.extend(output.cpu().numpy())\n        actuals.extend(y.cpu().numpy())\n\nval_wmae_raw = wmae(np.array(actuals), np.array(preds), class_weights)\npreds_rounded = np.round(np.clip(preds, 0, 6)).astype(int)\nval_wmae_rounded = wmae(np.array(actuals), preds_rounded, class_weights)\n\nprint(f\"Val Loss: {val_loss/len(test_loader):.4f} | \"\n      f\"Val WMAE (raw): {val_wmae_raw:.4f} | \"\n      f\"Val WMAE (rounded): {val_wmae_rounded:.4f}\")\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Финальное предсказание\n\n- Формируем последовательности признаков для клиентов, отсутствующих в `df_train`;\n- Получаем предсказания;\n- Округляем значения до классов от 0 до 6;\n- Сохраняем результат в `submission.csv`.\n","metadata":{}},{"cell_type":"code","source":"class PredictionDataset(Dataset):\n    def __init__(self, df, client_ids, seq_len, numeric_cols, categorical_cols):\n        self.seq_len = seq_len\n        self.client_ids = client_ids\n        self.numeric_cols = numeric_cols\n        self.categorical_cols = categorical_cols\n        self.groups = df.sort_values(['client_num', 'date']).groupby('client_num')\n\n    def __len__(self):\n        return len(self.client_ids)\n\n    def __getitem__(self, idx):\n        client_id = self.client_ids[idx]\n        group = self.groups.get_group(client_id)\n\n        numeric = group[self.numeric_cols].values.astype(np.float32)\n        categorical = group[self.categorical_cols].values.astype(np.int64)\n\n        return torch.tensor(client_id), torch.tensor(numeric), torch.tensor(categorical)\n\n\n# ✅ Setup prediction\n\npredict_dataset = PredictionDataset(\n    final_df,\n    client_ids=sorted(list(clients_to_predict)),\n    seq_len=SEQ_LEN,\n    numeric_cols=numeric_cols,\n    categorical_cols=categorical_cols\n)\npredict_loader = DataLoader(predict_dataset, batch_size=32)\n\n# ✅ Run predictions\nmodel.eval()\nresults = []\n\nwith torch.no_grad():\n    for client_ids, num_x, cat_x in predict_loader:\n        num_x, cat_x = num_x.to(device), cat_x.to(device)\n        preds = model(num_x, cat_x)\n        results.extend(zip(client_ids.numpy(), preds.cpu().numpy()))\n\n# ✅ Build DataFrame with results\npred_df = pd.DataFrame(results, columns=[\"client_num\", \"prediction\"])","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"pred_df.head()","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"pred_df[\"target\"] = np.clip(np.round(pred_df[\"prediction\"].astype(float)), 0, 6).astype(int)\npred_df[[\"client_num\", \"target\"]].to_csv(\"submission.csv\", index=False)","metadata":{},"outputs":[],"execution_count":null}]}