{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","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"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import warnings\nwarnings.filterwarnings('ignore')\n\nimport os, glob\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import TensorDataset, DataLoader\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.model_selection import GroupKFold\n\n# ============================================================\n# 【旋钮区】\n# ============================================================\nUSE_BASE_PERCENT = False\nSEEDS         = [42, 1, 2025]\nHIDDEN_DIMS   = [64, 64]\nDROPOUT       = 0.1\nLR            = 1e-3\nWEIGHT_DECAY  = 1e-4\nMAX_EPOCHS    = 500\nPATIENCE      = 40\nBATCH_SIZE    = 64\nN_SPLITS      = 5\n# ============================================================\n\nDEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'\nSQRT2  = np.sqrt(2.0)\n\nALL_FEATURES = [\n    'Age', 'Base_FVC', 'Base_Percent', 'Week_diff',\n    'Sex_Female', 'Sex_Male',\n    'SmokingStatus_Currently smokes', 'SmokingStatus_Ex-smoker', 'SmokingStatus_Never smoked',\n]\nFEATURES = ALL_FEATURES if USE_BASE_PERCENT else [f for f in ALL_FEATURES if f != 'Base_Percent']\nTARGET = 'FVC'\n\n# One-Hot 后必须存在的全部类别列（保证隐藏 test 缺类别时也能补齐为 0）\nSEX_COLS     = ['Sex_Female', 'Sex_Male']\nSMOKING_COLS = ['SmokingStatus_Currently smokes', 'SmokingStatus_Ex-smoker', 'SmokingStatus_Never smoked']\n\n\ndef find_competition_test():\n    \"\"\"专门定位比赛原始 test.csv —— 打分时这个文件会被隐藏测试集替换。\"\"\"\n    candidates = [\n        '/kaggle/input/osic-pulmonary-fibrosis-progression/test.csv',\n    ]\n    for c in candidates:\n        if os.path.exists(c):\n            return c\n    # 兜底：在比赛目录下递归找 test.csv（排除预处理过的 test_without_percent.csv）\n    hits = [h for h in glob.glob('/kaggle/input/**/test.csv', recursive=True)\n            if 'without_percent' not in h]\n    if hits:\n        return sorted(hits, key=len)[0]\n    raise FileNotFoundError(\"找不到比赛原始 test.csv，请确认已 Add Input 比赛数据集。\")\n\n\ndef find_dataset_csv(name):\n    if os.path.exists(name):\n        return name\n    hits = glob.glob(f'/kaggle/input/**/{name}', recursive=True)\n    if hits:\n        return hits[0]\n    raise FileNotFoundError(f\"找不到 {name}，请确认 data preprocessed 数据集已添加。\")\n\n\ndef preprocess_test_from_raw(raw_test_path):\n    \"\"\"完整复刻 preprocessing.ipynb 的 test 处理：从原始 test.csv 现做。\"\"\"\n    test = pd.read_csv(raw_test_path)\n\n    # Kaggle 要求每位病人预测 -12 ~ 133 周\n    target_weeks = np.arange(-12, 134)\n    grid = pd.MultiIndex.from_product(\n        [test['Patient'].unique(), target_weeks],\n        names=['Patient', 'Weeks']\n    ).to_frame(index=False)\n\n    base = test.rename(columns={'Weeks': 'Base_Week', 'FVC': 'Base_FVC', 'Percent': 'Base_Percent'})\n    df = grid.merge(base, on='Patient', how='left')\n\n    df['Week_diff']    = df['Weeks'] - df['Base_Week']\n    df['Patient_Week'] = df['Patient'] + '_' + df['Weeks'].astype(str)\n\n    # One-Hot 编码 Sex / SmokingStatus，并对齐到训练时的全部类别列\n    df = pd.get_dummies(df, columns=['Sex', 'SmokingStatus'])\n    for col in SEX_COLS + SMOKING_COLS:\n        if col not in df.columns:\n            df[col] = 0\n    df[SEX_COLS + SMOKING_COLS] = df[SEX_COLS + SMOKING_COLS].astype(int)\n\n    return df\n\n\ndef set_seed(seed):\n    np.random.seed(seed); torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)\n\n\ndef laplace_log_likelihood(y_true, y_pred, sigma):\n    sigma_clipped = np.maximum(sigma, 70)\n    delta = np.minimum(np.abs(y_true - y_pred), 1000)\n    return np.mean(-(SQRT2 * delta) / sigma_clipped - np.log(SQRT2 * sigma_clipped))\n\n\ndef laplace_nll_loss(y, mu, sigma):\n    return torch.mean(SQRT2 * torch.abs(y - mu) / sigma + torch.log(SQRT2 * sigma))\n\n\nclass MLPTwoHead(nn.Module):\n    def __init__(self, in_dim, hidden_dims, dropout):\n        super().__init__()\n        layers, prev = [], in_dim\n        for h in hidden_dims:\n            layers += [nn.Linear(prev, h), nn.ReLU(), nn.Dropout(dropout)]\n            prev = h\n        self.trunk = nn.Sequential(*layers)\n        self.mu_head    = nn.Linear(prev, 1)\n        self.sigma_head = nn.Linear(prev, 1)\n        for m in self.modules():\n            if isinstance(m, nn.Linear):\n                nn.init.xavier_uniform_(m.weight); nn.init.zeros_(m.bias)\n\n    def forward(self, x):\n        z = self.trunk(x)\n        mu = self.mu_head(z).squeeze(-1)\n        sigma = F.softplus(self.sigma_head(z)).squeeze(-1) + 1e-3\n        return mu, sigma\n\n\ndef train_one_fold(Xtr, ytr, Xva, yva, y_mean, y_std):\n    model = MLPTwoHead(Xtr.shape[1], HIDDEN_DIMS, DROPOUT).to(DEVICE)\n    opt = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\n    tr_loader = DataLoader(\n        TensorDataset(torch.tensor(Xtr, dtype=torch.float32),\n                      torch.tensor(ytr, dtype=torch.float32)),\n        batch_size=BATCH_SIZE, shuffle=True)\n    Xva_t = torch.tensor(Xva, dtype=torch.float32).to(DEVICE)\n    yva_raw = yva * y_std + y_mean\n\n    best_score, best_state, wait = -1e9, None, 0\n    for epoch in range(MAX_EPOCHS):\n        model.train()\n        for xb, yb in tr_loader:\n            xb, yb = xb.to(DEVICE), yb.to(DEVICE)\n            opt.zero_grad()\n            mu, sigma = model(xb)\n            loss = laplace_nll_loss(yb, mu, sigma)\n            loss.backward(); opt.step()\n        model.eval()\n        with torch.no_grad():\n            mu_s, sigma_s = model(Xva_t)\n            mu_ml    = mu_s.cpu().numpy() * y_std + y_mean\n            sigma_ml = sigma_s.cpu().numpy() * y_std\n        val_score = laplace_log_likelihood(yva_raw, mu_ml, sigma_ml)\n        if val_score > best_score + 1e-6:\n            best_score, wait = val_score, 0\n            best_state = {k: v.clone() for k, v in model.state_dict().items()}\n        else:\n            wait += 1\n            if wait >= PATIENCE:\n                break\n    model.load_state_dict(best_state)\n    return model\n\n\ndef run_one_seed(seed, X, y, groups, X_test):\n    set_seed(seed)\n    gkf = GroupKFold(n_splits=N_SPLITS)\n    oof_mu, oof_sigma = np.zeros(len(X)), np.zeros(len(X))\n    test_mu, test_sigma = np.zeros(len(X_test)), np.zeros(len(X_test))\n\n    for tr_idx, va_idx in gkf.split(X, y, groups):\n        x_scaler = StandardScaler().fit(X[tr_idx])\n        Xtr, Xva, Xte = x_scaler.transform(X[tr_idx]), x_scaler.transform(X[va_idx]), x_scaler.transform(X_test)\n        y_scaler = StandardScaler().fit(y[tr_idx].reshape(-1, 1))\n        y_mean, y_std = y_scaler.mean_[0], y_scaler.scale_[0]\n        ytr = (y[tr_idx] - y_mean) / y_std\n        yva = (y[va_idx] - y_mean) / y_std\n\n        model = train_one_fold(Xtr, ytr, Xva, yva, y_mean, y_std)\n        model.eval()\n        with torch.no_grad():\n            mu_v, sig_v = model(torch.tensor(Xva, dtype=torch.float32).to(DEVICE))\n            mu_t, sig_t = model(torch.tensor(Xte, dtype=torch.float32).to(DEVICE))\n        oof_mu[va_idx]    = mu_v.cpu().numpy() * y_std + y_mean\n        oof_sigma[va_idx] = sig_v.cpu().numpy() * y_std\n        test_mu    += (mu_t.cpu().numpy() * y_std + y_mean) / N_SPLITS\n        test_sigma += (sig_t.cpu().numpy() * y_std) / N_SPLITS\n    return oof_mu, oof_sigma, test_mu, test_sigma\n\n\ndef main():\n    # train 用预处理好的（不会被替换）；test 从比赛原始 test.csv 现做（会被隐藏集替换）\n    train_df = pd.read_csv(find_dataset_csv('train_without_percent.csv'))\n    test_df  = preprocess_test_from_raw(find_competition_test())\n\n    X = train_df[FEATURES].values.astype(np.float32)\n    y = train_df[TARGET].values.astype(np.float32)\n    groups = train_df['Patient'].values\n    X_test = test_df[FEATURES].values.astype(np.float32)\n\n    tag = \"含 Base_Percent\" if USE_BASE_PERCENT else \"不含 Base_Percent\"\n    print(f\"配置: {tag} | 特征数={len(FEATURES)} | test 行数={len(test_df)}\")\n    print(\"-\" * 60)\n\n    oof_mu_sum, oof_sigma_sum = np.zeros(len(X)), np.zeros(len(X))\n    test_mu_sum, test_sigma_sum = np.zeros(len(X_test)), np.zeros(len(X_test))\n    per_seed_scores = []\n\n    for seed in SEEDS:\n        oof_mu, oof_sigma, test_mu, test_sigma = run_one_seed(seed, X, y, groups, X_test)\n        s = laplace_log_likelihood(y, oof_mu, oof_sigma)\n        per_seed_scores.append(s)\n        print(f\"种子 {seed:>4}: 单独 OOF Laplace = {s:.4f}\")\n        oof_mu_sum += oof_mu; oof_sigma_sum += oof_sigma\n        test_mu_sum += test_mu; test_sigma_sum += test_sigma\n\n    n = len(SEEDS)\n    oof_mu_avg    = oof_mu_sum / n\n    oof_sigma_avg = oof_sigma_sum / n          # ← 补上这个\n    test_mu_avg, test_sigma_avg = test_mu_sum / n, test_sigma_sum / n\n    avg_score = laplace_log_likelihood(y, oof_mu_avg, oof_sigma_avg)\n\n    # —— 事后标定 sigma：在 OOF 上搜一个最优缩放系数 ——\n    best_c, best_s = 1.0, avg_score\n    for c in np.arange(0.7, 1.51, 0.01):\n        s = laplace_log_likelihood(y, oof_mu_avg, oof_sigma_avg * c)\n        if s > best_s:\n            best_c, best_s = c, s\n\n    print(\"-\" * 60)\n    print(f\"多种子平均后 Laplace : {avg_score:.4f}\")\n    print(f\"最优 sigma 系数 c    : {best_c:.2f}\")\n    print(f\"标定后 OOF Laplace   : {best_s:.4f}  <- 这个才是最终分数\")\n    print(\"-\" * 60)\n\n    # 生成提交：sigma 乘上最优系数 best_c\n    sub = test_df[['Patient_Week']].copy()\n    sub['FVC'] = test_mu_avg\n    sub['Confidence'] = np.maximum(test_sigma_avg * best_c, 70)\n    sub.to_csv('submission.csv', index=False)\n    print(f\"已生成 submission.csv，共 {len(sub)} 行\")\n\n\nif __name__ == '__main__':\n    main()\n\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-06-05T12:09:10.660894Z","iopub.execute_input":"2026-06-05T12:09:10.663402Z","iopub.status.idle":"2026-06-05T12:14:15.042264Z","shell.execute_reply.started":"2026-06-05T12:09:10.663351Z","shell.execute_reply":"2026-06-05T12:14:15.041222Z"}},"outputs":[],"execution_count":null}]}