{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceType":"competition","sourceId":38760,"databundleVersionId":4493939}],"dockerImageVersionId":31259,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# OTTO – Multi-Objective Recommender System\n\n**Шевченко Кирилл, мИПИИ241**","metadata":{}},{"cell_type":"markdown","source":"### Описание\n\n**Цель соревнования**\n\nЦель этого соревнования — предсказать клики, добавления в корзину и покупки в сфере e-commerce. Необходимо построить многоцелевую рекомендательную систему, основанную на предыдущих событиях в рамках пользовательской сессии.\n\nРабота поможет улучшить покупательский опыт для всех участников процесса. Клиенты будут получать более персонализированные рекомендации, а онлайн-ритейлеры смогут увеличить свои продажи.\n\n**Контекст**\n\nУ онлайн-покупателей есть выбор из миллионов товаров от крупных ритейлеров. Хотя такое разнообразие впечатляет, наличие огромного количества вариантов может сбивать с толку, в результате чего покупатели уходят с пустыми корзинами. Это не выгодно ни покупателям, стремящимся совершить покупку, ни ритейлерам, которые упускают продажи. Это одна из причин, по которой онлайн-ритейлеры полагаются на рекомендательные системы, которые направляют покупателей к товарам, наиболее соответствующим их интересам и мотивам. Использование методов Data Science для улучшения способности ритейлеров прогнозировать в режиме реального времени, какие именно товары каждый клиент хочет увидеть, добавить в корзину и заказать в любой момент своего визита, может улучшить клиентский опыт, когда вы будете делать покупки в любимом интернет-магазине в следующий раз.\n\nСовременные рекомендательные системы состоят из различных моделей с разными подходами: от простой матричной факторизации до глубоких нейронных сетей типа transformer. Однако не существует единой модели, способной одновременно оптимизировать несколько целей. В рамках этого соревнования вы создадите единое решение для прогнозирования показателей кликабельности (click-through), добавлений в корзину (add-to-cart) и конверсии (conversion) на основе предыдущих событий той же сессии.\n\nOTTO — крупнейший немецкий интернет-магазин, в ассортименте которого более 10 миллионов товаров от более чем 19 000 брендов. OTTO входит в состав базирующейся в Гамбурге многонациональной группы Otto Group, в которую также входят Crate & Barrel (США) и 3 Suisses (Франция).\n\n### Оценка\n\nРешения оцениваются по метрике **Recall@20** (полнота) для каждого типа действий. Итоговый балл представляет собой средневзвешенное значение этих трех показателей:\n\n$$score = 0.10 \\cdot R_{clicks} + 0.30 \\cdot R_{carts} + 0.60 \\cdot R_{orders}$$\n\nгде $R_{type}$ определяется как:\n\n$$R_{type} = \\frac{\\sum_{i}^{N} | \\{ \\text{predicted aids} \\}_{i, type} \\cap \\{ \\text{ground truth aids} \\}_{i, type} | }{\\sum_{i}^{N} \\min{( 20, | \\{ \\text{ground truth aids} \\}_{i, type} | )}}$$\n\nЗдесь $N$ — общее количество сессий в тестовом наборе, а $predicted$ $aids$ — это предсказания для каждого типа сессии (т.е. для каждой строки в файле решения), ограниченные первыми 20 значениями.\n\nДля каждой сессии в тестовых данных задача — предсказать идентификаторы товаров (**aid**), которые появятся для каждого типа действий после последней временной метки (**ts**) в этой сессии. Другими словами, тестовые данные содержат сессии, обрезанные по времени, и вам нужно предсказать, что произойдет после этой точки отсечения.\n\n*   **Для кликов (clicks)** существует только одно истинное значение (ground truth) для каждой сессии — это следующий товар (aid), на который нажмет пользователь (хотя вы всё равно можете предложить до 20 вариантов aid).\n*   **Для корзин (carts) и заказов (orders)** истинные значения включают в себя все товары (aid), которые были добавлены в корзину или куплены соответственно до конца сессии.","metadata":{}},{"cell_type":"code","source":"import pandas as pd\nimport polars as pl\nfrom pathlib import Path\nimport os\nimport random\nimport numpy as np\nimport json\nfrom datetime import timedelta\nfrom collections import Counter\nfrom tqdm.notebook import tqdm\nfrom heapq import nlargest\n\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nsns.set_theme()\n\nimport warnings\nwarnings.filterwarnings('ignore')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"DATA_PATH = Path('/kaggle/input/competitions/otto-recommender-system')\nTRAIN_PATH = DATA_PATH / 'train.jsonl'\nTEST_PATH = DATA_PATH / 'test.jsonl'\nSAMPLE_SUB_PATH = DATA_PATH / 'sample_submission.csv'","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-17T20:36:49.858252Z","iopub.execute_input":"2026-02-17T20:36:49.858716Z","iopub.status.idle":"2026-02-17T20:36:49.863412Z","shell.execute_reply.started":"2026-02-17T20:36:49.858685Z","shell.execute_reply":"2026-02-17T20:36:49.862545Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"Чтение тренировочных данных (первые 5 строк)\")\ntrain_sample = pl.read_ndjson(TRAIN_PATH, n_rows=5)\nprint(train_sample)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-17T20:36:51.45911Z","iopub.execute_input":"2026-02-17T20:36:51.459493Z","iopub.status.idle":"2026-02-17T20:36:51.636507Z","shell.execute_reply.started":"2026-02-17T20:36:51.459465Z","shell.execute_reply":"2026-02-17T20:36:51.635526Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"\\nПример файла submission\")\nsample_sub = pd.read_csv(SAMPLE_SUB_PATH)\nprint(sample_sub.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-17T20:36:54.640251Z","iopub.execute_input":"2026-02-17T20:36:54.641168Z","iopub.status.idle":"2026-02-17T20:37:00.049417Z","shell.execute_reply.started":"2026-02-17T20:36:54.641136Z","shell.execute_reply":"2026-02-17T20:37:00.048335Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"os.system(f'tail -n 3000000 {TRAIN_PATH} > train_subset.jsonl')\n\nprint(\"Загружаем подмножество данных...\")\ndf_train = (\n    pl.read_ndjson(\"train_subset.jsonl\")\n    .explode('events')\n    .unnest('events')\n    .with_columns([\n        pl.col('session').cast(pl.Int32),\n        pl.col('aid').cast(pl.Int32),\n        pl.col('ts').cast(pl.Int64),\n        pl.col('type').replace({'clicks': 0, 'carts': 1, 'orders': 2}).cast(pl.Int8)\n    ])\n)\n\nos.remove(\"train_subset.jsonl\")\n\nprint(f\"Готово! Размер датафрейма: {df_train.shape}\")\nprint(df_train.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-17T20:37:00.051247Z","iopub.execute_input":"2026-02-17T20:37:00.051581Z","iopub.status.idle":"2026-02-17T20:47:01.307512Z","shell.execute_reply.started":"2026-02-17T20:37:00.051552Z","shell.execute_reply":"2026-02-17T20:47:01.305759Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"type_counts = df_train['type'].value_counts().sort('type')\nprint(type_counts)\n\n# переводим в pandas для отрисовки\npdf_types = type_counts.to_pandas()\npdf_types['type_name'] = pdf_types['type'].map({0: 'Clicks', 1: 'Carts', 2: 'Orders'})\n\nplt.figure(figsize=(6, 4))\nsns.barplot(data=pdf_types, x='type_name', y='count', palette='viridis')\nplt.title('Распределение типов событий (Clicks vs Carts vs Orders)')\nplt.ylabel('Количество событий')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-17T16:08:01.264711Z","iopub.execute_input":"2026-02-17T16:08:01.265015Z","iopub.status.idle":"2026-02-17T16:08:01.670384Z","shell.execute_reply.started":"2026-02-17T16:08:01.264992Z","shell.execute_reply":"2026-02-17T16:08:01.66934Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# cчитаем количество событий на каждую сессию\nsession_lengths = df_train.group_by('session').count().rename({'count': 'event_count'})\n\n# cтатистика (минимум, максимум, среднее, медиана)\nprint(\"Статистика длины сессий:\")\nprint(session_lengths['event_count'].describe())\n\n# визуализация (обрежем хвост, чтобы график был читаемым, например, до 100 событий)\npdf_session_len = session_lengths.filter(pl.col('event_count') <= 100).to_pandas()\n\nplt.figure(figsize=(12, 5))\nsns.histplot(pdf_session_len['event_count'], bins=50, kde=True)\nplt.title('Распределение длины сессий (до 100 событий)')\nplt.xlabel('Количество событий в сессии')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-17T16:09:15.719713Z","iopub.execute_input":"2026-02-17T16:09:15.7202Z","iopub.status.idle":"2026-02-17T16:09:16.823459Z","shell.execute_reply.started":"2026-02-17T16:09:15.720169Z","shell.execute_reply":"2026-02-17T16:09:16.822638Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# топ самых популярных товаров (aid)\ntop_items = df_train['aid'].value_counts().head(20).to_pandas()\n\nplt.figure(figsize=(12, 5))\nsns.barplot(data=top_items, x=top_items.index, y='count', color='royalblue')\nplt.xticks(ticks=range(20), labels=top_items['aid'], rotation=45)\nplt.title('Топ-20 самых популярных товаров')\nplt.xlabel(\"aid\")\nplt.ylabel('Количество взаимодействий')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-17T16:11:29.153222Z","iopub.execute_input":"2026-02-17T16:11:29.153533Z","iopub.status.idle":"2026-02-17T16:11:31.069456Z","shell.execute_reply.started":"2026-02-17T16:11:29.153509Z","shell.execute_reply":"2026-02-17T16:11:31.068572Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def build_weighted_covisitation_matrix(df, type_filter=None, n_top=20, name=\"matrix\"):\n    print(f\"--- Строим взвешенную по времени матрицу: {name} ---\")\n    \n    # фильтр и оптимизация\n    if type_filter:\n        df_filtered = df.filter(pl.col(\"type\").is_in(type_filter))\n    else:\n        df_filtered = df\n        \n    df_filtered = (\n        df_filtered\n        .sort(['session', 'ts'])\n        .group_by('session')\n        .tail(30)\n    )\n    \n    # строим пары и считаем разницу во времени\n    pairs = (\n        df_filtered.lazy()\n        .join(df_filtered.lazy(), on=\"session\")\n        .filter(pl.col(\"aid\") != pl.col(\"aid_right\"))\n        # считаем разницу во времени (в миллисекундах)\n        .with_columns(\n            (pl.col(\"ts_right\") - pl.col(\"ts\")).abs().alias(\"ts_diff\")\n        )\n        # оставляем только пары, где второе событие было ПОСЛЕ первого\n        .filter(pl.col(\"ts_right\") > pl.col(\"ts\"))\n    )\n    \n    # считаем взвешенные веса\n    matrix = (\n        pairs\n        # даем вес в зависимости от времени\n        # формула: чем меньше разница во времени, тем больше вес.\n        # используем экспоненциальное затухание, но для простоты начнем с порогов.\n        .with_columns([\n            pl.when(pl.col(\"ts_diff\") < 1 * 60 * 60 * 1000).then(1.0)\n            .when(pl.col(\"ts_diff\") < 6 * 60 * 60 * 1000).then(0.5)\n            .otherwise(0.1).alias(\"time_weight\"),\n\n            pl.col(\"type_right\").replace({0: 1.0, 1: 3.0, 2: 6.0}).cast(pl.Float64).alias(\"type_weight\")\n        ])\n        .with_columns(\n            (pl.col(\"time_weight\") * pl.col(\"type_weight\")).alias(\"pair_weight\")\n        )\n        .group_by([\"aid\", \"aid_right\"])\n        .agg(pl.sum(\"pair_weight\").alias(\"weight\"))\n        .filter(pl.col(\"weight\") >= 1)\n        .sort([\"aid\", \"weight\"], descending=[False, True])\n        .group_by(\"aid\")\n        .head(n_top)\n    )\n    \n    return matrix.collect()\n\n\n# матрица кликов (оставляем как была)\nmatrix_clicks = build_weighted_covisitation_matrix(df_train, type_filter=None, n_top=20, name=\"Clicks\")\n\n# матрица для корзин (Carts + Orders)\nmatrix_carts = build_weighted_covisitation_matrix(df_train, type_filter=[1, 2], n_top=50, name=\"Carts-Orders\")\n\n# матрица ТОЛЬКО для покупок\nmatrix_buy2buy = build_weighted_covisitation_matrix(df_train, type_filter=[1, 2], n_top=50, name=\"Buy-to-Buy\")\n\n\nprint(\"\\nРазмер матрицы кликов:\", matrix_clicks.shape)\nprint(\"Размер матрицы для корзин:\", matrix_carts.shape)\nprint(\"Размер матрицы для покупок:\", matrix_buy2buy.shape)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-17T16:16:31.264413Z","iopub.execute_input":"2026-02-17T16:16:31.264797Z","execution_failed":"2026-02-17T16:16:57.609Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"Обрабатываем тест...\")\ntest_df = (\n    pl.read_ndjson(TEST_PATH)\n    .explode('events')\n    .unnest('events')\n    .with_columns([\n        pl.col('aid').cast(pl.Int32),\n        pl.col('ts').cast(pl.Int64),\n        pl.col('type').replace({'clicks': 0, 'carts': 1, 'orders': 2}).cast(pl.Int8)\n    ])\n    .sort(['session', 'ts'])\n    .group_by('session')\n    .tail(30)\n)\n\ntest_df = test_df.with_columns([\n    ((pl.col(\"ts\") - pl.col(\"ts\").min().over(\"session\")) / \n     (pl.col(\"ts\").max().over(\"session\") - pl.col(\"ts\").min().over(\"session\") + 1)).alias(\"recency_weight\")\n])\n\ndef suggest_items_weighted(test_df, matrix_df):\n    return (\n        test_df\n        .join(matrix_df, on=\"aid\", how=\"inner\")\n        .with_columns([\n            (pl.col(\"weight\") * (1 + 0.5 * pl.col(\"recency_weight\"))).alias(\"weighted_score\")\n        ])\n        .group_by([\"session\", \"aid_right\"])\n        .agg(pl.sum(\"weighted_score\").alias(\"score\"))\n        .rename({\"aid_right\": \"aid\"})\n    )\n\nprint(\"Генерируем кандидатов...\")\ncands_clicks = suggest_items_weighted(test_df, matrix_clicks)\ncands_carts = suggest_items_weighted(test_df, matrix_carts)\ncands_orders = suggest_items_weighted(test_df, matrix_buy2buy)\n\ntype_weight_map = {0: 1, 1: 10, 2: 20}\n\n# добавляем историю\nhistory_candidates = (\n    test_df\n    .with_columns([\n        pl.col(\"type\").replace(type_weight_map).cast(pl.Float64).alias(\"type_score\"),\n    ])\n    .with_columns([\n        (pl.col(\"type_score\") + pl.col(\"recency_weight\") + 1000.0).alias(\"score\")\n    ])\n    .select([\"session\", \"aid\", \"score\"])\n    .group_by([\"session\", \"aid\"])\n    .agg(pl.max(\"score\"))\n    .with_columns(pl.col(\"aid\").cast(pl.Int32))\n)\n\ndef create_submission_labels(candidates_df, session_ids, top_n=20):\n    # Объединяем с историей, чтобы просмотренные товары были в топе\n    final_cands = (\n        candidates_df\n        .vstack(history_candidates)\n        .group_by([\"session\", \"aid\"])\n        .agg(pl.sum(\"score\"))\n        .sort([\"session\", \"score\"], descending=[False, True])\n        .group_by(\"session\")\n        .head(top_n)\n    )\n\n    labels_df = (\n        final_cands\n        .group_by(\"session\")\n        .agg(pl.col(\"aid\"))\n    )\n\n    labels_df = (\n        labels_df\n        .with_columns(\n            pl.col(\"aid\").list.eval(pl.element().cast(pl.Utf8)).list.join(\" \").alias(\"labels\")\n        )\n        .select([\"session\", \"labels\"])\n    )\n    \n    return (\n        session_ids\n        .join(labels_df, on=\"session\", how=\"left\")\n        .with_columns(pl.col(\"labels\").fill_null(top_20_str)) # заглушка\n    )\n\ntop_20_list = (\n    df_train['aid']\n    .value_counts()\n    .sort('count', descending=True)\n    .head(20)['aid']\n    .cast(pl.Utf8)\n    .to_list()\n)\ntop_20_str = \" \".join(top_20_list)\n\ncands_clicks = cands_clicks.with_columns(pl.col(\"aid\").cast(pl.Int32))\ncands_carts = cands_carts.with_columns(pl.col(\"aid\").cast(pl.Int32))\ncands_orders = cands_orders.with_columns(pl.col(\"aid\").cast(pl.Int32))\nhistory_candidates = history_candidates.with_columns(pl.col(\"aid\").cast(pl.Int32))\n\n# получаем уникальные сессии для основы\nunique_sessions = test_df.select(\"session\").unique()\n\nprint(\"Формируем финальные строки...\")\npreds_clicks = create_submission_labels(cands_clicks, unique_sessions)\npreds_carts = create_submission_labels(cands_carts, unique_sessions)\npreds_orders = create_submission_labels(cands_orders, unique_sessions)\n\n# сборка файла\nprint(\"Сохраняем submission...\")\nsubmission = (\n    preds_clicks.select([\n        (pl.col(\"session\").cast(pl.Utf8) + \"_clicks\").alias(\"session_type\"),\n        pl.col(\"labels\")\n    ])\n    .vstack(\n        preds_carts.select([\n            (pl.col(\"session\").cast(pl.Utf8) + \"_carts\").alias(\"session_type\"),\n            pl.col(\"labels\")\n        ])\n    )\n    .vstack(\n        preds_orders.select([\n            (pl.col(\"session\").cast(pl.Utf8) + \"_orders\").alias(\"session_type\"),\n            pl.col(\"labels\")\n        ])\n    )\n)\n\nsubmission.write_csv(\"submission.csv\")","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}