{"metadata":{"kernelspec":{"display_name":"kaggle","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.7"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":35332,"databundleVersionId":3723648,"isSourceIdPinned":false}],"dockerImageVersionId":31287,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"c902f022","cell_type":"markdown","source":"Competition https://www.kaggle.com/competitions/amex-default-prediction/data\n\nНа Kaggle наша команда не выступала до этого совсем, также как и не обучала LigtGBM - поэтому для нас участие в этом соревновании будет серьезным вызовом, ровно так же как и опытом.","metadata":{}},{"id":"c3dfec82","cell_type":"code","source":"FIRST_RUN     = False  # True → читать CSV и создать parquet; False → загружать из parquet\nIS_FIRST_RNN_RUN = False  # True → строить X_tr/X_te и сохранить npz; False → загружать из npz\n","metadata":{},"outputs":[],"execution_count":null},{"id":"edad4248","cell_type":"code","source":"'''\npackages used from \n- lightgbm tutorial https://www.kaggle.com/code/avanwyk/a-lightgbm-overview\n- CV vs LB check notebook https://www.kaggle.com/code/wsolcor/why-your-cross-validation-is-lying-cv-vs-lb\n'''\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\n\nfrom sklearn.model_selection import StratifiedKFold\n\nfrom lightgbm import LGBMClassifier\n\nfrom cvguard import check_cv\n\nimport seaborn as sns\nimport matplotlib as mpl\nimport matplotlib.pyplot as plt\n\nfrom sklearn.metrics import roc_auc_score\nfrom sklearn.metrics import roc_curve\nfrom sklearn.metrics import auc\nfrom sklearn.metrics import recall_score\n\nnp.set_printoptions(precision=4, suppress=True)\npd.set_option('display.float_format', lambda x: '%.3f' % x)","metadata":{},"outputs":[],"execution_count":null},{"id":"ba3561da","cell_type":"code","source":"def amex_metric(y_true, y_pred):\n    # Если на вход пришли Series, берем только значения\n    if isinstance(y_true, pd.Series):\n        y_true = y_true.values\n    if isinstance(y_pred, pd.Series) or isinstance(y_pred, pd.DataFrame):\n        y_pred = y_pred.values.flatten()\n\n    def top_four_percent_captured(y_true, y_pred):\n        df = pd.DataFrame({'target': y_true, 'prediction': y_pred})\n        df = df.sort_values('prediction', ascending=False).reset_index(drop=True)\n        df['weight'] = df['target'].apply(lambda x: 20 if x==0 else 1)\n        four_pct_cutoff = 0.04 * df['weight'].sum()\n        df['weight_cumsum'] = df['weight'].cumsum()\n        df_cutoff = df.loc[df['weight_cumsum'] <= four_pct_cutoff]\n        return (df_cutoff['target'] == 1).sum() / (df['target'] == 1).sum()\n\n    def weighted_gini(y_true, y_pred):\n        df = pd.DataFrame({'target': y_true, 'prediction': y_pred})\n        df = df.sort_values('prediction', ascending=False).reset_index(drop=True)\n        df['weight'] = df['target'].apply(lambda x: 20 if x==0 else 1)\n        df['random'] = (df['weight'] / df['weight'].sum()).cumsum()\n        total_pos = (df['target'] * df['weight']).sum()\n        df['cum_pos_found'] = (df['target'] * df['weight']).cumsum()\n        df['lorentz'] = df['cum_pos_found'] / total_pos\n        return (df['lorentz'] - df['random']) * df['weight']\n\n    def normalized_weighted_gini(y_true, y_pred):\n        # Джини для предсказания vs Джини для идеального предсказания\n        return weighted_gini(y_true, y_pred).sum() / weighted_gini(y_true, y_true).sum()\n\n    g = normalized_weighted_gini(y_true, y_pred)\n    d = top_four_percent_captured(y_true, y_pred)\n\n    return 0.5 * (g + d)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"b82bbe8a","cell_type":"code","source":"%%time\n# should be run only once\nif FIRST_RUN:\n    train_data = pd.read_csv('./train_data.csv')\n    train_labels = pd.read_csv('./train_labels.csv')\n    train = pd.merge(train_data, train_labels, on='customer_ID')\n    test = pd.read_csv('./test_data.csv')\nelse:\n    train = pd.read_parquet('./train_compressed.parquet', engine='pyarrow')\n    test = pd.read_parquet('./test_compressed.parquet', engine='pyarrow')","metadata":{},"outputs":[],"execution_count":null},{"id":"72f30f38","cell_type":"markdown","source":"## EDA","metadata":{}},{"id":"0e3310c8","cell_type":"markdown","source":"Первое, что можем заметить - датасет очень и очень большой. Будем использовать техники сжатия данных для того, чтобы быстрее работать","metadata":{}},{"id":"43772948","cell_type":"code","source":"# data compression\n\nid = 'customer_ID'\ntarget = 'target'\nfeatures = [f for f in train.columns.tolist() if 'target' not in f]\ncat_features = ['B_30', 'B_38', 'D_114', 'D_116', 'D_117', 'D_120', 'D_126', 'D_63', 'D_64', 'D_66', 'D_68']\ntime_features = ['S_2']\nbool_features = ['B_31']\nnum_features = [f for f in features if (f not in cat_features) and (f not in time_features) and (f not in bool_features) and (f not in id)]\n\n\ndef convert_ID_features(df, f):\n    df[f] = df[f].str[-16:].apply(lambda x: int(x, 16)).astype('int64')\n    return df\n\n\ndef convert_time_features(df, f):\n    df[f] = df[f].apply(pd.to_datetime)\n\n    new_f = {}\n\n    for col in f:\n        new_f[f'year_last_2_digits_{col}'] = (df[col].dt.year % 100).astype('int8')\n        new_f[f'month_{col}'] = df[col].dt.month.astype('int8')\n        new_f[f'day_of_month_{col}'] = df[col].dt.day.astype('int8')\n        new_f[f'day_of_week_{col}'] = df[col].dt.dayofweek.astype('int8')\n        new_f[f'day_of_year_{col}'] = df[col].dt.dayofyear.astype('int8')\n        new_f[f'week_of_year_{col}'] = df[col].dt.isocalendar().week\n        new_f[f'quarter_{col}'] = df[col].dt.quarter.astype('int8')\n        new_f[f'is_weekend_{col}'] = (df[col].dt.dayofweek > 4).astype('int8')\n        new_f[f'is_month_start_{col}'] = df[col].dt.is_month_start.astype('int8')\n        new_f[f'is_month_end_{col}'] = df[col].dt.is_month_end.astype('int8')\n        new_f[f'is_year_start_{col}'] = df[col].dt.is_year_start.astype('int8')\n        new_f[f'is_year_end_{col}'] = df[col].dt.is_year_end.astype('int8')\n    \n    new_df = pd.DataFrame(new_f, index=df.index)\n    df = pd.concat([df, new_df], axis=1)\n    return df\n\ndef convert_cat_features(df, f):\n    rename_dict = {col: f'name_{col}' for col in f}\n    df = df.rename(columns=rename_dict)\n    return df\n\ndef convert_bool_features(df, f):\n    df[f] = df[f].astype('int8')\n    return df\n\ndef convert_num_features(df, f):\n    df[f] = df[f].astype('float16')\n    return df\n\nif FIRST_RUN:\n    train = convert_ID_features(train, id)\n    train = convert_time_features(train, time_features)\n    train = convert_cat_features(train, cat_features)\n    train = convert_bool_features(train, bool_features)\n    train = convert_num_features(train, num_features)\n\n    test = convert_ID_features(test, id)\n    test = convert_time_features(test, time_features)\n    test = convert_cat_features(test, cat_features)\n    test = convert_bool_features(test, bool_features)\n    test = convert_num_features(test, num_features)\n\n    train.to_parquet('./train_compressed.parquet', \n                 engine='pyarrow', \n                 compression='snappy') \n\n    test.to_parquet('./test_compressed.parquet', \n                engine='pyarrow', \n                compression='snappy')\n\n\ndef get_size_mb(df):\n    size_bytes = df.memory_usage(deep=True).sum()\n    return size_bytes / 1024**2\n\n\nrename_dict = {col: f'name:{col}' for col in cat_features}\ncat_features = [rename_dict[f] for f in cat_features]\n\n\nid = 'customer_ID'\ntarget = 'target'\nfeatures = [f for f in train.columns.tolist() if 'target' not in f]\ncat_features = [\n    'name_B_30', \n    'name_B_38', \n    'name_D_114', \n    'name_D_116', \n    'name_D_117', \n    'name_D_120', \n    'name_D_126', \n    'name_D_63', \n    'name_D_64', \n    'name_D_66', \n    'name_D_68'\n]\ntime_features = [\n    'S_2',\n    'year_last_2_digits_S_2',\n    'month_S_2',\n    'day_of_month_S_2',\n    'day_of_week_S_2',\n    'day_of_year_S_2',\n    'week_of_year_S_2',\n    'quarter_S_2',\n    'is_weekend_S_2',\n    'is_month_start_S_2',\n    'is_month_end_S_2',\n    'is_year_start_S_2',\n    'is_year_end_S_2'\n]\nbool_features = ['B_31']\nnum_features = [f for f in features if (f not in cat_features) and (f not in time_features) and (f not in bool_features) and (f not in id)]\n\n\nprint('Train')\nprint(train.info())\nprint('Test')\nprint(test.info())","metadata":{},"outputs":[],"execution_count":null},{"id":"4f23508f","cell_type":"code","source":"missing = pd.DataFrame(train.isnull().sum().sort_values(ascending=False) / len(train) * 100, columns=['% missing'])\nmissing = missing[missing['% missing'] > 0]\n\nprint(f'{len(missing)} features with missing values')\n\nprint(missing.head(5))\nplt.figure(figsize=(12,6))\nmissing.plot(kind='barh')\nplt.title('Top missing features')\nplt.show()","metadata":{},"outputs":[],"execution_count":null},{"id":"527ae5e2","cell_type":"code","source":"print(train['target'].value_counts(normalize=True))  # ~0.95 / 0.05\n\n# Считаем корреляцию Пирсона для всех числовых признаков с таргетом\ncorrelations = train[num_features].corrwith(train['target']).sort_values(ascending=False)\n\n# Рисуем топ-30 самых сильных корреляций (по модулю)\ntop_corr = correlations.abs().sort_values(ascending=False).head(30)\ntop_corr_full = correlations[top_corr.index] # Возвращаем исходные знаки (+/-)\n\nplt.figure(figsize=(12, 6))\nsns.barplot(x=top_corr_full.values, y=top_corr_full.index)\nplt.title(\"Top 30 Target-correlating features\")\nplt.xlabel(\"Correlation Coef\")\nplt.grid(axis='x', linestyle='--', alpha=0.5)\nplt.show()\n","metadata":{},"outputs":[],"execution_count":null},{"id":"7573ee69","cell_type":"code","source":"groups = {'B': [c for c in train.columns if 'B' in c and 'name' not in c and 'customer' not in c],\n          'D': [c for c in train.columns if 'D' in c and 'name' not in c and 'customer' not in c],\n          'time': [c for c in train.columns if 'S_2' in c and 'name' not in c and 'customer' not in c and c != 'S_2'],\n          'S': [c for c in train.columns if 'S' in c and 'name' not in c and 'customer' not in c and not 'S_2' in c],\n          'R': [c for c in train.columns if 'R' in c and 'name' not in c and 'customer' not in c],\n          'P': [c for c in train.columns if 'P' in c and 'name' not in c and 'customer' not in c]\n         }\n\nfig, axes = plt.subplots(2, 3, figsize=(18,12))\nfor i, (group_name, cols) in enumerate(groups.items()):\n    ax = axes[i//3, i%3]\n    train.groupby('target')[cols].mean().T.plot(ax=ax)\n    ax.set_title(f'{group_name} features: mean by target')\nplt.tight_layout()\nplt.show()\n","metadata":{},"outputs":[],"execution_count":null},{"id":"79306fd5","cell_type":"code","source":"train[time_features + ['target']].groupby('target').describe()","metadata":{},"outputs":[],"execution_count":null},{"id":"97189255","cell_type":"code","source":"train['customer_ID'] = train['customer_ID'].astype('category')  # если drop'нул раньше\ncustomer_counts = train.groupby('customer_ID').size()\nprint(customer_counts.value_counts().sort_index())  # сколько заявлений на клиента\n\nplt.figure(figsize=(10,4))\ncustomer_counts.value_counts().sort_index().plot(kind='bar')\n\nplt.title('Распределение заявлений по customer_ID')\nplt.xlabel('Количество заявлений на одного клиента')\nplt.ylabel('Количество клиентов')\nplt.xticks(rotation=0)\nplt.show()\n\n","metadata":{},"outputs":[],"execution_count":null},{"id":"321ed4c8","cell_type":"markdown","source":"## CV/LB gap","metadata":{}},{"id":"53b87be4","cell_type":"code","source":"train.columns = train.columns.str.replace(r'[^A-Za-z0-9_]', '_', regex=True)\ntest.columns = test.columns.str.replace(r'[^A-Za-z0-9_]', '_', regex=True)\n\nif FIRST_RUN:\n    report = check_cv(train_df=train, test_df=test, target_col='target', metric=\"roc_auc\")\n\n'''\n=== CV Guard Summary ===\nScore: 67.9/100\nVerdict: Moderate risk\nCV: auto (splits=5)  |  Mean CV: 0.9397  ± 0.0003\nAdv AUC (train vs test): 1.0000\nLeakage: none detected\nTop shifted features (by score):\n  • S_6 (numeric) shift=1.000\n  • name_D_66 (numeric) shift=1.000\n  • D_69 (numeric) shift=1.000\n========================\n'''","metadata":{},"outputs":[],"execution_count":null},{"id":"757ab2b9","cell_type":"markdown","source":"## Скелет - LGBM на последних заявлениях каждого клиента","metadata":{}},{"id":"41b8239b","cell_type":"code","source":"import lightgbm as lgb\nfrom sklearn.model_selection import GroupKFold\nimport os\n\n\nos.environ['OMP_WAIT_POLICY'] = 'active'\n\ndef get_last_statements(df):\n    return df.groupby('customer_ID').tail(1).reset_index(drop=False)\n\ntrain_last = get_last_statements(train)\ntest_last = get_last_statements(test)\n\nmin_date = train_last['S_2'].min()\ntrain_last['S_2'] = (train_last['S_2'] - min_date).dt.days\ntest_last['S_2'] = (test_last['S_2'] - min_date).dt.days\ntrain_last[cat_features] = train_last[cat_features].apply(lambda x: x.astype('category'))\ntest_last[cat_features] = test_last[cat_features].apply(lambda x: x.astype('category'))\n\nX_train = train_last.drop(columns=['target', 'customer_ID'])\ny_train = train_last['target']\ngroups = train_last['customer_ID'].cat.codes  # для CV\n\nX_test = test_last.drop(columns=['customer_ID'])\n\n\n\nparams = {\n    'objective': 'binary',\n    'metric': 'binary_logloss',  # базовая, но early_stop по amex_metric\n    'boosting_type': 'gbdt',\n    'num_leaves': 31,\n    'learning_rate': 0.02,\n    'feature_fraction': 0.8,\n    'bagging_fraction': 0.8,\n    'bagging_freq': 5,\n    'min_data_in_leaf': 100,\n    'lambda_l1': 0.1,\n    'lambda_l2': 0.1,\n    'verbose': -1,\n    'random_state': 42,\n    'n_jobs': -1\n}\n\ngkf = GroupKFold(n_splits=5)\noof_pred = np.zeros(len(X_train))\ntest_pred = np.zeros(len(X_test))\n\nfor fold, (trn_idx, val_idx) in enumerate(gkf.split(X_train, y_train, groups=groups)):\n    print(f'Fold {fold+1}')\n    \n    X_trn, X_val = X_train.iloc[trn_idx], X_train.iloc[val_idx]\n    y_trn, y_val = y_train.iloc[trn_idx], y_train.iloc[val_idx]\n    \n    # LGBM datasets\n    trn_ds = lgb.Dataset(X_trn, y_trn)\n    val_ds = lgb.Dataset(X_val, y_val)\n    \n    model = lgb.train(\n        params,\n        trn_ds,\n        valid_sets=[trn_ds, val_ds],\n        valid_names=['train', 'valid'],\n        num_boost_round=5000,\n        callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)]\n    )\n    \n    oof_pred[val_idx] = model.predict(X_val)\n    test_pred += model.predict(X_test) / gkf.n_splits\n","metadata":{},"outputs":[],"execution_count":null},{"id":"95c47f37","cell_type":"code","source":"cv_score = amex_metric(y_train, oof_pred)\nprint(f'CV AMEX M: {cv_score:.5f}')\n\nsub = pd.read_csv('./sample_submission.csv')\nsub['prediction'] = test_pred\nsub.to_csv('submission.csv', index=False)","metadata":{},"outputs":[],"execution_count":null},{"id":"a5497010","cell_type":"markdown","source":"Public Score: 0.78825","metadata":{}},{"id":"55d554ba","cell_type":"code","source":"import sys\nprint(sys.executable)","metadata":{},"outputs":[],"execution_count":null},{"id":"602cb227","cell_type":"markdown","source":"## Альтернативная архитектура - GRU","metadata":{}},{"id":"6042e8f3","cell_type":"markdown","source":"Чтобы прогнать GRU, нам потребуется сделать преобразования над датасетами. \n\nСдлелаем структуру N_users x time x features. Добавим нормализаций (совсем базовых для скелета) и сохраним, чтобы не переделывать каждый раз.","metadata":{}},{"id":"9ce4a83a","cell_type":"code","source":"import torch\nimport torch.nn as nn\nfrom torch.utils.data import TensorDataset, DataLoader\n\nDEVICE = 'mps' if torch.backends.mps.is_available() else 'cpu'\nMAX_SEQ_LEN = 13\nprint(f'Device: {DEVICE}')\n\nseq_cols   = [c for c in train.columns if c not in ('customer_ID', 'target', 'S_2')]\nN_FEATURES = len(seq_cols)\nprint(f'Sequence features: {N_FEATURES}')\ncat_maps = {}\nfor col in seq_cols:\n    try:\n        train[col].values.astype('float16')\n    except (ValueError, TypeError):\n        vals = pd.concat([train[col], test[col]]).dropna().unique().astype(str)\n        cat_maps[col] = {v: i + 1 for i, v in enumerate(sorted(vals))}\nprint(f'String columns (label-encoded): {list(cat_maps.keys())}')\n_non_cat  = [c for c in seq_cols if c not in cat_maps]\n_cat_only = [c for c in seq_cols if c in cat_maps]","metadata":{},"outputs":[],"execution_count":null},{"id":"2a2011b7","cell_type":"code","source":"# Нормализация\n\nif IS_FIRST_RNN_RUN:\n    print('Computing normalization stats...')\n    num_arr   = train[_non_cat].values.astype('float32')\n    _mean_num = np.nanmean(num_arr, axis=0)\n    _std_num  = np.nanstd(num_arr, axis=0)\n    del num_arr\n\n    _mean_cat = np.zeros(len(_cat_only), 'float32')\n    _std_cat  = np.ones(len(_cat_only),  'float32')\n    for i, col in enumerate(_cat_only):\n        arr = train[col].astype(str).map(cat_maps[col]).fillna(0).values.astype('float32')\n        _mean_cat[i], _std_cat[i] = arr.mean(), arr.std()\n\n    feat_mean = np.concatenate([_mean_num, _mean_cat]).astype('float32')\n    feat_std  = np.concatenate([_std_num,  _std_cat ]).clip(min=1e-6).astype('float32')\n    print('Done')","metadata":{},"outputs":[],"execution_count":null},{"id":"7505680e","cell_type":"code","source":"import gc, time\nfrom pathlib import Path\n\nN_CHUNKS = 10\n\nRNN_TRAIN_META = Path('./rnn_train_meta.npz')\nRNN_TEST_META  = Path('./rnn_test_meta.npz')\n\n_ordered_cols = _non_cat + _cat_only  # порядок = порядок feat_mean/feat_std\n\n\ndef build_sequences(df, has_target=False):\n    \"\"\"\n    Плоская таблица → куб (N_клиентов, MAX_SEQ_LEN, N_FEATURES).\n\n    Для каждого клиента:\n      - берём его строки в хронологическом порядке\n      - нормализуем: (x - mean) / std, NaN → 0\n      - берём последние MAX_SEQ_LEN шагов\n      - дополняем нулями слева если шагов меньше MAX_SEQ_LEN\n    \"\"\"\n    df.sort_values(['customer_ID', 'S_2'], inplace=True)\n    df.reset_index(drop=True, inplace=True)\n\n    for col in _cat_only:\n        df[col] = df[col].astype(str).map(cat_maps[col]).fillna(0)\n\n    N = df['customer_ID'].nunique()\n    X       = np.zeros((N, MAX_SEQ_LEN, N_FEATURES), dtype='float16')\n    lengths = np.zeros(N, dtype='int64')\n    cids, y_list = [], []\n\n    for i, (cid, grp) in enumerate(tqdm(\n        df.groupby('customer_ID', sort=False), total=N, desc='customers'\n    )):\n        rows = grp[_ordered_cols].values.astype('float32')\n        rows = np.nan_to_num((rows - feat_mean) / feat_std, nan=0.0, posinf=0.0, neginf=0.0)\n\n        n = min(len(grp), MAX_SEQ_LEN)\n        X[i, MAX_SEQ_LEN - n:] = rows[-n:].astype('float16')\n        lengths[i] = n\n        cids.append(cid)\n\n        if has_target:\n            y_list.append(int(grp['target'].iloc[0]))\n\n    if has_target:\n        return X, lengths, np.array(y_list, 'float16'), cids\n    return X, lengths, cids\n\n\nif IS_FIRST_RNN_RUN:\n    print('Строим train...')\n    X_tr, L_tr, y_tr, cids_tr = build_sequences(train, has_target=True)\n    del train; gc.collect()\n\n    # Сохраняем мета (L, y, cids) — без X\n    np.savez_compressed(RNN_TRAIN_META, L=L_tr, y=y_tr, cids=np.array(cids_tr))\n\n    # Сохраняем X по чанкам\n    train_chunk_idx = np.array_split(np.arange(len(X_tr)), N_CHUNKS)\n    for ci, idx in enumerate(train_chunk_idx):\n        np.savez_compressed(f'rnn_train_X_{ci}.npz', X=X_tr[idx])\n        print(f'  Train chunk {ci}: {X_tr[idx].shape}  ({X_tr[idx].nbytes/1e6:.0f} MB)')\n    del X_tr; gc.collect()\n    print(f'Train сохранён в {N_CHUNKS} чанков + rnn_train_meta.npz')\n\n    print('\\nСтроим test...')\n    X_te, L_te, cids_te = build_sequences(test, has_target=False)\n    del test; gc.collect()\n\n    np.savez_compressed(RNN_TEST_META, L=L_te, cids=np.array(cids_te))\n    test_chunk_idx = np.array_split(np.arange(len(X_te)), N_CHUNKS)\n    for ci, idx in enumerate(test_chunk_idx):\n        np.savez_compressed(f'rnn_test_X_{ci}.npz', X=X_te[idx])\n        print(f'  Test chunk {ci}: {X_te[idx].shape}  ({X_te[idx].nbytes/1e6:.0f} MB)')\n    del X_te; gc.collect()\n    print(f'Test сохранён в {N_CHUNKS} чанков + rnn_test_meta.npz')\n\nelse:\n    print('Загружаем мета из кеша...')\n    t0 = time.time()\n    d = np.load(RNN_TRAIN_META, allow_pickle=True)\n    L_tr, y_tr, cids_tr = d['L'], d['y'], list(d['cids'])\n    d = np.load(RNN_TEST_META, allow_pickle=True)\n    L_te, cids_te = d['L'], list(d['cids'])\n    print(f'Загружено за {time.time()-t0:.1f}s  |  Train: {len(y_tr)} клиентов  Test: {len(L_te)} клиентов')\n    del train, test; gc.collect()\n\n    # Индексы чанков (воспроизводимо без X)\n    train_chunk_idx = np.array_split(np.arange(len(y_tr)), N_CHUNKS)\n    test_chunk_idx  = np.array_split(np.arange(len(L_te)), N_CHUNKS)\n\nassert L_tr.min() >= 1 and L_te.min() >= 1, 'Есть пустые последовательности!'\nprint(f'\\nTrain: {len(y_tr)} клиентов  |  Test: {len(L_te)} клиентов')\nprint(f'Chunk sizes — train: {len(train_chunk_idx[0])}  test: {len(test_chunk_idx[0])}')","metadata":{},"outputs":[],"execution_count":null},{"id":"24khz2xw3wc","cell_type":"code","source":"class SimpleGRU(nn.Module):\n    \"\"\"\n    Pre-padding: нули слева, реальные данные справа.\n    Поэтому h_n после последнего шага = финальное состояние после реальных данных.\n    pack_padded_sequence не нужен — работает стабильно на MPS.\n    \"\"\"\n\n    def __init__(self, n_features, hidden=128, dropout=0.3):\n        super().__init__()\n        self.gru = nn.GRU(n_features, hidden, batch_first=True)\n        self.head = nn.Sequential(\n            nn.Dropout(dropout),\n            nn.Linear(hidden, 1),\n        )\n\n    def forward(self, x, lengths=None):\n        _, h_n = self.gru(x)              # h_n: (1, batch, hidden)\n        return self.head(h_n.squeeze(0)).squeeze(-1)  # (batch,)\n\n\n# Быстрая проверка формы\n_dummy_x = torch.zeros(4, MAX_SEQ_LEN, N_FEATURES)\n_model   = SimpleGRU(N_FEATURES)\nprint(f'Model output shape: {_model(_dummy_x).shape}')  # (4,)\nprint(f'Parameters: {sum(p.numel() for p in _model.parameters()):,}')\ndel _dummy_x, _model","metadata":{},"outputs":[],"execution_count":null},{"id":"clloz3j1t38","cell_type":"code","source":"from sklearn.model_selection import GroupKFold\nfrom torch.utils.data import TensorDataset, DataLoader\n\n# Освобождаем LGBM-переменные (~1 GB)\nfor _var in ['train_last', 'test_last', 'X_train', 'X_test']:\n    if _var in globals():\n        del globals()[_var]\ngc.collect()\n\nN_FOLDS    = 5\nN_EPOCHS   = 20\nBATCH_SIZE = 512\n\n# mmap_mode='r' — данные читаются с диска, НЕ загружаются в RAM\ndef load_train_chunk(ci):\n    return np.load(f'rnn_train_X_{ci}.npy', mmap_mode='r')\n\ndef load_test_chunk(ci):\n    return np.load(f'rnn_test_X_{ci}.npy', mmap_mode='r')\n\n# Fold-labels вычисляем по cids/y — без X\ngkf    = GroupKFold(n_splits=N_FOLDS)\ngroups = pd.Categorical(cids_tr).codes\nfold_ids = np.zeros(len(y_tr), dtype=np.int8)\nfor fold, (_, val_idx) in enumerate(gkf.split(np.zeros(len(y_tr)), y_tr, groups=groups)):\n    fold_ids[val_idx] = fold\n\npos_weight = torch.tensor([(y_tr==0).sum()/(y_tr==1).sum()], dtype=torch.float32)\ncriterion  = nn.BCEWithLogitsLoss(pos_weight=pos_weight.to(DEVICE))\n\noof_rnn  = np.zeros(len(y_tr),    dtype=np.float32)\ntest_rnn = np.zeros(len(L_te),    dtype=np.float32)\n\nfor fold in range(N_FOLDS):\n    print(f'\\n=== Fold {fold+1}/{N_FOLDS} ===')\n    trn_mask = (fold_ids != fold)\n    val_mask  = (fold_ids == fold)\n\n    model     = SimpleGRU(N_FEATURES).to(DEVICE)\n    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)\n    best_score, best_state, best_val_preds = 0.0, None, None\n\n    for epoch in range(N_EPOCHS):\n        # --- TRAIN: по чанкам ---\n        model.train()\n        for ci, idx in enumerate(train_chunk_idx):\n            mask = trn_mask[idx]\n            if mask.sum() == 0:\n                continue\n            X_chunk = load_train_chunk(ci)\n            ds = TensorDataset(\n                torch.from_numpy(np.ascontiguousarray(X_chunk[mask])),\n                torch.from_numpy(L_tr[idx][mask]),\n                torch.from_numpy(y_tr[idx][mask].astype('float32'))\n            )\n            for xb, lb, yb in DataLoader(ds, BATCH_SIZE, shuffle=True):\n                xb = xb.to(DEVICE, dtype=torch.float32)\n                lb, yb = lb.to(DEVICE), yb.to(DEVICE)\n                optimizer.zero_grad()\n                criterion(model(xb, lb), yb).backward()\n                optimizer.step()\n            del X_chunk, ds; gc.collect()\n\n        # --- VALIDATE: по чанкам ---\n        model.eval()\n        val_preds_chunks = []\n        with torch.no_grad():\n            for ci, idx in enumerate(train_chunk_idx):\n                mask = val_mask[idx]\n                if mask.sum() == 0:\n                    continue\n                X_chunk = load_train_chunk(ci)\n                preds = []\n                for xb, lb in DataLoader(\n                    TensorDataset(torch.from_numpy(np.ascontiguousarray(X_chunk[mask])),\n                                  torch.from_numpy(L_tr[idx][mask])),\n                    BATCH_SIZE * 2\n                ):\n                    preds.append(\n                        torch.sigmoid(model(xb.to(DEVICE, dtype=torch.float32),\n                                            lb.to(DEVICE))).cpu().numpy()\n                    )\n                val_preds_chunks.append(np.concatenate(preds))\n                del X_chunk; gc.collect()\n\n        val_preds = np.concatenate(val_preds_chunks)\n        score = amex_metric(y_tr[val_mask], val_preds)\n        if score > best_score:\n            best_score = score\n            best_val_preds = val_preds.copy()\n            best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}\n        print(f'  Epoch {epoch+1:2d}: {score:.5f}')\n\n    oof_rnn[val_mask] = best_val_preds\n    print(f'Fold {fold+1} best: {best_score:.5f}')\n\n    # --- TEST INFERENCE ---\n    model.load_state_dict(best_state)\n    model.eval()\n    with torch.no_grad():\n        for ci, idx in enumerate(test_chunk_idx):\n            X_chunk = load_test_chunk(ci)\n            preds = []\n            for xb, lb in DataLoader(\n                TensorDataset(torch.from_numpy(np.ascontiguousarray(X_chunk)),\n                              torch.from_numpy(L_te[idx])),\n                BATCH_SIZE * 2\n            ):\n                preds.append(\n                    torch.sigmoid(model(xb.to(DEVICE, dtype=torch.float32),\n                                        lb.to(DEVICE))).cpu().numpy()\n                )\n            test_rnn[idx] += np.concatenate(preds) / N_FOLDS\n            del X_chunk; gc.collect()\n\nprint(f'\\nOOF GRU: {amex_metric(y_tr, oof_rnn):.5f}')","metadata":{},"outputs":[],"execution_count":null},{"id":"6eed6f42","cell_type":"code","source":"# GRU submission\nsub = pd.read_csv('./sample_submission.csv')\ngru_by_cid = dict(zip(cids_te, test_rnn))\nsub['prediction'] = sub['customer_ID'].map(gru_by_cid)\nsub.to_csv('submission_gru.csv', index=False)\nprint(f'OOF GRU: {amex_metric(y_tr, oof_rnn):.5f}')\nprint(f'Saved submission_gru.csv  ({len(sub)} rows)')","metadata":{},"outputs":[],"execution_count":null},{"id":"3926f32d","cell_type":"code","source":"","metadata":{},"outputs":[],"execution_count":null}]}