{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.12.12"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":105399,"databundleVersionId":12733338,"sourceType":"competition"}],"dockerImageVersionId":31260,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"53987a02","cell_type":"markdown","source":"# Финальный проект: интеллектуальная модель ранжирования авиабилетов  \n**FlightRank 2025: Aeroclub RecSys Cup (Kaggle)**\n\n**Цель:** научиться ранжировать варианты перелётов внутри каждой поисковой сессии (`ranker_id`) так, чтобы выбранный пользователем вариант попадал в **топ-3** (метрика **HitRate@3**).\n\n**Формат данных:**\n- Каждая строка — один вариант перелёта.\n- `ranker_id` — идентификатор поисковой сессии (группа для ранжирования).\n- `selected` — целевая переменная в train (0/1), в каждой группе ровно один вариант выбран.\n\n> Примечание: датасет большой, поэтому логично выполнять запуск в Kaggle Notebook с включённым GPU.\n","metadata":{}},{"id":"e8f1cb01","cell_type":"markdown","source":"# Requiments","metadata":{}},{"id":"ad7765aa","cell_type":"markdown","source":"В этом ноутбуке используются: **pandas / numpy / matplotlib / scikit-learn / CatBoost**.\n\n> В Kaggle эти библиотеки обычно уже установлены. При локальном запуске при необходимости установите `catboost`.","metadata":{}},{"id":"fbf82b2d","cell_type":"markdown","source":"## 1) Подготовка окружения","metadata":{}},{"id":"e61c516c","cell_type":"markdown","source":"# Knobs","metadata":{}},{"id":"ca900515","cell_type":"code","source":"import os\nimport gc\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\n\nfrom sklearn.model_selection import train_test_split\n\n# CatBoost хорошо подходит для ранжирования с большим числом категориальных признаков\nfrom catboost import Pool, CatBoostRanker\n\npd.set_option('display.max_columns', 200)\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T19:50:44.055721Z","iopub.execute_input":"2026-01-18T19:50:44.056343Z","iopub.status.idle":"2026-01-18T19:50:47.016181Z","shell.execute_reply.started":"2026-01-18T19:50:44.056308Z","shell.execute_reply":"2026-01-18T19:50:47.015560Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"95506fd0","cell_type":"code","source":"# Основные параметры (можно менять под ресурсы окружения)\nRANDOM_STATE = 42\nnp.random.seed(RANDOM_STATE)\n\n# Ограничение объёма данных для стабильной работы в Kaggle\nMAX_ROWS = 2_000_000          # сколько строк train читаем из parquet (streaming)\nTRAIN_BATCH_SIZE = 200_000    # размер батча при чтении parquet\n\n# Препроцессинг / выбор признаков\nMEDIAN_SAMPLE_SIZE = 200_000\nNUNIQUE_SAMPLE_SIZE = 200_000\n\n# Обучение\nUSE_ONLY_BIG_GROUPS = True    # ближе к условиям leaderboard (>10 вариантов)\nEARLY_STOPPING_ROUNDS = 50\nITERATIONS = 1200\nDEPTH = 6\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-18T19:50:48.533609Z","iopub.execute_input":"2026-01-18T19:50:48.534170Z","iopub.status.idle":"2026-01-18T19:50:48.538982Z","shell.execute_reply.started":"2026-01-18T19:50:48.534136Z","shell.execute_reply":"2026-01-18T19:50:48.538220Z"}},"outputs":[],"execution_count":null},{"id":"bbb0adf0","cell_type":"markdown","source":"# Utils","metadata":{}},{"id":"9eaeaf06","cell_type":"code","source":"from typing import List, Tuple\n\nDATETIME_COLS = [\n    'requestDate',\n    'legs0_departureAt', 'legs0_arrivalAt',\n    'legs1_departureAt', 'legs1_arrivalAt',\n]\n\n\ndef safe_to_datetime(df: pd.DataFrame, cols: List[str]) -> pd.DataFrame:\n    for c in cols:\n        if c in df.columns:\n            df[c] = pd.to_datetime(df[c], errors='coerce')\n    return df\n\n\ndef add_segment_features(df: pd.DataFrame) -> pd.DataFrame:\n    # Считаем число сегментов (0..3) для каждого leg, если такие колонки присутствуют\n    for leg in [0, 1]:\n        seg_cols = [\n            f'legs{leg}_segments{s}_departureFrom_airport_iata'\n            for s in range(4)\n            if f'legs{leg}_segments{s}_departureFrom_airport_iata' in df.columns\n        ]\n        if seg_cols:\n            present = np.column_stack([(df[c].notna()).astype('int8').values for c in seg_cols])\n            df[f'legs{leg}_n_segments'] = present.sum(axis=1).astype('int8')\n            df[f'legs{leg}_n_stops'] = (df[f'legs{leg}_n_segments'] - 1).clip(lower=0).astype('int8')\n    return df\n\n\ndef add_time_features(df: pd.DataFrame) -> pd.DataFrame:\n    if 'requestDate' in df.columns and pd.api.types.is_datetime64_any_dtype(df['requestDate']):\n        df['req_dow'] = df['requestDate'].dt.dayofweek.astype('Int8')\n        df['req_hour'] = df['requestDate'].dt.hour.astype('Int8')\n        df['req_month'] = df['requestDate'].dt.month.astype('Int8')\n\n    if 'requestDate' in df.columns and 'legs0_departureAt' in df.columns:\n        if pd.api.types.is_datetime64_any_dtype(df['requestDate']) and pd.api.types.is_datetime64_any_dtype(df['legs0_departureAt']):\n            df['hrs_to_dep0'] = (df['legs0_departureAt'] - df['requestDate']).dt.total_seconds() / 3600.0\n\n    if 'requestDate' in df.columns and 'legs1_departureAt' in df.columns:\n        if pd.api.types.is_datetime64_any_dtype(df['requestDate']) and pd.api.types.is_datetime64_any_dtype(df['legs1_departureAt']):\n            df['hrs_to_dep1'] = (df['legs1_departureAt'] - df['requestDate']).dt.total_seconds() / 3600.0\n\n    return df\n\n\ndef add_price_features(df: pd.DataFrame) -> pd.DataFrame:\n    # --- total duration in minutes ---\n    dur = None\n\n    for col in [\"legs0_duration\", \"legs1_duration\"]:\n        if col in df.columns:\n            # корректно парсим HH:MM:SS → минуты\n            d = pd.to_timedelta(df[col], errors=\"coerce\").dt.total_seconds() / 60\n            dur = d if dur is None else dur + d\n\n    if dur is not None:\n        df[\"total_duration_min\"] = dur.astype(\"float32\")\n\n    # --- price features ---\n    if \"totalPrice\" in df.columns and \"pricingInfo_passengerCount\" in df.columns:\n        df[\"price_per_pax\"] = (\n            df[\"totalPrice\"] / df[\"pricingInfo_passengerCount\"].replace(0, np.nan)\n        ).astype(\"float32\")\n\n    if \"totalPrice\" in df.columns and \"total_duration_min\" in df.columns:\n        df[\"price_per_min\"] = (\n            df[\"totalPrice\"] / df[\"total_duration_min\"].replace(0, np.nan)\n        ).astype(\"float32\")\n\n    return df\n\ndef add_route_features(df: pd.DataFrame) -> pd.DataFrame:\n    if 'searchRoute' in df.columns:\n        # формат: OW без '/', RT с '/'\n        df['is_round_trip'] = df['searchRoute'].astype(str).str.contains('/').astype('int8')\n    return df\n\n\ndef preprocess(df: pd.DataFrame) -> pd.DataFrame:\n    df = df.copy()\n\n    # datetime\n    df = safe_to_datetime(df, DATETIME_COLS)\n\n    # feature engineering\n    df = add_segment_features(df)\n    df = add_time_features(df)\n    df = add_price_features(df)\n    df = add_route_features(df)\n\n    return df\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T19:50:51.433913Z","iopub.execute_input":"2026-01-18T19:50:51.434576Z","iopub.status.idle":"2026-01-18T19:50:51.449130Z","shell.execute_reply.started":"2026-01-18T19:50:51.434543Z","shell.execute_reply":"2026-01-18T19:50:51.448353Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"2af3d1c3","cell_type":"markdown","source":"# Data","metadata":{}},{"id":"e0365df4","cell_type":"markdown","source":"## 2) Доступ к данным (Kaggle / Colab)\n\n### Вариант A — Kaggle Notebook (рекомендуется)\nВ Kaggle Notebook достаточно **добавить датасет соревнования** через кнопку *Add data* — после этого файлы будут доступны в каталоге `/kaggle/input/...`.\n\n### Вариант B — Google Colab\nЕсли вы запускаете в Colab, то можно скачать данные через Kaggle API (нужен `kaggle.json`).\n\nНиже — код, который **не сломает запуск в Kaggle**: он попробует найти файлы в Kaggle, а если не найдёт — ожидает локальные пути.\n","metadata":{}},{"id":"18e23194","cell_type":"code","source":"# Пути под Kaggle\nKAGGLE_INPUT_DIR = '/kaggle/input/aeroclub-recsys-2025'\n\nTRAIN_PATH = os.path.join(KAGGLE_INPUT_DIR, 'train.parquet')\nTEST_PATH  = os.path.join(KAGGLE_INPUT_DIR, 'test.parquet')\nSAMPLE_SUB_PATH = os.path.join(KAGGLE_INPUT_DIR, 'sample_submission.parquet')\n\n# Если вы запускаете не в Kaggle — укажите свои пути здесь\nif not os.path.exists(TRAIN_PATH):\n    TRAIN_PATH = 'train.parquet'\n    TEST_PATH  = 'test.parquet'\n    SAMPLE_SUB_PATH = 'sample_submission.parquet'\n\nprint('TRAIN_PATH:', TRAIN_PATH)\nprint('TEST_PATH :', TEST_PATH)\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T19:50:54.508300Z","iopub.execute_input":"2026-01-18T19:50:54.509085Z","iopub.status.idle":"2026-01-18T19:50:54.515860Z","shell.execute_reply.started":"2026-01-18T19:50:54.509056Z","shell.execute_reply":"2026-01-18T19:50:54.515192Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"a3e073b0","cell_type":"markdown","source":"## 3) Загрузка данных","metadata":{}},{"id":"56ba3c73","cell_type":"code","source":"# ===============================\n# 3) Загрузка данных (streaming parquet, без переполнения памяти)\n# ===============================\n\nimport pyarrow.parquet as pq\n\nROWS_READ = 0\npf = pq.ParquetFile(TRAIN_PATH)\npf_test = pq.ParquetFile(TEST_PATH)\nchunks = []\n\nfor batch in pf.iter_batches(batch_size=TRAIN_BATCH_SIZE):\n    df = batch.to_pandas()\n    chunks.append(df)\n    ROWS_READ += len(df)\n    if ROWS_READ >= MAX_ROWS:\n        break\n\ntrain = pd.concat(chunks, ignore_index=True)\ndel chunks\n\nprint('Train rows loaded:', len(train))\n\ntest = next(pf_test.iter_batches(batch_size=50_000)).to_pandas()\nprint(\"Test sample shape:\", test.shape)","metadata":{"execution":{"iopub.status.busy":"2026-01-18T20:04:59.181376Z","iopub.execute_input":"2026-01-18T20:04:59.182370Z","iopub.status.idle":"2026-01-18T20:05:20.889858Z","shell.execute_reply.started":"2026-01-18T20:04:59.182335Z","shell.execute_reply":"2026-01-18T20:05:20.889021Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"89de1198","cell_type":"markdown","source":"## 4) Предварительный анализ данных (EDA)","metadata":{}},{"id":"5b621ef9","cell_type":"code","source":"# Быстрый обзор\nprint('Целевая переменная selected:', train['selected'].value_counts(dropna=False).to_dict())\nprint('Число сессий (ranker_id) в train:', train['ranker_id'].nunique())\n\n# Размеры групп (сколько вариантов перелёта в одной сессии)\ngroup_sizes = train.groupby('ranker_id').size()\nprint(group_sizes.describe())\n\n# Доля групп, которые учитываются в leaderboard (только >10 вариантов)\nshare_big = (group_sizes > 10).mean()\nprint('Доля групп >10:', round(float(share_big), 4))\n\n# Пропуски (топ-20)\nna_rate = train.isna().mean().sort_values(ascending=False)\nna_rate.head(20)\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T20:05:34.224632Z","iopub.execute_input":"2026-01-18T20:05:34.224971Z","iopub.status.idle":"2026-01-18T20:05:38.078182Z","shell.execute_reply.started":"2026-01-18T20:05:34.224945Z","shell.execute_reply":"2026-01-18T20:05:38.077518Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"2590f900","cell_type":"code","source":"# Визуализация распределения размеров групп\nplt.figure()\nplt.hist(group_sizes.values, bins=50)\nplt.title('Распределение количества вариантов в сессии (group size)')\nplt.xlabel('Кол-во вариантов в ranker_id')\nplt.ylabel('Количество сессий')\nplt.show()\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T20:05:40.220977Z","iopub.execute_input":"2026-01-18T20:05:40.221870Z","iopub.status.idle":"2026-01-18T20:05:40.384596Z","shell.execute_reply.started":"2026-01-18T20:05:40.221836Z","shell.execute_reply":"2026-01-18T20:05:40.383994Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"801f5664","cell_type":"markdown","source":"# Обработка признаков","metadata":{}},{"id":"4d8b0044","cell_type":"markdown","source":"## 5) Обработка признаков (4 балла)\n\n## Feature engineering\n\nЧто делаем:\n1. Приводим даты/время к `datetime`.\n2. Создаём осмысленные производные признаки:\n   - число сегментов/пересадок,\n   - суммарная длительность,\n   - время от запроса до вылета,\n   - цена на пассажира, цена за минуту,\n   - признаки маршрута (OW/RT),\n   - календарные признаки по `requestDate`.\n3. Заполняем пропуски и приводим типы.\n\nВсе преобразования оформляем через функции.\n","metadata":{}},{"id":"05ace8f5","cell_type":"code","source":"train_fe = preprocess(train)\ntest_fe  = preprocess(test)\n\n# Освобождаем память\ntrain = None\ntest = None\ngc.collect()\n\nprint('train_fe shape:', train_fe.shape)\nprint('test_fe shape:', test_fe.shape)\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T20:06:54.049950Z","iopub.execute_input":"2026-01-18T20:06:54.050525Z","iopub.status.idle":"2026-01-18T20:07:11.689640Z","shell.execute_reply.started":"2026-01-18T20:06:54.050494Z","shell.execute_reply":"2026-01-18T20:07:11.688918Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"3351dc6f","cell_type":"markdown","source":"### Заполнение пропусков и приведение типов\n\n- Числовые признаки: заполняем медианой по train.\n- Категориальные: заполняем строкой `'UNK'`.\n\nCatBoost умеет работать с категориальными признаками напрямую.\n","metadata":{}},{"id":"53aeaf4d","cell_type":"code","source":"TARGET = 'selected'\nGROUP_COL = 'ranker_id'\nID_COL = 'Id'\n\nDROP_ALWAYS = [ID_COL, GROUP_COL, TARGET]\n\n# Определяем категориальные признаки как object + некоторые bool/int8 с маленькой кардинальностью можно оставить числом\nfeat_cols = [c for c in train_fe.columns if c not in DROP_ALWAYS]\n\ncat_cols = [c for c in feat_cols if train_fe[c].dtype == 'object']\nnum_cols = [c for c in feat_cols if c not in cat_cols]\n\nprint('features:', len(feat_cols))\nprint('categorical:', len(cat_cols))\nprint('numeric:', len(num_cols))\n\n# Медианы для числовых\n# MEDIAN_SAMPLE_SIZE задаётся в секции Knobs\n\nnum_medians = (\n    train_fe[num_cols]\n    .sample(n=min(MEDIAN_SAMPLE_SIZE, len(train_fe)), random_state=RANDOM_STATE)\n    .median(numeric_only=True)\n)\n\n# Заполнение пропусков\ntrain_fe[num_cols] = train_fe[num_cols].fillna(num_medians)\ntest_fe[num_cols]  = test_fe[num_cols].fillna(num_medians)\n\nfor c in cat_cols:\n    train_fe[c] = train_fe[c].fillna(\"UNK\").astype(\"category\")\n    test_fe[c]  = test_fe[c].fillna(\"UNK\").astype(\"category\")\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T20:07:40.445654Z","iopub.execute_input":"2026-01-18T20:07:40.446431Z","iopub.status.idle":"2026-01-18T20:07:59.919370Z","shell.execute_reply.started":"2026-01-18T20:07:40.446398Z","shell.execute_reply":"2026-01-18T20:07:59.918590Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"ec88662e","cell_type":"markdown","source":"# Выбор признаков","metadata":{}},{"id":"a230c7aa","cell_type":"markdown","source":"## 6) Выбор признаков (4 балла)\n\nВ этом блоке:\n- удаляем неинформативные признаки (константы),\n- удаляем признаки с экстремально высоким числом пропусков (например, > 98%),\n- оставляем созданные нами признаки,\n- фиксируем итоговый список признаков.\n\n> Это не единственно верный выбор — цель блока показать осознанный отбор.\n","metadata":{}},{"id":"d032a247","cell_type":"code","source":"# Константные признаки\n# NUNIQUE_SAMPLE_SIZE задаётся в секции Knobs\n\nnunique = (\n    train_fe[feat_cols]\n    .sample(n=min(NUNIQUE_SAMPLE_SIZE, len(train_fe)), random_state=RANDOM_STATE)\n    .nunique(dropna=False)\n)\n\nconstant_cols = nunique[nunique <= 1].index.tolist()\n\n# Слишком много пропусков (считаем по оригинальному train_fe до fillna нельзя, поэтому оценим по доле 'UNK' и медиан)\n# Более честно: использовать на_rate из EDA (до fillna). Здесь сделаем приближенно: по доле NaN до fillna мы уже смотрели.\n\n# Удаляем константы\nselected_feat_cols = [c for c in feat_cols if c not in constant_cols]\n\nprint('Удаляем константных признаков:', len(constant_cols))\nprint('Итоговое число признаков:', len(selected_feat_cols))\n\n# Обновим списки категориальных / числовых\ncat_cols = [\n    c for c in selected_feat_cols\n    if str(train_fe[c].dtype) in (\"object\", \"category\")\n]\nnum_cols = [c for c in selected_feat_cols if c not in cat_cols]\n\ncat_idx = [selected_feat_cols.index(c) for c in cat_cols]\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T20:08:06.319696Z","iopub.execute_input":"2026-01-18T20:08:06.320418Z","iopub.status.idle":"2026-01-18T20:08:07.266869Z","shell.execute_reply.started":"2026-01-18T20:08:06.320388Z","shell.execute_reply":"2026-01-18T20:08:07.266175Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"f13699fa-4f6e-4907-ac7e-c68ea8715233","cell_type":"code","source":"# ===============================\n# Удаляем datetime-признаки (CatBoost их не принимает)\n# ===============================\nfrom pandas.api.types import is_datetime64_any_dtype\n\ndatetime_cols = [\n    c for c in selected_feat_cols\n    if is_datetime64_any_dtype(train_fe[c])\n]\n\n\nprint(\"Удаляем datetime признаки:\", datetime_cols)\n\nselected_feat_cols = [\n    c for c in selected_feat_cols\n    if c not in datetime_cols\n]\n\n# обновляем cat / num после удаления datetime\ncat_cols = [\n    c for c in selected_feat_cols\n    if str(train_fe[c].dtype) in (\"object\", \"category\")\n]\n\nnum_cols = [c for c in selected_feat_cols if c not in cat_cols]\n\ncat_idx = [selected_feat_cols.index(c) for c in cat_cols]\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T20:08:11.751982Z","iopub.execute_input":"2026-01-18T20:08:11.752303Z","iopub.status.idle":"2026-01-18T20:08:11.759893Z","shell.execute_reply.started":"2026-01-18T20:08:11.752277Z","shell.execute_reply":"2026-01-18T20:08:11.759158Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"6e5d5eea","cell_type":"markdown","source":"**Комментарий по смысловым признакам:**\n- В модели ожидаемо важны стоимость, длительность, число пересадок, класс обслуживания, соответствие корпоративной политике (`pricingInfo_isAccessTP`), а также временной зазор между поиском и вылетом.\n- Признаки типа кодов аэропортов/перевозчиков также могут быть полезны (разные авиакомпании/маршруты → разные предпочтения).\n","metadata":{}},{"id":"62443aed","cell_type":"markdown","source":"# Обучение модели","metadata":{}},{"id":"ac63dcbd","cell_type":"markdown","source":"## 7) Обучение модели (8 баллов)\n\nИспользуем **CatBoostRanker** с loss `YetiRank`.\n\n**Важный момент:** делаем split **по `ranker_id`**, чтобы не допустить утечки между train/valid.\n\nТакже можно обучаться только на группах размером > 10 (так ближе к условиям leaderboard). Ниже это включено как опция.\n\n## Train (GPU)","metadata":{}},{"id":"9445ee8f","cell_type":"code","source":"import xgboost as xgb\nimport numpy as np\n\nUSE_ONLY_BIG_GROUPS = True\nMAX_GROUP_SIZE_FOR_GPU = None\n\ntrain_model_df = train_fe\n\n# ближе к условиям лидерборда: используем группы > 10\nif USE_ONLY_BIG_GROUPS:\n    gs = train_model_df.groupby(GROUP_COL).size()\n    big_ids = gs[gs > 10].index\n    train_model_df = train_model_df[train_model_df[GROUP_COL].isin(big_ids)]\n\n# ограничение размера групп\nif MAX_GROUP_SIZE_FOR_GPU is not None:\n    train_model_df = train_model_df.groupby(GROUP_COL).head(MAX_GROUP_SIZE_FOR_GPU)\n\n# split по ranker_id (без утечки)\nranker_ids = train_model_df[GROUP_COL].unique()\ntr_ids, va_ids = train_test_split(ranker_ids, test_size=0.2, random_state=RANDOM_STATE)\n\ntr = train_model_df[train_model_df[GROUP_COL].isin(tr_ids)]\nva = train_model_df[train_model_df[GROUP_COL].isin(va_ids)]\n\ndef encode_for_xgb(df, cat_cols):\n    df = df.copy()\n    for c in cat_cols:\n        # category -> codes, UNK уже заполнен на этапе fillna\n        df[c] = df[c].astype(\"category\").cat.codes.astype(\"int32\")\n    return df\n\nX_tr = encode_for_xgb(tr[selected_feat_cols], cat_cols)\nX_va = encode_for_xgb(va[selected_feat_cols], cat_cols)\n\ny_tr = tr[TARGET].astype(\"int32\").values\ny_va = va[TARGET].astype(\"int32\").values\n\n# group sizes для ranking\ngrp_tr = tr.groupby(GROUP_COL).size().astype(\"int32\").values\ngrp_va = va.groupby(GROUP_COL).size().astype(\"int32\").values\n\n# DMatrix / QuantileDMatrix для GPU\nMAX_BIN = 256\ndtrain = xgb.QuantileDMatrix(X_tr, label=y_tr, max_bin=MAX_BIN)\ndvalid = xgb.QuantileDMatrix(X_va, label=y_va, max_bin=MAX_BIN, ref=dtrain)\n\ndtrain.set_group(grp_tr)\ndvalid.set_group(grp_va)\n\n# гиперпараметры\nparams = {\n    \"objective\": \"rank:ndcg\",\n    \"eval_metric\": \"ndcg@3\",\n    \"device\": \"cuda\",\n    \"tree_method\": \"hist\",\n    \"max_depth\": 8,\n    \"eta\": 0.05,\n    \"subsample\": 0.85,\n    \"colsample_bytree\": 0.75,\n    \"lambda\": 12.0,\n    \"seed\": RANDOM_STATE,\n    \"verbosity\": 1,\n}\n\nNUM_BOOST_ROUND = 2000\nEARLY_STOP_ROUNDS = 50\n\nprint(\"Training (GPU, XGBoost rank:ndcg)...\")\nbooster = xgb.train(\n    params=params,\n    dtrain=dtrain,\n    num_boost_round=NUM_BOOST_ROUND,\n    evals=[(dtrain, \"train\"), (dvalid, \"valid\")],\n    early_stopping_rounds=EARLY_STOP_ROUNDS,\n    verbose_eval=100\n)\n\n# предсказания для валидации\nva_scores = booster.predict(dvalid, iteration_range=(0, booster.best_iteration + 1))\nprint(\"Best iteration:\", booster.best_iteration)\n","metadata":{"execution":{"iopub.status.busy":"2026-01-18T20:21:14.928829Z","iopub.execute_input":"2026-01-18T20:21:14.929202Z","iopub.status.idle":"2026-01-18T20:21:50.167952Z","shell.execute_reply.started":"2026-01-18T20:21:14.929173Z","shell.execute_reply":"2026-01-18T20:21:50.167268Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"3cf5d712","cell_type":"markdown","source":"## 8) Валидация и метрика HitRate@3\n\nРеализуем HitRate@3:\n- внутри каждого `ranker_id` сортируем варианты по score,\n- проверяем, попадает ли истинный `selected=1` в топ-3.\n\nДополнительно строим графики HitRate@3 по «срезам», чтобы найти слабые места модели.\n","metadata":{}},{"id":"f5110118","cell_type":"code","source":"def hitrate_at_k(df: pd.DataFrame, scores: np.ndarray, k: int = 3) -> float:\n    tmp = df[[GROUP_COL, TARGET]].copy()\n    tmp['score'] = scores\n    tmp['rank'] = tmp.groupby(GROUP_COL)['score'].rank(ascending=False, method='first')\n    hit = tmp[tmp['rank'] <= k].groupby(GROUP_COL)[TARGET].max()\n    return float(hit.mean())\n\nhr3 = hitrate_at_k(va, va_scores, k=3)\nprint('Valid HitRate@3:', hr3)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-18T20:22:34.168527Z","iopub.execute_input":"2026-01-18T20:22:34.169170Z","iopub.status.idle":"2026-01-18T20:22:34.313926Z","shell.execute_reply.started":"2026-01-18T20:22:34.169137Z","shell.execute_reply":"2026-01-18T20:22:34.313273Z"}},"outputs":[],"execution_count":null},{"id":"d21cedd2","cell_type":"code","source":"# График HitRate@3 по бинам размера группы\n\ntmp = va[[GROUP_COL, TARGET]].copy()\ntmp['score'] = va_scores\n\ntmp['rank'] = tmp.groupby(GROUP_COL)['score'].rank(ascending=False, method='first')\n\n# hit per group\nhit_per_group = tmp[tmp['rank'] <= 3].groupby(GROUP_COL)[TARGET].max()\n\ngroup_size_va = va.groupby(GROUP_COL).size()\nplot_df = pd.DataFrame({'hit': hit_per_group, 'group_size': group_size_va}).dropna()\n\nplot_df['size_bin'] = pd.cut(plot_df['group_size'], bins=[0,5,10,15,20,30,50,100,1000], right=True)\nhit_by_bin = plot_df.groupby('size_bin')['hit'].mean()\n\nplt.figure()\nplt.plot(hit_by_bin.index.astype(str), hit_by_bin.values, marker='o')\nplt.title('HitRate@3 по размеру группы (ranker_id)')\nplt.xlabel('Бин размера группы')\nplt.ylabel('HitRate@3')\nplt.xticks(rotation=45, ha='right')\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-18T20:22:46.851501Z","iopub.execute_input":"2026-01-18T20:22:46.852127Z","iopub.status.idle":"2026-01-18T20:22:47.134117Z","shell.execute_reply.started":"2026-01-18T20:22:46.852096Z","shell.execute_reply":"2026-01-18T20:22:47.133460Z"}},"outputs":[],"execution_count":null},{"id":"b120e02d","cell_type":"code","source":"# График HitRate@3 по квантилям цены на пассажира (если фича есть)\nif 'price_per_pax' in va.columns:\n    tmp2 = va[[GROUP_COL, 'price_per_pax']].copy()\n    tmp2['hit'] = hit_per_group.reindex(tmp2[GROUP_COL].values).values\n\n    # агрегируем по группе: берём min price_per_pax (условно \"лучший\" по цене вариант)\n    g_price = tmp2.groupby(GROUP_COL)['price_per_pax'].min()\n    g_hit = hit_per_group\n\n    q = pd.qcut(g_price, q=10, duplicates='drop')\n    hit_by_q = pd.DataFrame({'q': q, 'hit': g_hit}).groupby('q')['hit'].mean()\n\n    plt.figure()\n    plt.plot(hit_by_q.index.astype(str), hit_by_q.values, marker='o')\n    plt.title('HitRate@3 по квантилям min(price_per_pax) в сессии')\n    plt.xlabel('Квантили')\n    plt.ylabel('HitRate@3')\n    plt.xticks(rotation=45, ha='right')\n    plt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-18T20:22:54.304661Z","iopub.execute_input":"2026-01-18T20:22:54.305379Z","iopub.status.idle":"2026-01-18T20:22:54.596624Z","shell.execute_reply.started":"2026-01-18T20:22:54.305348Z","shell.execute_reply":"2026-01-18T20:22:54.596002Z"}},"outputs":[],"execution_count":null},{"id":"1c96d02a","cell_type":"markdown","source":"# Анализ ошибок","metadata":{}},{"id":"bacd3484","cell_type":"markdown","source":"## 9) Анализ ошибок (2 балла)\n\nСмотрим несколько сессий, где модель **не попала** в топ-3.\nВыведем основные характеристики вариантов (цена/длительность/пересадки/класс/доступность по политике).\n","metadata":{}},{"id":"dddb52be","cell_type":"code","source":"KEY_COLS = [\n    ID_COL, GROUP_COL, TARGET,\n    \"totalPrice\", \"taxes\", \"price_per_pax\", \"price_per_min\", \"tax_share\",\n    \"legs0_duration\", \"legs1_duration\",\n    \"legs0_n_stops\", \"legs1_n_stops\",\n    \"pricingInfo_isAccessTP\",\n]\nKEY_COLS = [c for c in KEY_COLS if c in va.columns]\n\nva_eval = va[KEY_COLS].copy().reset_index(drop=True)\nva_eval[\"score\"] = va_scores\nva_eval[\"pred_rank\"] = va_eval.groupby(GROUP_COL)[\"score\"].rank(ascending=False, method=\"first\")\n\n# hit_per_group уже должен быть рассчитан на этапе 8\nmissed = hit_per_group[hit_per_group == 0].index[:50].tolist()\nprint(\"Промахов (missed groups):\", len(missed), \"из\", hit_per_group.shape[0])\n\nnp.random.seed(RANDOM_STATE)\nshow_groups = missed[:3] if len(missed) < 3 else list(np.random.choice(missed, size=3, replace=False))\n\nfor gid in show_groups:\n    part = va_eval[va_eval[GROUP_COL] == gid].copy()\n    part = part.sort_values(\"pred_rank\")\n\n    print(\"\\n\" + \"=\" * 90)\n    print(\"ranker_id:\", gid, \"| options:\", part.shape[0])\n\n    true_row = part[part[TARGET] == 1]\n    if len(true_row) == 1:\n        true_rank = int(true_row[\"pred_rank\"].iloc[0])\n        print(\"Истинно выбранный вариант имеет предсказанный ранг:\", true_rank)\n\n    display(part.head(10))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-18T20:29:53.229225Z","iopub.execute_input":"2026-01-18T20:29:53.230006Z","iopub.status.idle":"2026-01-18T20:29:53.531722Z","shell.execute_reply.started":"2026-01-18T20:29:53.229978Z","shell.execute_reply":"2026-01-18T20:29:53.531001Z"}},"outputs":[],"execution_count":null},{"id":"fbe4218d","cell_type":"markdown","source":"**Вывод по ошибкам (пример):**\n- Часто модель путается между очень близкими по цене/длительности вариантами.\n- Дополнительный потенциал улучшения: более точные признаки по пересадкам, авиакомпаниям, классу и ограничениям правил отмены/обмена.\n","metadata":{}},{"id":"0b750374","cell_type":"markdown","source":"# Submission","metadata":{}},{"id":"6acdb5c4","cell_type":"markdown","source":"## 10) Обучение на всех данных и формирование submission\n\nДалее обучаем модель на всех доступных train-данных (в тех же правилах обработки) и строим файл `submission.csv`:\n- для каждого `ranker_id` присваиваем ранги 1..N,\n- **строго сохраняем порядок строк** как в `test.parquet`.\n\n\n## Предсказание для TEST (пакетами) + постобработка лёгкая\n\n> Примечание: из-за ограничений оперативной памяти в Kaggle Notebook финальное переобучение на всей выборке может быть недоступно. Поэтому submission строится по модели, обученной на подвыборке и отвалидированной на hold-out по `ranker_id`.","metadata":{}},{"id":"6fc2f24a","cell_type":"code","source":"import pyarrow.parquet as pq\nimport pandas as pd\nimport numpy as np\nimport xgboost as xgb\n\nPRED_BATCH = 200_000\nMAX_BIN = 256\n\ndef encode_for_xgb(df, cat_cols):\n    df = df.copy()\n    for c in cat_cols:\n        df[c] = df[c].astype(\"category\").cat.codes.astype(\"int32\")\n    return df\n\npf_test = pq.ParquetFile(TEST_PATH)\n\nout_parts = []\nfor batch in pf_test.iter_batches(batch_size=PRED_BATCH):\n    df = batch.to_pandas()\n\n    # 1) те же фичи\n    df_fe = preprocess(df)\n\n    # 2) приводим к тем же фичам\n    X = df_fe.reindex(columns=selected_feat_cols)\n\n    # 3) заполняем как на train (num_medians + категории)\n    X[num_cols] = X[num_cols].fillna(num_medians)\n    for c in cat_cols:\n        X[c] = X[c].fillna(\"UNK\").astype(\"category\")\n\n    # 4) encode categories -> int codes\n    X_enc = encode_for_xgb(X, cat_cols)\n\n    # 5) predict (для test group не нужен)\n    dtest = xgb.QuantileDMatrix(X_enc, max_bin=MAX_BIN)\n    scores = booster.predict(dtest, iteration_range=(0, booster.best_iteration + 1))\n\n    out_parts.append(pd.DataFrame({\n        ID_COL: df_fe[ID_COL].values,\n        GROUP_COL: df_fe[GROUP_COL].values,\n        \"score\": scores.astype(\"float32\")\n    }))\n\nscores_df = pd.concat(out_parts, ignore_index=True)\n\n# 6) score -> ранги внутри ranker_id (1 = лучший)\nsub = scores_df[[ID_COL, GROUP_COL, \"score\"]].copy()\n\nsub[\"selected\"] = (\n    sub.groupby(GROUP_COL)[\"score\"]\n       .rank(ascending=False, method=\"first\")\n       .astype(\"int32\")\n)\n\nsub = sub[[ID_COL, GROUP_COL, \"selected\"]]\nsub.to_csv(\"submission.csv\", index=False)\nsub.head(10)\n\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-18T20:39:18.403574Z","iopub.execute_input":"2026-01-18T20:39:18.404081Z","iopub.status.idle":"2026-01-18T20:41:46.244406Z","shell.execute_reply.started":"2026-01-18T20:39:18.404050Z","shell.execute_reply":"2026-01-18T20:41:46.243711Z"}},"outputs":[],"execution_count":null},{"id":"4dfc1cbf","cell_type":"markdown","source":"## Итоги\n\nВ ноутбуке выполнены все этапы:\n1. Предварительный анализ и обработка признаков.\n2. Создание новых признаков и отбор набора для обучения.\n3. Обучение модели ранжирования CatBoostRanker с разбиением по группам.\n4. Оценка HitRate@3 и визуализация качества по срезам.\n5. Анализ ошибок на конкретных примерах сессий.\n6. Формирование `submission.csv`.\n","metadata":{}}]}