{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"e99e4d1a","cell_type":"markdown","source":"# AMEX Default Prediction · LightGBM GBDT + DART 앙상블 \n**고객별 13개월 명세서 이력 집계 → LightGBM 2종 → 순위 평균**  ·  FIT5021 Week 4 · Team 17\n\n| 모델 | 5-fold CV (대회 지표 M) |\n|---|---|\n| LightGBM GBDT | 0.7927 |\n| LightGBM DART | 0.7922 |\n| **순위 평균 앙상블 (0.6 : 0.4)** | **0.7936** |\n\n\n\n### 절차\n1. **변환** — 원본 CSV(50GB)를 1GB 블록 단위로 읽어 float16 parquet 로 저장 (메모리 절약)\n2. **집계** — 고객별 명세서 이력을 요약해 변수 1,276개 생성\n3. **학습** — LightGBM GBDT · DART 각각 5-fold 층화 교차검증, 조기종료/최고 시점 선택은 **대회 지표 M** 기준\n4. **앙상블** — OOF 로 정한 가중치로 두 모델의 순위 평균 → `submission.csv`\n\n","metadata":{}},{"id":"c5d83cf2","cell_type":"markdown","source":"## 0. 설정\n`DEBUG = True` 로 두면 앞부분 일부만 읽고 반복 횟수를 줄여 몇 분 안에 전체 흐름을 점검할 수 있습니다.","metadata":{}},{"id":"bffd175e","cell_type":"code","source":"import os, io, gc, glob, json, time, warnings\nimport numpy as np, pandas as pd\nimport pyarrow as pa, pyarrow.csv as pacsv, pyarrow.parquet as pq, pyarrow.compute as pc\nimport lightgbm as lgb\nimport matplotlib.pyplot as plt\nfrom sklearn.model_selection import StratifiedKFold\nfrom scipy.stats import rankdata\nwarnings.filterwarnings(\"ignore\")\n\nclass CFG:\n    DEBUG    = False\n    # 대회 데이터 위치 자동 탐색 (Kaggle 버전에 따라 경로가 조금 다를 수 있음)\n    INPUT    = next((os.path.dirname(p) for p in glob.glob(\"/kaggle/input/**/train_labels.csv\", recursive=True)),\n                    \"/kaggle/input/amex-default-prediction\")\n    WORK     = \"/kaggle/temp\" if os.path.isdir(\"/kaggle/temp\") else \"/tmp/amex\"   # 중간 파일 (출력에 남기지 않음)\n    OUTPUT   = \"/kaggle/working\"\n    SEED     = 5021\n    N_FOLDS  = 5\n    BLOCK    = 20_000_000 if DEBUG else 1_000_000_000      # CSV 를 읽는 블록 크기 (bytes)\n    MAX_BLOCKS = 3 if DEBUG else None\n    GBDT_ROUNDS, GBDT_PATIENCE = (60, 20) if DEBUG else (4000, 300)\n    DART_ROUNDS, DART_FROM, DART_EVERY = (80, 20, 10) if DEBUG else (1200, 600, 50)\n    W_GBDT, W_DART = 0.6, 0.4                                # OOF 로 정한 앙상블 가중치 (6장)\n    THREADS  = os.cpu_count()\n\nCATS = [\"B_30\", \"B_38\", \"D_114\", \"D_116\", \"D_117\", \"D_120\", \"D_126\", \"D_63\", \"D_64\", \"D_66\", \"D_68\"]  # 대회 지정 범주형\nfor d in [\"raw\", \"feat\", \"models\"]:\n    os.makedirs(f\"{CFG.WORK}/{d}\", exist_ok=True)\nprint(\"input\", CFG.INPUT, \"\\nlightgbm\", lgb.__version__, \"| threads\", CFG.THREADS, \"| DEBUG\", CFG.DEBUG, \"| work dir\", CFG.WORK)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-26T12:57:11.193078Z","iopub.execute_input":"2026-09-26T12:57:11.19349Z","iopub.status.idle":"2026-09-26T12:57:18.98107Z","shell.execute_reply.started":"2026-09-26T12:57:11.193444Z","shell.execute_reply":"2026-09-26T12:57:18.980056Z"}},"outputs":[],"execution_count":null},{"id":"366e7c26","cell_type":"markdown","source":"## 1. 대회 지표\n$M = \\tfrac{1}{2}\\,(G + D)$ — $G$: 정규화 가중 Gini, $D$: 예측 상위 4% 안에 포착한 부도 비율.\n정상 고객(target=0)은 5% 만 표집되어 있어 두 지표 모두 **가중치 20** 을 줍니다. 순위만 보는 지표이므로 확률 보정은 점수에 영향이 없습니다.","metadata":{}},{"id":"bbb59043","cell_type":"code","source":"def amex_metric(y_true, y_pred):\n    y_true = np.asarray(y_true)\n    order = np.argsort(-np.asarray(y_pred), kind=\"stable\")\n    y = y_true[order]\n    w = np.where(y == 0, 20.0, 1.0)\n    top4 = y[np.cumsum(w) <= 0.04 * w.sum()].sum() / y.sum()\n\n    def weighted_gini(yv):\n        w = np.where(yv == 0, 20.0, 1.0)\n        rand = np.cumsum(w / w.sum())\n        lorentz = np.cumsum(yv * w) / (yv * w).sum()\n        return ((lorentz - rand) * w).sum()\n\n    gini = weighted_gini(y) / weighted_gini(np.sort(y_true)[::-1])\n    return 0.5 * (gini + top4)\n\ndef lgb_amex(preds, data):\n    return \"amex\", amex_metric(data.get_label(), preds), True","metadata":{},"outputs":[],"execution_count":null},{"id":"bd541a0b","cell_type":"markdown","source":"## 2. 원본 CSV → parquet 변환\n- CSV 를 **1GB 블록**으로 끊어 `pyarrow` 로 읽습니다. 블록 경계는 다음 줄바꿈까지 늘려 행이 잘리지 않게 합니다.\n- `customer_ID`(64자 hex) → **마지막 16자리의 int64** (고객 구분에 충분하고 메모리를 크게 줄임)\n- `S_2`(명세서 날짜) → 2017-01-01 기준 일수, `D_63`·`D_64` 문자 범주 → 정수 코드\n- 나머지 수치형 → **float16** (원본 대비 상대오차 0.05% 이하, 결측 위치 동일)","metadata":{}},{"id":"df0e72c3","cell_type":"code","source":"D63 = {\"CL\": 0, \"CO\": 1, \"CR\": 2, \"XL\": 3, \"XM\": 4, \"XZ\": 5}\nD64 = {\"O\": 0, \"R\": 1, \"U\": 2, \"-1\": 3}\n\ndef hex_to_int64(arr):\n    return np.array([int(s[-16:], 16) for s in arr.to_pylist()], dtype=np.uint64).view(np.int64)\n\ndef csv_to_parquet(name):\n    src = f\"{CFG.INPUT}/{name}.csv\"; size = os.path.getsize(src)\n    with open(src, \"rb\") as f:\n        header = f.readline().decode().strip().split(\",\")\n    types = {c: pa.float32() for c in header}\n    types.update({c: pa.string() for c in [\"customer_ID\", \"S_2\", \"D_63\", \"D_64\"]})\n    off, k, t0 = len(\",\".join(header)) + 1, 0, time.time()\n    while off < size and (CFG.MAX_BLOCKS is None or k < CFG.MAX_BLOCKS):\n        with open(src, \"rb\") as f:\n            f.seek(off); buf = f.read(CFG.BLOCK)\n            if off + len(buf) < size:\n                buf += f.readline()                      # 줄 끝까지 읽어 행이 잘리지 않게\n        tbl = pacsv.read_csv(io.BytesIO(buf),\n                             read_options=pacsv.ReadOptions(column_names=header),\n                             convert_options=pacsv.ConvertOptions(column_types=types))\n        cols = {\"cid\": pa.array(hex_to_int64(tbl[\"customer_ID\"]))}\n        days = pc.strptime(tbl[\"S_2\"], format=\"%Y-%m-%d\", unit=\"s\").cast(pa.int64()).to_numpy() // 86400\n        cols[\"S_2\"] = pa.array((days - 17167).astype(np.int16))\n        for c in header[2:]:\n            if c in (\"D_63\", \"D_64\"):\n                m = D63 if c == \"D_63\" else D64\n                v = np.array([m.get(x, np.nan) if x is not None else np.nan for x in tbl[c].to_pylist()], dtype=np.float32)\n            else:\n                v = np.clip(tbl[c].to_numpy(zero_copy_only=False).astype(np.float32), -6e4, 6e4)\n            cols[c] = pa.array(v.astype(np.float16))\n        pq.write_table(pa.table(cols), f\"{CFG.WORK}/raw/{name}_{k:03d}.parquet\", compression=\"zstd\")\n        off += len(buf); k += 1\n        print(f\"\\r{name}: block {k} · {off/size:6.1%} · {time.time()-t0:5.0f}s\", end=\"\")\n    print()\n\nfor name in [\"train_data\", \"test_data\"]:\n    csv_to_parquet(name)","metadata":{},"outputs":[],"execution_count":null},{"id":"7f636e04","cell_type":"markdown","source":"## 3. 고객별 집계 피처 (1,276개)\n한 고객은 명세서가 최대 13개 있습니다. 이를 고객당 한 행으로 요약합니다.\n\n| 대상 | 집계 | 개수 |\n|---|---|---|\n| 수치형 177개 | mean · std · min · max · **last** · last−mean · last−직전 | 177 × 7 = 1,239 |\n| 범주형 11개 | last · nunique · count | 11 × 3 = 33 |\n| 명세서 | 개수 · 첫~마지막 기간 · 평균 간격 · 마지막 간격 | 4 |\n\n`last` 는 **마지막 명세서 행의 값**(결측이면 결측)입니다. 파일은 고객 단위로 정렬되어 있으므로, 블록 경계에 걸친 고객은 다음 블록으로 넘겨(carry) 한 번에 집계합니다.","metadata":{}},{"id":"d54ccee2","cell_type":"code","source":"def aggregate(df):\n    num = [c for c in df.columns if c not in [\"cid\", \"S_2\"] + CATS]\n    X = df[num].astype(np.float32); X[\"cid\"] = df[\"cid\"].values\n    g = X.groupby(\"cid\", sort=False)\n    rank_from_end = g.cumcount(ascending=False).values          # 0 = 마지막 명세서, 1 = 직전 명세서\n\n    stats = g[num].agg([\"mean\", \"std\", \"min\", \"max\"])\n    stats.columns = [f\"{c}_{s}\" for c, s in stats.columns]\n    last = X[rank_from_end == 0].set_index(\"cid\")[num]\n    prev = X[rank_from_end == 1].set_index(\"cid\")[num].reindex(last.index)\n    stats = stats.reindex(last.index)\n    d_mean = pd.DataFrame(last.values - stats[[f\"{c}_mean\" for c in num]].values, index=last.index, columns=[f\"{c}_last_mean\" for c in num])\n    d_prev = pd.DataFrame(last.values - prev.values, index=last.index, columns=[f\"{c}_last_prev\" for c in num])\n    last.columns = [f\"{c}_last\" for c in num]\n\n    C = df[CATS + [\"cid\"]].astype({c: np.float32 for c in CATS}); gc_ = C.groupby(\"cid\", sort=False)\n    c_last = C[rank_from_end == 0].set_index(\"cid\")[CATS].add_suffix(\"_last\")\n    c_nuniq = gc_[CATS].nunique().add_suffix(\"_nunique\")\n    c_count = gc_[CATS].count().add_suffix(\"_count\")\n\n    s2 = df[[\"cid\", \"S_2\"]].copy(); s2[\"gap\"] = s2.groupby(\"cid\", sort=False)[\"S_2\"].diff()\n    gs = s2.groupby(\"cid\", sort=False)\n    stmt = pd.DataFrame({\"n_statements\": gs.size(), \"days_span\": gs[\"S_2\"].max() - gs[\"S_2\"].min(), \"gap_mean\": gs[\"gap\"].mean()})\n    stmt[\"gap_last\"] = s2[rank_from_end == 0].set_index(\"cid\")[\"gap\"]\n\n    out = pd.concat([stats, last, d_mean, d_prev], axis=1).join(c_last).join(c_nuniq).join(c_count).join(stmt)\n    return out.astype(np.float32)\n\ndef build_features(name):\n    files = sorted(glob.glob(f\"{CFG.WORK}/raw/{name}_*.parquet\")); carry = None; t0 = time.time()\n    for k, f in enumerate(files):\n        df = pq.read_table(f).to_pandas()\n        if carry is not None:\n            df = pd.concat([carry, df], ignore_index=True)\n        if k < len(files) - 1:                                   # 마지막 고객은 다음 블록과 합쳐 집계\n            m = df[\"cid\"].values == df[\"cid\"].iloc[-1]; carry, df = df[m], df[~m]\n        aggregate(df).reset_index().to_parquet(f\"{CFG.WORK}/feat/{name}_{k:03d}.parquet\")\n        print(f\"\\r{name}: {k+1}/{len(files)} · {time.time()-t0:4.0f}s\", end=\"\")\n    print()\n\nfor name in [\"train_data\", \"test_data\"]:\n    build_features(name)","metadata":{},"outputs":[],"execution_count":null},{"id":"15f50ea9","cell_type":"markdown","source":"## 4. 학습 데이터 구성","metadata":{}},{"id":"789ec530","cell_type":"code","source":"train = pd.concat([pd.read_parquet(f) for f in sorted(glob.glob(f\"{CFG.WORK}/feat/train_data_*.parquet\"))], ignore_index=True)\nlabels = pd.read_csv(f\"{CFG.INPUT}/train_labels.csv\")\nlabels[\"cid\"] = labels[\"customer_ID\"].str[-16:].apply(lambda s: int(s, 16)).astype(\"uint64\").astype(\"int64\")\ntrain = train.merge(labels[[\"cid\", \"target\"]], on=\"cid\", how=\"inner\")\nFEATS = [c for c in train.columns if c not in (\"cid\", \"target\")]\n\ny = train[\"target\"].values\nX = train[FEATS].to_numpy(np.float32); del train; gc.collect()\nprint(f\"customers {len(y):,} · features {len(FEATS):,} · default rate {y.mean():.4f} · X {X.nbytes/1e9:.1f} GB\")\n\nfig, ax = plt.subplots(1, 2, figsize=(11, 3.2))\nn_stmt = X[:, FEATS.index(\"n_statements\")].astype(int)\npd.Series(n_stmt).value_counts().sort_index().plot.bar(ax=ax[0], color=\"#4C72B0\")\nax[0].set_title(\"Statements per customer\"); ax[0].set_xlabel(\"n_statements\"); ax[0].set_ylabel(\"customers\")\npd.Series(y).groupby(n_stmt).mean().plot(ax=ax[1], marker=\"o\", color=\"#C44E52\")\nax[1].set_title(\"Default rate by number of statements\"); ax[1].set_xlabel(\"n_statements\"); ax[1].set_ylabel(\"default rate\")\nplt.tight_layout(); plt.show()","metadata":{},"outputs":[],"execution_count":null},{"id":"b76dafb0","cell_type":"markdown","source":"## 5. LightGBM 5-fold (GBDT · DART)\n- 두 모델 모두 같은 5-fold 분할(층화, SEED 5021)과 같은 트리 설정을 씁니다.\n- **GBDT**: 대회 지표 M 으로 조기종료 (patience 300)\n- **DART**: 조기종료가 불가능하므로 600회 이후 50회마다 M 을 확인해 **가장 좋았던 시점의 모델을 저장**\n- 검증 폴드 예측(OOF)은 6장에서 앙상블 가중치를 정하는 데만 사용합니다.","metadata":{}},{"id":"97fbdeae","cell_type":"code","source":"BASE = dict(objective=\"binary\", num_leaves=100, min_data_in_leaf=40, feature_fraction=0.2,\n            bagging_fraction=0.5, bagging_freq=10, lambda_l2=2, max_bin=255, bin_construct_sample_cnt=60000,\n            metric=\"None\", verbose=-1, num_threads=CFG.THREADS, seed=CFG.SEED)\nPARAMS = {\"gbdt\": dict(BASE, learning_rate=0.03),\n          \"dart\": dict(BASE, boosting=\"dart\", learning_rate=0.05, drop_rate=0.1)}\n\nfull = lgb.Dataset(X, y, feature_name=FEATS, params={\"max_bin\": 255, \"bin_construct_sample_cnt\": 60000, \"verbose\": -1}, free_raw_data=False).construct()\nFOLDS = list(StratifiedKFold(CFG.N_FOLDS, shuffle=True, random_state=CFG.SEED).split(X, y))\n\ndef train_cv(kind):\n    oof = np.zeros(len(y)); boosters = []\n    for k, (tr, va) in enumerate(FOLDS):\n        t0 = time.time(); dtr, dva = full.subset(tr), full.subset(va)\n        if kind == \"gbdt\":\n            b = lgb.train(PARAMS[kind], dtr, CFG.GBDT_ROUNDS, valid_sets=[dva], feval=lgb_amex,\n                          callbacks=[lgb.early_stopping(CFG.GBDT_PATIENCE, verbose=False)])\n            best_it, best_s = b.best_iteration, b.best_score[\"valid_0\"][\"amex\"]\n            b = lgb.Booster(model_str=b.model_to_string(num_iteration=best_it))\n        else:\n            best = {\"s\": -1, \"it\": 0, \"m\": None}\n            def snapshot(env):\n                it = env.iteration + 1\n                if it >= CFG.DART_FROM and it % CFG.DART_EVERY == 0:\n                    s = env.evaluation_result_list[0][2]\n                    if s > best[\"s\"]:\n                        best.update(s=s, it=it, m=env.model.model_to_string())\n            lgb.train(PARAMS[kind], dtr, CFG.DART_ROUNDS, valid_sets=[dva], feval=lgb_amex, callbacks=[snapshot])\n            b, best_it, best_s = lgb.Booster(model_str=best[\"m\"]), best[\"it\"], best[\"s\"]\n        oof[va] = b.predict(X[va]); boosters.append(b)\n        b.save_model(f\"{CFG.WORK}/models/{kind}_fold{k}.txt\")\n        print(f\"{kind} fold {k}: M={amex_metric(y[va], oof[va]):.5f} · best iter {best_it} · {time.time()-t0:.0f}s\")\n    print(f\"{kind} CV (OOF) M = {amex_metric(y, oof):.5f}\")\n    return oof, boosters\n\noof_gbdt, models_gbdt = train_cv(\"gbdt\")","metadata":{},"outputs":[],"execution_count":null},{"id":"7dfda49c","cell_type":"code","source":"oof_dart, models_dart = train_cv(\"dart\")","metadata":{},"outputs":[],"execution_count":null},{"id":"114507ff","cell_type":"markdown","source":"## 6. 앙상블 가중치 (OOF 기준)\n두 모델의 확률 스케일이 달라 **폴드 안에서 순위로 바꾼 뒤** 가중 평균합니다. 가중치는 OOF 로만 정합니다 — 리더보드 점수를 보고 고르면 Public 에 과적합됩니다.","metadata":{}},{"id":"fb4423c6","cell_type":"code","source":"def fold_rank_blend(p1, p2, w2):\n    out = np.zeros(len(y))\n    for _, va in FOLDS:\n        out[va] = (1 - w2) * rankdata(p1[va]) / len(va) + w2 * rankdata(p2[va]) / len(va)\n    return out\n\ngrid = pd.DataFrame({\"w_dart\": np.round(np.arange(0, 1.01, 0.1), 1)})\ngrid[\"CV M\"] = [amex_metric(y, fold_rank_blend(oof_gbdt, oof_dart, w)) for w in grid.w_dart]\ndisplay(grid.style.format({\"CV M\": \"{:.5f}\"}).highlight_max(subset=\"CV M\", color=\"#d4edda\"))\nprint(f\"사용 가중치: GBDT {CFG.W_GBDT} : DART {CFG.W_DART}  (최고점 근처에서 두 모델을 고르게 쓰는 값)\")","metadata":{},"outputs":[],"execution_count":null},{"id":"e2a26b6f","cell_type":"markdown","source":"## 7. 변수 중요도 (GBDT, gain 평균 상위 20)","metadata":{}},{"id":"65bcc62d","cell_type":"code","source":"imp = pd.DataFrame({f\"f{k}\": m.feature_importance(\"gain\") for k, m in enumerate(models_gbdt)}, index=FEATS).mean(axis=1)\ntop = (imp / imp.sum()).sort_values(ascending=False).head(20)[::-1]\nplt.figure(figsize=(7, 6)); plt.barh(top.index, top.values, color=\"#4C72B0\")\nplt.title(\"Top 20 features · share of total gain\"); plt.xlabel(\"gain share\"); plt.tight_layout(); plt.show()\ndel X, full; _ = gc.collect()","metadata":{},"outputs":[],"execution_count":null},{"id":"4b00e076","cell_type":"markdown","source":"## 8. test 예측 → `submission.csv`\ntest 피처도 블록 단위로 읽어 각 폴드 모델로 예측 → 모델별 5개 폴드 평균 → 순위 평균(0.6 : 0.4).","metadata":{}},{"id":"846dcb42","cell_type":"code","source":"parts = []\nfor f in sorted(glob.glob(f\"{CFG.WORK}/feat/test_data_*.parquet\")):\n    d = pd.read_parquet(f); Xt = d[FEATS].to_numpy(np.float32)\n    parts.append(pd.DataFrame({\"cid\": d[\"cid\"].values,\n                               \"gbdt\": np.mean([m.predict(Xt) for m in models_gbdt], axis=0),\n                               \"dart\": np.mean([m.predict(Xt) for m in models_dart], axis=0)}))\npred = pd.concat(parts, ignore_index=True)\npred[\"prediction\"] = CFG.W_GBDT * rankdata(pred.gbdt) / len(pred) + CFG.W_DART * rankdata(pred.dart) / len(pred)\n\nsub = pd.read_csv(f\"{CFG.INPUT}/sample_submission.csv\")[[\"customer_ID\"]]\nsub[\"cid\"] = sub[\"customer_ID\"].str[-16:].apply(lambda s: int(s, 16)).astype(\"uint64\").astype(\"int64\")\nsub = sub.merge(pred[[\"cid\", \"prediction\"]], on=\"cid\", how=\"left\").drop(columns=\"cid\")\nmissing = sub.prediction.isna().sum()\nif CFG.DEBUG:\n    sub[\"prediction\"] = sub.prediction.fillna(0.0)          # DEBUG: 일부만 읽었으므로 나머지는 0\nelse:\n    assert missing == 0 and len(sub) == 924_621, f\"missing predictions: {missing}\"\nsub.to_csv(f\"{CFG.OUTPUT}/submission.csv\", index=False)\nprint(sub.shape, \"· missing before fill:\", missing); sub.head()","metadata":{},"outputs":[],"execution_count":null},{"id":"d0fe2796","cell_type":"markdown","source":"## 9. 정리\n- **가장 큰 개선은 피처에서** — 고객당 1행 스냅샷(수업 자료)으로는 M ≈ 0.78 이 상한이었지만, 13개월 이력을 집계하자 GBDT 단독으로 0.793 까지 올랐습니다.\n- **DART 는 단독으로는 GBDT 와 비슷**하지만 오차 패턴이 달라, 섞으면 CV 가 +0.001 오릅니다.\n- **여기서 멈춘 이유** — 추가 후보(시드 앙상블·XGBoost·CatBoost)의 기대 이득(+0.0005~0.001)이 리더보드 점수 자체의 표본 오차(약 ±0.001)보다 작아 개선 여부를 판별하기 어렵습니다.\n- 재현 시 LightGBM 버전·스레드 수에 따라 점수가 ±0.001 정도 달라질 수 있습니다.","metadata":{}}]}