{"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":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":112509,"databundleVersionId":14254895},{"sourceType":"datasetVersion","sourceId":14934050,"datasetId":9556425,"databundleVersionId":15801967}],"dockerImageVersionId":31287,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# infer_code1_kaggle.py\n# Code1 (DualStreamDINOv3DensityLoRARegressor) 用 Kaggle 推論コード\n# - test.csv から submission.csv を作成（sample_submission.csv 不要）\n# - Lightning .ckpt を直接ロード\n# - foldアンサンブル対応\n# - DINOv3 + LoRA + Density head + log1p出力 に対応\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, List, Optional, Tuple\n\nimport cv2\nimport numpy as np\nimport pandas as pd\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\n\nimport timm\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\n\n# =====================\n# 競技ターゲット\n# =====================\nALL_TARGET_COLS = [\n    \"Dry_Green_g\",\n    \"Dry_Clover_g\",\n    \"Dry_Dead_g\",\n    \"GDM_g\",\n    \"Dry_Total_g\",\n]\nTARGET5_ORDER = ALL_TARGET_COLS\n\n\n# =====================\n# 推論設定（学習時と一致させる）\n# =====================\n@dataclass\nclass InferenceConfig:\n    model_name: str = \"vit_large_patch16_dinov3_qkvb.lvd1689m\"\n    img_size: int = 448\n\n    # DataLoader\n    batch_size: int = 4\n    num_workers: int = 2\n\n    # LoRA (学習時設定と一致必須)\n    use_lora: bool = True\n    lora_rank: int = 16\n    lora_alpha: int = 32\n    lora_dropout: float = 0.05\n    lora_train_last_n_blocks: int = 8\n    lora_target_modules: Tuple[str, ...] = (\"attn.qkv\", \"attn.proj\")\n\n    # モデル実装依存\n    num_prefix_tokens: int = 5  # code1と同じ仮定（1 CLS + 4 register）\n\n\n# =====================\n# test.csv 整形\n# =====================\ndef make_test_tables(test_csv_path: Path) -> Tuple[pd.DataFrame, pd.DataFrame]:\n    \"\"\"\n    test.csv を読み、\n    - test_img: 画像1枚=1行（推論用）\n    - test_long: 提出行ベース（sample_id単位）\n    を返す\n    \"\"\"\n    test_long = pd.read_csv(test_csv_path)\n\n    if \"image_path\" not in test_long.columns:\n        raise ValueError(\"test.csv に image_path 列が見つかりません。\")\n\n    # sample_id_suffix を作る\n    if \"sample_id_suffix\" in test_long.columns:\n        pass\n    elif \"target_name\" in test_long.columns:\n        test_long[\"sample_id_suffix\"] = test_long[\"target_name\"].astype(str)\n    elif \"sample_id\" in test_long.columns:\n        split_df = test_long[\"sample_id\"].astype(str).str.split(\"__\", n=1, expand=True)\n        if split_df.shape[1] != 2:\n            raise ValueError(\"sample_id を '__' で分割できません。prefix__suffix形式を想定しています。\")\n        test_long[\"sample_id_prefix\"] = split_df[0]\n        test_long[\"sample_id_suffix\"] = split_df[1]\n    else:\n        raise ValueError(\"test.csv に sample_id / target_name / sample_id_suffix が見つかりません。\")\n\n    # sample_id を作る\n    if \"sample_id\" not in test_long.columns:\n        if \"sample_id_prefix\" not in test_long.columns:\n            raise ValueError(\"sample_id を作れません。sample_id_prefix がありません。\")\n        test_long[\"sample_id\"] = (\n            test_long[\"sample_id_prefix\"].astype(str) + \"__\" + test_long[\"sample_id_suffix\"].astype(str)\n        )\n\n    # sample_id_prefix を作る\n    if \"sample_id_prefix\" not in test_long.columns:\n        split_df = test_long[\"sample_id\"].astype(str).str.split(\"__\", n=1, expand=True)\n        if split_df.shape[1] != 2:\n            raise ValueError(\"sample_id から sample_id_prefix を作れません。\")\n        test_long[\"sample_id_prefix\"] = split_df[0]\n\n    # 推論は画像ごとに1回だけ行う\n    test_img = (\n        test_long[[\"image_path\", \"sample_id_prefix\"]]\n        .drop_duplicates(subset=[\"image_path\"])\n        .reset_index(drop=True)\n    )\n\n    # 型を明示（merge事故回避）\n    test_img[\"image_path\"] = test_img[\"image_path\"].astype(str)\n    test_img[\"sample_id_prefix\"] = test_img[\"sample_id_prefix\"].astype(str)\n    test_long[\"image_path\"] = test_long[\"image_path\"].astype(str)\n    test_long[\"sample_id_prefix\"] = test_long[\"sample_id_prefix\"].astype(str)\n    test_long[\"sample_id_suffix\"] = test_long[\"sample_id_suffix\"].astype(str)\n    test_long[\"sample_id\"] = test_long[\"sample_id\"].astype(str)\n\n    return test_img, test_long\n\n\n# =====================\n# Transform (code1のvalid相当)\n# =====================\ndef get_test_transform(img_size: int) -> A.Compose:\n    return A.Compose(\n        [\n            A.Resize(img_size, img_size),\n            A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n            ToTensorV2(),\n        ],\n        additional_targets={\"right\": \"image\"},\n    )\n\n\n# =====================\n# Dataset (code1の左右分割に対応)\n# =====================\nclass BiomassTestDataset(Dataset):\n    \"\"\"\n    code1 と同じく 1000x2000 画像を左右に分割して入力する。\n    test 用なので target は返さない。\n    \"\"\"\n\n    def __init__(self, test_img_df: pd.DataFrame, input_dir: Path, transform: A.Compose):\n        self.test_img_df = test_img_df.reset_index(drop=True)\n        self.input_dir = Path(input_dir)\n        self.transform = transform\n\n    def __len__(self) -> int:\n        return len(self.test_img_df)\n\n    def __getitem__(self, index: int) -> Tuple[torch.Tensor, torch.Tensor]:\n        row = self.test_img_df.iloc[index]\n        image_path = self.input_dir / str(row[\"image_path\"])\n\n        bgr_image = cv2.imread(str(image_path))\n        if bgr_image is None:\n            raise FileNotFoundError(f\"cv2.imread failed: {image_path}\")\n\n        rgb_image = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)\n\n        # code1の実装に合わせて左右1000pxで分割\n        left_rgb = rgb_image[:, :1000, :]\n        right_rgb = rgb_image[:, 1000:, :]\n\n        transformed = self.transform(image=left_rgb, right=right_rgb)\n        left_tensor = transformed[\"image\"]\n        right_tensor = transformed[\"right\"]\n        return left_tensor, right_tensor\n\n\n# =====================\n# LoRA (code1互換)\n# =====================\nclass LoRALinear(nn.Module):\n    \"\"\"\n    code1互換:\n    timm EVA/DINOv3 系で self.qkv.weight を直接参照する実装に対応するため、\n    .weight / .bias を属性として持つ。\n    \"\"\"\n\n    def __init__(self, base_linear: nn.Linear, rank: int, alpha: int, dropout: float):\n        super().__init__()\n        if not isinstance(base_linear, nn.Linear):\n            raise TypeError(\"base_linear must be nn.Linear\")\n\n        self.weight = base_linear.weight\n        self.bias = base_linear.bias\n\n        self.in_features = base_linear.in_features\n        self.out_features = base_linear.out_features\n\n        self.rank = int(rank)\n        self.alpha = int(alpha)\n        self.scale = float(self.alpha) / float(self.rank)\n\n        # baseは凍結\n        self.weight.requires_grad = False\n        if self.bias is not None:\n            self.bias.requires_grad = False\n\n        self.dropout = nn.Dropout(p=float(dropout)) if dropout > 0 else nn.Identity()\n\n        self.lora_A = nn.Parameter(torch.empty(self.rank, self.in_features))\n        self.lora_B = nn.Parameter(torch.empty(self.out_features, self.rank))\n\n        # 初期化（学習前相当）\n        nn.init.normal_(self.lora_A, std=0.01)\n        nn.init.zeros_(self.lora_B)\n\n    def forward(self, x: torch.Tensor) -> torch.Tensor:\n        base_output = nn.functional.linear(x, self.weight, self.bias)\n\n        dropped_x = self.dropout(x)\n        lora_output = nn.functional.linear(\n            nn.functional.linear(dropped_x, self.lora_A, bias=None),\n            self.lora_B,\n            bias=None,\n        )\n        return base_output + self.scale * lora_output\n\n\ndef apply_lora_to_dinov3_vit(\n    backbone: nn.Module,\n    *,\n    rank: int,\n    alpha: int,\n    dropout: float,\n    target_modules: Tuple[str, ...],\n    train_last_n_blocks: int,\n) -> List[nn.Parameter]:\n    if not hasattr(backbone, \"blocks\"):\n        raise AttributeError(\"backbone has no attribute 'blocks'\")\n\n    transformer_blocks = backbone.blocks\n    num_blocks = len(transformer_blocks)\n\n    if train_last_n_blocks <= 0:\n        target_block_indices = set(range(num_blocks))\n    else:\n        start_block_index = max(0, num_blocks - train_last_n_blocks)\n        target_block_indices = set(range(start_block_index, num_blocks))\n\n    lora_trainable_params: List[nn.Parameter] = []\n\n    for block_index, transformer_block in enumerate(transformer_blocks):\n        if block_index not in target_block_indices:\n            continue\n\n        attention_module = getattr(transformer_block, \"attn\", None)\n        if attention_module is None:\n            continue\n\n        if \"attn.qkv\" in target_modules:\n            base_qkv = getattr(attention_module, \"qkv\", None)\n            if isinstance(base_qkv, nn.Linear):\n                lora_qkv = LoRALinear(base_qkv, rank=rank, alpha=alpha, dropout=dropout)\n                setattr(attention_module, \"qkv\", lora_qkv)\n                lora_trainable_params.append(lora_qkv.lora_A)\n                lora_trainable_params.append(lora_qkv.lora_B)\n\n        if \"attn.proj\" in target_modules:\n            base_proj = getattr(attention_module, \"proj\", None)\n            if isinstance(base_proj, nn.Linear):\n                lora_proj = LoRALinear(base_proj, rank=rank, alpha=alpha, dropout=dropout)\n                setattr(attention_module, \"proj\", lora_proj)\n                lora_trainable_params.append(lora_proj.lora_A)\n                lora_trainable_params.append(lora_proj.lora_B)\n\n    return lora_trainable_params\n\n\n# =====================\n# Density head / model (code1互換)\n# =====================\nclass PatchDensityHead(nn.Module):\n    \"\"\"\n    patch token -> density map\n    dens >= 0 を保証するため softplus を使う\n    \"\"\"\n\n    def __init__(self, embed_dim: int, num_targets: int):\n        super().__init__()\n        self.proj = nn.Sequential(\n            nn.LayerNorm(embed_dim),\n            nn.Linear(embed_dim, num_targets),\n        )\n\n    def forward(self, patch_tokens: torch.Tensor, grid_hw: Tuple[int, int]) -> torch.Tensor:\n        # patch_tokens: (B, N, D), N = H*W\n        batch_size, num_patches, embed_dim = patch_tokens.shape\n        grid_h, grid_w = grid_hw\n\n        if num_patches != grid_h * grid_w:\n            raise ValueError(f\"num_patches={num_patches} != H*W={grid_h}*{grid_w}\")\n\n        patch_grid = patch_tokens.view(batch_size, grid_h, grid_w, embed_dim)  # (B,H,W,D)\n        density_map = self.proj(patch_grid).permute(0, 3, 1, 2).contiguous()   # (B,T,H,W)\n        density_map = nn.functional.softplus(density_map)\n        return density_map\n\n\nclass DualStreamDINOv3DensityLoRARegressor(nn.Module):\n    \"\"\"\n    code1互換:\n    DINOv3 patch tokens -> density map -> spatial sum -> log1p\n    \"\"\"\n\n    def __init__(self, infer_cfg: InferenceConfig, pretrained_backbone: bool):\n        super().__init__()\n        self.infer_cfg = infer_cfg\n        self.num_prefix_tokens = infer_cfg.num_prefix_tokens\n\n        self.backbone = timm.create_model(\n            infer_cfg.model_name,\n            pretrained=pretrained_backbone,  # Kaggleでは通常False推奨（重みはckptから読む）\n            num_classes=0,\n            global_pool=\"\",\n        )\n\n        # 学習時と同じく一旦凍結（推論ではどのみち grad 使わない）\n        for backbone_param in self.backbone.parameters():\n            backbone_param.requires_grad = False\n\n        self.lora_params: List[nn.Parameter] = []\n        if infer_cfg.use_lora:\n            self.lora_params = apply_lora_to_dinov3_vit(\n                self.backbone,\n                rank=infer_cfg.lora_rank,\n                alpha=infer_cfg.lora_alpha,\n                dropout=infer_cfg.lora_dropout,\n                target_modules=tuple(infer_cfg.lora_target_modules),\n                train_last_n_blocks=infer_cfg.lora_train_last_n_blocks,\n            )\n\n        embed_dim = self.backbone.num_features\n        self.density_head = PatchDensityHead(embed_dim=embed_dim, num_targets=len(ALL_TARGET_COLS))\n\n    def _get_patch_grid(self, input_tensor: torch.Tensor) -> Tuple[int, int]:\n        patch_size = self.backbone.patch_embed.patch_size\n        if isinstance(patch_size, tuple):\n            patch_size = patch_size[0]\n\n        grid_h = input_tensor.shape[-2] // patch_size\n        grid_w = input_tensor.shape[-1] // patch_size\n        return grid_h, grid_w\n\n    def _encode_to_density(self, input_tensor: torch.Tensor) -> torch.Tensor:\n        features = self.backbone.forward_features(input_tensor)          # (B, prefix+N, D)\n        patch_tokens = features[:, self.num_prefix_tokens :, :]          # (B, N, D)\n        grid_hw = self._get_patch_grid(input_tensor)\n        density_map = self.density_head(patch_tokens, grid_hw)          # (B,5,H,W)\n        return density_map\n\n    def forward(self, left_tensor: torch.Tensor, right_tensor: torch.Tensor) -> torch.Tensor:\n        left_density = self._encode_to_density(left_tensor)\n        right_density = self._encode_to_density(right_tensor)\n\n        total_density = left_density + right_density                     # (B,5,H,W)\n        pred_raw = total_density.sum(dim=(2, 3))                        # (B,5)\n        pred_log = torch.log1p(pred_raw)                                # (B,5)\n        return pred_log\n\n\n# =====================\n# ckpt ロード（Lightning .ckpt 対応）\n# =====================\ndef strip_state_dict_prefix(\n    state_dict: Dict[str, torch.Tensor],\n    prefixes: List[str],\n) -> Dict[str, torch.Tensor]:\n    stripped_state_dict: Dict[str, torch.Tensor] = {}\n\n    for param_name, param_tensor in state_dict.items():\n        new_name = param_name\n        for prefix in prefixes:\n            if new_name.startswith(prefix):\n                new_name = new_name[len(prefix):]\n        stripped_state_dict[new_name] = param_tensor\n\n    return stripped_state_dict\n\n\ndef load_model_from_checkpoint(\n    checkpoint_path: Path,\n    infer_cfg: InferenceConfig,\n    device: torch.device,\n) -> nn.Module:\n    \"\"\"\n    code1のLightningModule(BiomassModule)のckptから、\n    内部 model (DualStream...) に対応する state_dict をロードする。\n    \"\"\"\n    checkpoint_obj = torch.load(checkpoint_path, map_location=\"cpu\")\n\n    if not isinstance(checkpoint_obj, dict):\n        raise ValueError(f\"Unsupported checkpoint format: {checkpoint_path}\")\n\n    if \"state_dict\" in checkpoint_obj:\n        checkpoint_state_dict = checkpoint_obj[\"state_dict\"]\n    else:\n        checkpoint_state_dict = checkpoint_obj\n\n    # code1のBiomassModule内の self.model.xxx を想定して \"model.\" を剥がす\n    # 例: model.backbone.blocks.0.... / model.density_head....\n    model_state_dict = strip_state_dict_prefix(\n        checkpoint_state_dict,\n        prefixes=[\"model.\", \"net.\", \"module.\"],\n    )\n\n    # Kaggleのオフライン推論では pretrained_backbone=False 推奨\n    model = DualStreamDINOv3DensityLoRARegressor(\n        infer_cfg=infer_cfg,\n        pretrained_backbone=False,\n    )\n\n    missing_keys, unexpected_keys = model.load_state_dict(model_state_dict, strict=False)\n\n    # 重要な不一致を見逃しにくくする\n    if len(unexpected_keys) > 0:\n        print(f\"[warn] unexpected keys (first 20): {unexpected_keys[:20]} (total={len(unexpected_keys)})\")\n    if len(missing_keys) > 0:\n        print(f\"[warn] missing keys (first 20): {missing_keys[:20]} (total={len(missing_keys)})\")\n\n    model.to(device)\n    model.eval()\n    return model\n\n\n# =====================\n# 推論本体\n# =====================\n@torch.no_grad()\ndef predict_log_ensemble(\n    ckpt_paths: List[Path],\n    infer_cfg: InferenceConfig,\n    test_img_df: pd.DataFrame,\n    input_dir: Path,\n    device: torch.device,\n) -> np.ndarray:\n    \"\"\"\n    戻り値:\n        pred_log_mean: (N,5)  log1p予測\n    \"\"\"\n    test_transform = get_test_transform(infer_cfg.img_size)\n    test_dataset = BiomassTestDataset(test_img_df, input_dir=input_dir, transform=test_transform)\n    test_loader = DataLoader(\n        test_dataset,\n        batch_size=infer_cfg.batch_size,\n        shuffle=False,\n        num_workers=infer_cfg.num_workers,\n        pin_memory=True,\n        drop_last=False,\n    )\n\n    pred_log_sum: Optional[np.ndarray] = None\n\n    for fold_index, checkpoint_path in enumerate(ckpt_paths):\n        print(f\"[info] loading checkpoint {fold_index}: {checkpoint_path}\")\n        model = load_model_from_checkpoint(checkpoint_path, infer_cfg=infer_cfg, device=device)\n\n        fold_pred_log_batches: List[np.ndarray] = []\n        for left_tensor, right_tensor in test_loader:\n            left_tensor = left_tensor.to(device, non_blocking=True)\n            right_tensor = right_tensor.to(device, non_blocking=True)\n\n            pred_log_tensor = model(left_tensor, right_tensor).float()  # (B,5) log1p\n            fold_pred_log_batches.append(pred_log_tensor.cpu().numpy().astype(np.float32))\n\n        fold_pred_log = np.concatenate(fold_pred_log_batches, axis=0)  # (N,5)\n        pred_log_sum = fold_pred_log if pred_log_sum is None else (pred_log_sum + fold_pred_log)\n\n        # メモリ解放\n        del model\n        if torch.cuda.is_available():\n            torch.cuda.empty_cache()\n\n    pred_log_mean = pred_log_sum / max(1, len(ckpt_paths))\n    return pred_log_mean.astype(np.float32)\n\n\n# =====================\n# submission 作成\n# =====================\ndef build_submission_from_testcsv(\n    pred_log5: np.ndarray,   # (N,5) log1p\n    test_img_df: pd.DataFrame,\n    test_long_df: pd.DataFrame,\n) -> pd.DataFrame:\n    if pred_log5.ndim != 2 or pred_log5.shape[1] != 5:\n        raise ValueError(f\"pred_log5 の形が想定外: {pred_log5.shape} (N,5) を想定\")\n\n    # code1 の保存方針に合わせて raw 空間へ戻す\n    pred_raw5 = np.expm1(pred_log5)\n    pred_raw5 = np.clip(pred_raw5, 0.0, None).astype(np.float32)\n\n    pred_wide_df = test_img_df.copy()\n    for target_index, target_name in enumerate(TARGET5_ORDER):\n        pred_wide_df[target_name] = pred_raw5[:, target_index]\n\n    # test.csv の行順そのままで提出を作る\n    submission_df = test_long_df[[\"sample_id\", \"sample_id_suffix\", \"image_path\"]].copy()\n    submission_df = submission_df.merge(\n        pred_wide_df[[\"image_path\"] + TARGET5_ORDER],\n        on=\"image_path\",\n        how=\"left\",\n    )\n\n    # suffixに応じて target を選ぶ\n    submission_df[\"target\"] = np.nan\n    for target_name in TARGET5_ORDER:\n        row_mask = submission_df[\"sample_id_suffix\"].astype(str) == target_name\n        submission_df.loc[row_mask, \"target\"] = submission_df.loc[row_mask, target_name].astype(float)\n\n    # 異常検知\n    if submission_df[\"target\"].isna().any():\n        bad_rows = submission_df[submission_df[\"target\"].isna()][[\"sample_id\", \"sample_id_suffix\"]].head(10)\n        raise ValueError(\n            \"submission target に NaN が発生した。suffix が想定外の可能性あり。例:\\n\"\n            f\"{bad_rows}\"\n        )\n\n    return submission_df[[\"sample_id\", \"target\"]]\n\n\n# =====================\n# main (Kaggle Notebook用)\n# =====================\ndef main() -> None:\n    # ===== ここを自分のKaggle環境に合わせて変更 =====\n    COMP_DATASET_DIR = Path(\"/kaggle/input/competitions/csiro-biomass\")  # 競技データ\n    WEIGHTS_DATASET_DIR = Path(\"/kaggle/input/datasets/surumendd/csiro-latesub-exp007-000-dinov3-lora-dataset\")  # あなたの重みdataset名に変更\n\n    CKPT_PATHS = [\n        WEIGHTS_DATASET_DIR / \"fold0_epoch22-step391.ckpt\",  # 実ファイル名に変更\n        WEIGHTS_DATASET_DIR / \"fold1_epoch20-step357.ckpt\",\n        WEIGHTS_DATASET_DIR / \"fold2_epoch24-step425.ckpt\",\n        WEIGHTS_DATASET_DIR / \"fold3_epoch17-step306.ckpt\",\n    ]\n\n    infer_cfg = InferenceConfig(\n        model_name=\"vit_large_patch16_dinov3_qkvb.lvd1689m\",\n        img_size=448,\n        batch_size=4,\n        num_workers=2,\n        use_lora=True,\n        lora_rank=16,\n        lora_alpha=32,\n        lora_dropout=0.05,\n        lora_train_last_n_blocks=8,\n        lora_target_modules=(\"attn.qkv\", \"attn.proj\"),\n        num_prefix_tokens=5,\n    )\n    # ================================================\n\n    test_csv_path = COMP_DATASET_DIR / \"test.csv\"\n    input_dir = COMP_DATASET_DIR  # image_path が \"test/xxx.jpg\" 前提\n\n    if not test_csv_path.exists():\n        raise FileNotFoundError(f\"test.csv が見つかりません: {test_csv_path}\")\n\n    for checkpoint_path in CKPT_PATHS:\n        if not checkpoint_path.exists():\n            raise FileNotFoundError(f\"checkpoint が見つかりません: {checkpoint_path}\")\n\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    print(f\"[info] device: {device}\")\n\n\n    test_img_df, test_long_df = make_test_tables(test_csv_path)\n    print(f\"[info] test images: {len(test_img_df)}\")\n    print(f\"[info] test rows  : {len(test_long_df)}\")\n\n    pred_log5 = predict_log_ensemble(\n        ckpt_paths=CKPT_PATHS,\n        infer_cfg=infer_cfg,\n        test_img_df=test_img_df,\n        input_dir=input_dir,\n        device=device,\n    )\n\n    submission_df = build_submission_from_testcsv(\n        pred_log5=pred_log5,\n        test_img_df=test_img_df,\n        test_long_df=test_long_df,\n    )\n\n    output_path = Path(\"submission.csv\")\n    submission_df.to_csv(output_path, index=False)\n\n    print(f\"[info] saved: {output_path} rows={len(submission_df)}\")\n    print(submission_df.head())\n\n\nif __name__ == \"__main__\":\n    main()","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-02-23T16:21:58.712264Z","iopub.execute_input":"2026-02-23T16:21:58.712672Z","iopub.status.idle":"2026-02-23T16:22:47.216198Z","shell.execute_reply.started":"2026-02-23T16:21:58.712640Z","shell.execute_reply":"2026-02-23T16:22:47.215414Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}