{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"sourceType":"competition"}],"dockerImageVersionId":31154,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"\"\"\"\nCSIRO Image2Biomass Prediction - CoAtNet Pipeline with Visualization\n================================================================================\nTwo-Stream Three-Head Specialized Model with Stratified K-Fold CV\n\n新機能:\n1. 統合可視化（全Fold結果を1枚の画像に集約）\n2. 詳細なconfig.json生成（CVスコア、学習時間含む）\n3. 学習履歴の自動記録・可視化\n\n設計方針:\n- 処理内容は変更せず、可視化・記録機能を追加\n- クラス/関数化の徹底\n- 型ヒント完備（Python 3.10+）\n- 日本語Docstring\n================================================================================\n\"\"\"\n\nimport os\nimport random\nimport gc\nimport time\nimport json\nfrom dataclasses import dataclass, field\nfrom typing import Callable, Optional\nfrom pathlib import Path\nfrom enum import Enum\n\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom tqdm import tqdm\n\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.metrics import r2_score\n\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nfrom PIL import Image\nimport timm\n\n\n# =============================================================================\n# 1. 設定管理\n# =============================================================================\n\nclass ModelBackbone(str, Enum):\n    \"\"\"利用可能なCoAtNetモデル\"\"\"\n    # 標準サイズ\n    COATNET_NANO = 'coatnet_nano_rw_224.sw_in1k'\n    COATNET_0 = 'coatnet_0_rw_224.sw_in1k'\n    COATNET_1 = 'coatnet_1_rw_224.sw_in1k'\n    COATNET_2 = 'coatnet_2_rw_224.sw_in12k_ft_in1k'\n    COATNET_3 = 'coatnet_3_rw_224.sw_in12k_ft_in1k'\n    \n    # RMLP版（推奨）\n    COATNET_RMLP_NANO = 'coatnet_rmlp_nano_rw_224.sw_in1k'\n    COATNET_RMLP_1 = 'coatnet_rmlp_1_rw_224.sw_in1k'\n    COATNET_RMLP_2_224 = 'coatnet_rmlp_2_rw_224.sw_in12k_ft_in1k'\n    COATNET_RMLP_2_384 = 'coatnet_rmlp_2_rw_384.sw_in12k_ft_in1k'  # ★推奨\n    \n    # MaxVit（高精度・重い）\n    MAXVIT_TINY = 'maxvit_tiny_tf_224.in1k'\n    MAXVIT_SMALL = 'maxvit_small_tf_224.in1k'\n    MAXVIT_BASE = 'maxvit_base_tf_384.in21k_ft_in1k'\n\n\n@dataclass\nclass Config:\n    \"\"\"パイプライン全体の設定を管理するクラス\"\"\"\n    \n    # ファイルパス設定\n    base_path: str = '/kaggle/input/csiro-biomass'\n    train_csv: str = field(init=False)\n    image_dir: str = field(init=False)\n    output_dir: Path = field(default_factory=lambda: Path('./outputs'))\n    \n    # モデル設定\n    model_name: ModelBackbone = ModelBackbone.COATNET_RMLP_2_384\n    pretrained: bool = True\n    img_size: int = 384\n    \n    # 学習設定\n    seed: int = 42\n    device: torch.device = field(default_factory=lambda: torch.device(\n        'cuda' if torch.cuda.is_available() else 'cpu'\n    ))\n    batch_size: int = 2\n    epochs: int = 20\n    freeze_epochs: int = 5\n    learning_rate: float = 1e-4\n    finetune_lr: float = 1e-5\n    num_workers: int = 2\n    \n    # クロスバリデーション設定\n    n_folds: int = 5\n    \n    # ターゲット変数設定\n    train_target_cols: list[str] = field(default_factory=lambda: [\n        'Dry_Total_g', 'GDM_g', 'Dry_Green_g'\n    ])\n    all_target_cols: list[str] = field(default_factory=lambda: [\n        'Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g'\n    ])\n    \n    # 損失関数の重み設定\n    loss_weights: dict[str, float] = field(default_factory=lambda: {\n        'total_loss': 0.5,\n        'gdm_loss': 0.2,\n        'green_loss': 0.1\n    })\n    \n    # R²スコアの重み設定\n    r2_weights: list[float] = field(default_factory=lambda: [0.1, 0.1, 0.1, 0.2, 0.5])\n    \n    def __post_init__(self):\n        \"\"\"初期化後にパスを構築\"\"\"\n        self.train_csv = os.path.join(self.base_path, 'train.csv')\n        self.image_dir = os.path.join(self.base_path, 'train')\n        self.output_dir.mkdir(parents=True, exist_ok=True)\n    \n    def save(self, path: Path, cv_results: Optional[dict] = None) -> None:\n        \"\"\"\n        設定とCVスコアを詳細にJSON保存\n        \n        Args:\n            path: 保存先JSONファイルパス\n            cv_results: CVスコア結果（fold別スコア、平均、標準偏差等）\n        \"\"\"\n        config_dict = {\n            'data': {\n                'base_path': str(self.base_path),\n                'train_csv': str(self.train_csv),\n                'image_dir': str(self.image_dir),\n                'train_target_cols': self.train_target_cols,\n                'all_target_cols': self.all_target_cols,\n                'loss_weights': self.loss_weights,\n                'r2_weights': self.r2_weights\n            },\n            'model': {\n                'model_name': self.model_name.value if isinstance(self.model_name, Enum) else str(self.model_name),\n                'pretrained': self.pretrained,\n                'img_size': self.img_size\n            },\n            'training': {\n                'batch_size': self.batch_size,\n                'epochs': self.epochs,\n                'learning_rate': self.learning_rate,\n                'finetune_lr': self.finetune_lr,\n                'freeze_epochs': self.freeze_epochs,\n                'num_workers': self.num_workers,\n                'scheduler_patience': 3,\n                'scheduler_factor': 0.2,\n                'n_folds': self.n_folds,\n                'random_state': self.seed,\n                'gradient_clip_norm': 1.0,\n                'device': str(self.device)\n            },\n            'augmentation': {\n                'horizontal_flip_p': 0.5,\n                'vertical_flip_p': 0.5,\n                'rotate90_p': 0.5,\n                'shift_scale_rotate_p': 0.6,\n                'color_jitter_p': 0.75,\n                'distortion_p': 0.3,\n                'blur_noise_p': 0.3,\n                'coarse_dropout_p': 0.4\n            },\n            'visualization': {\n                'enable_visualization': True,\n                'output_dir': str(self.output_dir),\n                'save_plots': True,\n                'dpi': 100,\n                'save_dpi': 300\n            }\n        }\n        \n        if cv_results:\n            config_dict['cv_results'] = cv_results\n        \n        with open(path, 'w', encoding='utf-8') as f:\n            json.dump(config_dict, f, indent=2, ensure_ascii=False)\n        \n        print(f\"💾 Config saved: {path}\")\n\n\n# グローバル設定インスタンス\nCFG = Config()\n\nprint(f\"使用デバイス: {CFG.device}\")\nprint(f\"Backboneモデル: {CFG.model_name.value if isinstance(CFG.model_name, Enum) else CFG.model_name}\")\nprint(f\"画像サイズ: {CFG.img_size}×{CFG.img_size}\")\nprint(f\"出力ディレクトリ: {CFG.output_dir}\")\n\n\n# =============================================================================\n# 2. ユーティリティ関数\n# =============================================================================\n\ndef set_seed(seed: int = 42) -> None:\n    \"\"\"\n    完全な再現性を確保するためにランダムシードを設定\n    \n    Args:\n        seed: 設定するシード値\n    \"\"\"\n    random.seed(seed)\n    os.environ[\"PYTHONHASHSEED\"] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n    print(f\"✅ シード値 {seed} を設定しました\")\n\n\ndef load_and_pivot_data(csv_path: str) -> pd.DataFrame:\n    \"\"\"\n    Long formatのCSVをWide formatに変換\n    \n    Args:\n        csv_path: 学習データCSVのパス\n        \n    Returns:\n        Wide format DataFrame（1行=1画像）\n    \"\"\"\n    print(f\"データ読み込み中: {csv_path}\")\n    df_long = pd.read_csv(csv_path)\n    print(f\"Long format: {len(df_long)} 行\")\n    \n    # Pivot変換\n    df_wide = df_long.pivot(\n        index='image_path',\n        columns='target_name',\n        values='target'\n    ).reset_index()\n    df_wide.columns.name = None\n    \n    print(f\"Wide format: {len(df_wide)} 行（画像数）\")\n    print(f\"カラム: {df_wide.columns.tolist()}\")\n    return df_wide\n\n\ndef create_stratified_folds(\n    df: pd.DataFrame,\n    target_col: str = 'Dry_Total_g',\n    n_folds: int = 5,\n    random_state: int = 42\n) -> pd.DataFrame:\n    \"\"\"\n    層化K-Fold分割を実施してfoldカラムを追加\n    \n    Args:\n        df: 対象DataFrame\n        target_col: 層化に使用するターゲット変数\n        n_folds: 分割数\n        random_state: ランダムシード\n        \n    Returns:\n        foldカラムが追加されたDataFrame\n    \"\"\"\n    print(f\"\\n{n_folds}-Fold層化分割を実施中...\")\n    \n    # ビニング処理\n    num_bins = 10 if len(df) > 100 else int(np.floor(1 + np.log2(len(df))))\n    print(f\"ビン数: {num_bins} ({target_col}で層化)\")\n    \n    df['target_bin'] = pd.cut(df[target_col], bins=num_bins, labels=False)\n    df['fold'] = -1\n    \n    # StratifiedKFold分割\n    skf = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=random_state)\n    for fold_num, (_, valid_idx) in enumerate(skf.split(df, df['target_bin'])):\n        df.loc[valid_idx, 'fold'] = fold_num\n    \n    print(f\"Fold分布:\\n{df['fold'].value_counts().sort_index()}\")\n    df = df.drop(columns=['target_bin'])\n    return df\n\n\n# =============================================================================\n# 3. データ拡張・データセット定義\n# =============================================================================\n\ndef get_train_transforms(img_size: int = 384) -> A.Compose:\n    \"\"\"学習用データ拡張パイプライン\"\"\"\n    return A.Compose([\n        A.HorizontalFlip(p=0.5),\n        A.VerticalFlip(p=0.5),\n        A.RandomRotate90(p=0.5),\n        A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, border_mode=0, p=0.6),\n        A.OneOf([\n            A.OpticalDistortion(distort_limit=0.05, shift_limit=0.05, p=1.0),\n            A.GridDistortion(num_steps=5, distort_limit=0.03, p=1.0),\n            A.ElasticTransform(alpha=30, sigma=5, alpha_affine=10, p=1.0)\n        ], p=0.3),\n        A.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1, p=0.75),\n        A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5),\n        A.OneOf([\n            A.Blur(blur_limit=3, p=1.0),\n            A.GaussNoise(var_limit=(10.0, 50.0), p=1.0)\n        ], p=0.3),\n        A.CoarseDropout(max_holes=8, max_height=img_size // 10, max_width=img_size // 10, fill_value=0, p=0.4),\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\n\ndef get_valid_transforms(img_size: int = 384) -> A.Compose:\n    \"\"\"検証用データ変換パイプライン\"\"\"\n    return A.Compose([\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\n\nclass BiomassDataset(Dataset):\n    \"\"\"Two-Stream用カスタムデータセット\"\"\"\n    \n    def __init__(\n        self,\n        df: pd.DataFrame,\n        transforms_fn: Callable,\n        image_dir: str,\n        train_target_cols: list[str],\n        all_target_cols: list[str]\n    ):\n        self.df = df\n        self.transforms_fn = transforms_fn\n        self.image_dir = image_dir\n        self.image_paths = df['image_path'].values\n        self.train_targets = df[train_target_cols].values\n        self.all_targets = df[all_target_cols].values\n    \n    def __len__(self) -> int:\n        return len(self.df)\n    \n    def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:\n        \"\"\"\n        Returns:\n            img_left, img_right, train_targets, all_targets\n        \"\"\"\n        # 画像読み込み\n        img_path = self.image_paths[idx]\n        filename = os.path.basename(img_path)\n        full_path = os.path.join(self.image_dir, filename)\n        \n        image = cv2.imread(full_path)\n        if image is None:\n            raise FileNotFoundError(f\"画像が読み込めません: {full_path}\")\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n        # 左右分割\n        height, width, _ = image.shape\n        mid_point = width // 2\n        img_left = image[:, :mid_point]\n        img_right = image[:, mid_point:]\n        \n        # 独立した拡張適用\n        transforms = self.transforms_fn(CFG.img_size)\n        img_left_tensor = transforms(image=img_left)['image']\n        \n        transforms_2 = self.transforms_fn(CFG.img_size)\n        img_right_tensor = transforms_2(image=img_right)['image']\n        \n        # ターゲット変数をTensorに変換\n        train_targets_tensor = torch.tensor(self.train_targets[idx], dtype=torch.float32)\n        all_targets_tensor = torch.tensor(self.all_targets[idx], dtype=torch.float32)\n        \n        return img_left_tensor, img_right_tensor, train_targets_tensor, all_targets_tensor\n\n\ndef create_dataloaders(\n    train_df: pd.DataFrame,\n    valid_df: pd.DataFrame,\n    cfg: Config\n) -> tuple[DataLoader, DataLoader]:\n    \"\"\"学習・検証用DataLoaderを作成\"\"\"\n    train_dataset = BiomassDataset(\n        df=train_df,\n        transforms_fn=get_train_transforms,\n        image_dir=cfg.image_dir,\n        train_target_cols=cfg.train_target_cols,\n        all_target_cols=cfg.all_target_cols\n    )\n    \n    valid_dataset = BiomassDataset(\n        df=valid_df,\n        transforms_fn=get_valid_transforms,\n        image_dir=cfg.image_dir,\n        train_target_cols=cfg.train_target_cols,\n        all_target_cols=cfg.all_target_cols\n    )\n    \n    train_loader = DataLoader(\n        train_dataset,\n        batch_size=cfg.batch_size,\n        shuffle=True,\n        num_workers=cfg.num_workers,\n        pin_memory=True\n    )\n    \n    valid_loader = DataLoader(\n        valid_dataset,\n        batch_size=cfg.batch_size * 2,\n        shuffle=False,\n        num_workers=cfg.num_workers,\n        pin_memory=True\n    )\n    \n    return train_loader, valid_loader\n\n\n# =============================================================================\n# 4. モデル定義\n# =============================================================================\n\nclass BiomassModel(nn.Module):\n    \"\"\"Two-Stream Three-Head Specialized Model\"\"\"\n    \n    def __init__(self, model_name: str, pretrained: bool = True):\n        super(BiomassModel, self).__init__()\n        \n        # 共有Backbone\n        self.backbone = timm.create_model(\n            model_name,\n            pretrained=pretrained,\n            num_classes=0,\n            global_pool='avg'\n        )\n        \n        self.n_features = self.backbone.num_features\n        self.n_combined_features = self.n_features * 2\n        \n        # 3つの専門MLPヘッド\n        self.head_total = self._create_mlp_head()\n        self.head_gdm = self._create_mlp_head()\n        self.head_green = self._create_mlp_head()\n    \n    def _create_mlp_head(self) -> nn.Sequential:\n        \"\"\"共通のMLPヘッド構造を作成\"\"\"\n        return nn.Sequential(\n            nn.Linear(self.n_combined_features, self.n_combined_features // 2),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(self.n_combined_features // 2, 1)\n        )\n    \n    def forward(\n        self,\n        img_left: torch.Tensor,\n        img_right: torch.Tensor\n    ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:\n        # 特徴抽出\n        features_left = self.backbone(img_left)\n        features_right = self.backbone(img_right)\n        \n        # 特徴量結合\n        combined = torch.cat([features_left, features_right], dim=1)\n        \n        # 各ヘッドで予測\n        out_total = self.head_total(combined)\n        out_gdm = self.head_gdm(combined)\n        out_green = self.head_green(combined)\n        \n        return out_total, out_gdm, out_green\n\n\n# =============================================================================\n# 5. 損失関数・評価関数\n# =============================================================================\n\nclass WeightedBiomassLoss(nn.Module):\n    \"\"\"重み付き複合損失関数\"\"\"\n    \n    def __init__(self, loss_weights: dict[str, float]):\n        super(WeightedBiomassLoss, self).__init__()\n        self.criterion = nn.SmoothL1Loss()\n        self.weights = loss_weights\n    \n    def forward(\n        self,\n        predictions: tuple[torch.Tensor, torch.Tensor, torch.Tensor],\n        targets: torch.Tensor\n    ) -> torch.Tensor:\n        pred_total, pred_gdm, pred_green = predictions\n        \n        true_total = targets[:, 0].unsqueeze(-1)\n        true_gdm = targets[:, 1].unsqueeze(-1)\n        true_green = targets[:, 2].unsqueeze(-1)\n        \n        loss_total = self.criterion(pred_total, true_total)\n        loss_gdm = self.criterion(pred_gdm, true_gdm)\n        loss_green = self.criterion(pred_green, true_green)\n        \n        total_loss = (\n            self.weights['total_loss'] * loss_total +\n            self.weights['gdm_loss'] * loss_gdm +\n            self.weights['green_loss'] * loss_green\n        )\n        \n        return total_loss\n\n\ndef calculate_competition_score(\n    preds_dict: dict[str, np.ndarray],\n    targets_5: np.ndarray,\n    r2_weights: list[float]\n) -> tuple[float, dict[str, float]]:\n    \"\"\"\n    コンペティション評価指標を計算\n    \n    Returns:\n        weighted_r2: 重み付きR²スコア\n        individual_r2: 各ターゲットの個別R²スコア\n    \"\"\"\n    # 残り2つの予測を導出\n    pred_clover = np.maximum(0, preds_dict['gdm'] - preds_dict['green'])\n    pred_dead = np.maximum(0, preds_dict['total'] - preds_dict['gdm'])\n    \n    # 5つの予測を結合\n    y_preds = np.stack([\n        preds_dict['green'],\n        pred_dead,\n        pred_clover,\n        preds_dict['gdm'],\n        preds_dict['total']\n    ], axis=1)\n    \n    # 各ターゲットのR²を計算\n    r2_scores = r2_score(targets_5, y_preds, multioutput='raw_values')\n    \n    # 重み付き平均\n    weighted_r2 = np.dot(r2_scores, r2_weights)\n    \n    # 個別R²\n    target_names = ['Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g']\n    individual_r2 = {name: float(score) for name, score in zip(target_names, r2_scores)}\n    \n    return float(weighted_r2), individual_r2\n\n\n# =============================================================================\n# 6. 学習ループ\n# =============================================================================\n\ndef train_one_epoch(\n    model: nn.Module,\n    loader: DataLoader,\n    criterion: nn.Module,\n    optimizer: optim.Optimizer,\n    device: torch.device\n) -> float:\n    \"\"\"1エポックの学習\"\"\"\n    model.train()\n    epoch_loss = 0.0\n    \n    pbar = tqdm(loader, desc=\"Training\", leave=False)\n    for img_left, img_right, train_targets, _ in pbar:\n        img_left = img_left.to(device)\n        img_right = img_right.to(device)\n        targets = train_targets.to(device)\n        \n        optimizer.zero_grad()\n        predictions = model(img_left, img_right)\n        loss = criterion(predictions, targets)\n        loss.backward()\n        optimizer.step()\n        \n        epoch_loss += loss.item()\n        pbar.set_postfix(loss=f\"{loss.item():.4f}\")\n    \n    return epoch_loss / len(loader)\n\n\ndef validate_one_epoch(\n    model: nn.Module,\n    loader: DataLoader,\n    criterion: nn.Module,\n    device: torch.device,\n    r2_weights: list[float]\n) -> tuple[float, float, dict[str, float]]:\n    \"\"\"\n    1エポックの検証\n    \n    Returns:\n        avg_loss: 平均損失\n        weighted_r2: 重み付きR²スコア\n        individual_r2: 各ターゲットの個別R²スコア\n    \"\"\"\n    model.eval()\n    epoch_loss = 0.0\n    \n    preds_dict = {'total': [], 'gdm': [], 'green': []}\n    all_targets_list = []\n    \n    with torch.no_grad():\n        pbar = tqdm(loader, desc=\"Validating\", leave=False)\n        for batch in pbar:\n            if len(batch) == 5:\n                img_left, img_right, train_targets, all_targets, _ = batch\n            else:\n                img_left, img_right, train_targets, all_targets = batch\n            \n            img_left = img_left.to(device)\n            img_right = img_right.to(device)\n            train_targets = train_targets.to(device)\n            \n            # 推論\n            pred_total, pred_gdm, pred_green = model(img_left, img_right)\n            \n            # 損失計算\n            loss = criterion((pred_total, pred_gdm, pred_green), train_targets)\n            epoch_loss += loss.item()\n            \n            # 予測値収集\n            preds_dict['total'].append(pred_total.cpu().numpy())\n            preds_dict['gdm'].append(pred_gdm.cpu().numpy())\n            preds_dict['green'].append(pred_green.cpu().numpy())\n            all_targets_list.append(all_targets.cpu().numpy())\n    \n    # 予測値を結合\n    preds_dict_np = {\n        'total': np.concatenate(preds_dict['total']).flatten(),\n        'gdm': np.concatenate(preds_dict['gdm']).flatten(),\n        'green': np.concatenate(preds_dict['green']).flatten()\n    }\n    targets_np = np.concatenate(all_targets_list)\n    \n    # R²スコア計算\n    weighted_r2, individual_r2 = calculate_competition_score(preds_dict_np, targets_np, r2_weights)\n    avg_loss = epoch_loss / len(loader)\n    \n    return avg_loss, weighted_r2, individual_r2\n\n\n# =============================================================================\n# 7. 可視化クラス\n# =============================================================================\n\nclass UnifiedVisualizer:\n    \"\"\"全Fold結果を1枚の画像に統合して可視化\"\"\"\n    \n    def __init__(self, output_dir: Path):\n        self.output_dir = output_dir\n        sns.set_style(\"whitegrid\")\n        plt.rcParams['figure.dpi'] = 100\n    \n    def plot_all_results(\n        self,\n        fold_histories: dict[int, dict],\n        fold_scores: dict[int, float],\n        fold_times: dict[int, float],\n        freeze_epoch: int\n    ) -> None:\n        \"\"\"\n        全Fold結果を1枚の画像に統合\n        \n        Layout:\n        ┌────────────────┬────────────────┐\n        │ All Loss       │ All R² Score   │\n        ├────────────────┴────────────────┤\n        │ CV Score Bar                    │\n        ├─────────────────────────────────┤\n        │ Training Time Bar               │\n        └─────────────────────────────────┘\n        \"\"\"\n        fig = plt.figure(figsize=(20, 14))\n        gs = fig.add_gridspec(3, 2, height_ratios=[2, 1, 1], hspace=0.3, wspace=0.3)\n        \n        ax_loss = fig.add_subplot(gs[0, 0])\n        ax_r2 = fig.add_subplot(gs[0, 1])\n        ax_score = fig.add_subplot(gs[1, :])\n        ax_time = fig.add_subplot(gs[2, :])\n        \n        # 1. Loss curves\n        colors = plt.cm.tab10(np.linspace(0, 1, len(fold_histories)))\n        for fold, color in zip(sorted(fold_histories.keys()), colors):\n            hist = fold_histories[fold]\n            epochs = range(1, len(hist['train_losses']) + 1)\n            ax_loss.plot(epochs, hist['train_losses'], '--', color=color, alpha=0.5, label=f'Fold {fold} Train')\n            ax_loss.plot(epochs, hist['valid_losses'], '-', color=color, linewidth=2, label=f'Fold {fold} Valid')\n        \n        ax_loss.axvline(x=freeze_epoch, color='red', linestyle=':', linewidth=2, label=f'Unfreeze (Epoch {freeze_epoch})')\n        ax_loss.set_xlabel('Epoch', fontsize=12)\n        ax_loss.set_ylabel('Loss', fontsize=12)\n        ax_loss.set_title('Training & Validation Loss (All Folds)', fontsize=14, fontweight='bold')\n        ax_loss.legend(loc='upper right', fontsize=8, ncol=2)\n        ax_loss.grid(True, alpha=0.3)\n        \n        # 2. R² Score curves\n        for fold, color in zip(sorted(fold_histories.keys()), colors):\n            hist = fold_histories[fold]\n            epochs = range(1, len(hist['valid_scores']) + 1)\n            ax_r2.plot(epochs, hist['valid_scores'], '-', color=color, linewidth=2, \n                      marker='o', markersize=3, label=f'Fold {fold}')\n        \n        ax_r2.axvline(x=freeze_epoch, color='red', linestyle=':', linewidth=2, label=f'Unfreeze (Epoch {freeze_epoch})')\n        ax_r2.set_xlabel('Epoch', fontsize=12)\n        ax_r2.set_ylabel('R² Score', fontsize=12)\n        ax_r2.set_title('Validation R² Score (All Folds)', fontsize=14, fontweight='bold')\n        ax_r2.legend(loc='lower right', fontsize=10)\n        ax_r2.grid(True, alpha=0.3)\n        \n        # 3. CV Score Bar\n        folds = sorted(fold_scores.keys())\n        scores = [fold_scores[f] for f in folds]\n        mean_score = np.mean(scores)\n        std_score = np.std(scores)\n        \n        bars = ax_score.bar(folds, scores, color='skyblue', edgecolor='navy', linewidth=1.5, width=0.6)\n        for fold, score, bar in zip(folds, scores, bars):\n            height = bar.get_height()\n            ax_score.text(bar.get_x() + bar.get_width() / 2., height, f'{score:.4f}',\n                         ha='center', va='bottom', fontweight='bold', fontsize=11)\n        \n        ax_score.axhline(y=mean_score, color='red', linestyle='--', linewidth=2.5,\n                        label=f'Mean R² = {mean_score:.4f} ± {std_score:.4f}')\n        ax_score.set_xlabel('Fold', fontsize=12)\n        ax_score.set_ylabel('Best R² Score', fontsize=12)\n        ax_score.set_title('Cross-Validation Performance', fontsize=14, fontweight='bold')\n        ax_score.set_xticks(folds)\n        ax_score.set_xticklabels([f'Fold {f}' for f in folds])\n        ax_score.legend(fontsize=11)\n        ax_score.grid(True, alpha=0.3, axis='y')\n        \n        # 4. Training Time Bar\n        times = [fold_times[f] for f in folds]\n        mean_time = np.mean(times)\n        \n        bars = ax_time.bar(folds, times, color='lightcoral', edgecolor='darkred', linewidth=1.5, width=0.6)\n        for fold, time_min, bar in zip(folds, times, bars):\n            height = bar.get_height()\n            ax_time.text(bar.get_x() + bar.get_width() / 2., height, f'{time_min:.1f}m',\n                        ha='center', va='bottom', fontweight='bold', fontsize=11)\n        \n        ax_time.axhline(y=mean_time, color='blue', linestyle='--', linewidth=2.5,\n                       label=f'Mean Time = {mean_time:.1f} min')\n        ax_time.set_xlabel('Fold', fontsize=12)\n        ax_time.set_ylabel('Training Time (minutes)', fontsize=12)\n        ax_time.set_title('Training Time per Fold', fontsize=14, fontweight='bold')\n        ax_time.set_xticks(folds)\n        ax_time.set_xticklabels([f'Fold {f}' for f in folds])\n        ax_time.legend(fontsize=11)\n        ax_time.grid(True, alpha=0.3, axis='y')\n        \n        # 全体タイトル\n        fig.suptitle('CSIRO Biomass Prediction - Complete Training Results', \n                    fontsize=16, fontweight='bold', y=0.995)\n        \n        # 保存\n        save_path = self.output_dir / 'training_results_unified.png'\n        plt.savefig(save_path, bbox_inches='tight', dpi=300)\n        print(f\"\\n📊 Saved unified visualization: {save_path}\")\n        plt.close()\n\n\n# =============================================================================\n# 8. 学習パイプライン\n# =============================================================================\n\ndef run_training_fold(\n    fold: int,\n    df: pd.DataFrame,\n    cfg: Config\n) -> tuple[float, dict, float]:\n    \"\"\"\n    指定Foldの学習を実行\n    \n    Returns:\n        best_score: 最良R²スコア\n        history: 学習履歴（loss, scoreの推移）\n        fold_time: 学習時間（分）\n    \"\"\"\n    print(f\"\\n{'='*50}\")\n    print(f\"🚀 FOLD {fold} 学習開始\")\n    print(f\"{'='*50}\")\n    \n    fold_start = time.time()\n    \n    # データ分割\n    train_df = df[df['fold'] != fold].reset_index(drop=True)\n    valid_df = df[df['fold'] == fold].reset_index(drop=True)\n    print(f\"学習データ: {len(train_df)} 枚 | 検証データ: {len(valid_df)} 枚\")\n    \n    # DataLoader作成\n    train_loader, valid_loader = create_dataloaders(train_df, valid_df, cfg)\n    \n    # モデル初期化\n    print(f\"モデル読み込み中: {cfg.model_name.value if isinstance(cfg.model_name, Enum) else cfg.model_name}\")\n    model_name_str = cfg.model_name.value if isinstance(cfg.model_name, Enum) else str(cfg.model_name)\n    model_base = BiomassModel(model_name_str, cfg.pretrained)\n    \n    if torch.cuda.device_count() > 1:\n        print(f\"{torch.cuda.device_count()} GPUでDataParallel使用\")\n        model = nn.DataParallel(model_base)\n    else:\n        model = model_base\n    model.to(cfg.device)\n    \n    criterion = WeightedBiomassLoss(cfg.loss_weights).to(cfg.device)\n    best_score = -float('inf')\n    \n    # 学習履歴\n    history = {'train_losses': [], 'valid_losses': [], 'valid_scores': []}\n    \n    # Stage 1: Backbone凍結\n    print(f\"\\n--- Stage 1: Backbone凍結 (Epochs 1-{cfg.freeze_epochs}) ---\")\n    \n    backbone = model.module.backbone if isinstance(model, nn.DataParallel) else model.backbone\n    for param in backbone.parameters():\n        param.requires_grad = False\n    \n    optimizer = optim.Adam(\n        filter(lambda p: p.requires_grad, model.parameters()),\n        lr=cfg.learning_rate\n    )\n    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=2)\n    \n    for epoch in range(1, cfg.freeze_epochs + 1):\n        print(f\"\\nEpoch {epoch}/{cfg.epochs} (Stage 1)\")\n        \n        train_loss = train_one_epoch(model, train_loader, criterion, optimizer, cfg.device)\n        valid_loss, competition_score, individual_r2 = validate_one_epoch(\n            model, valid_loader, criterion, cfg.device, cfg.r2_weights\n        )\n        \n        history['train_losses'].append(train_loss)\n        history['valid_losses'].append(valid_loss)\n        history['valid_scores'].append(competition_score)\n        \n        scheduler.step(valid_loss)\n        \n        print(f\"Train Loss: {train_loss:.4f} | Valid Loss: {valid_loss:.4f} | R²: {competition_score:.4f}\")\n        \n        if competition_score > best_score:\n            best_score = competition_score\n            model_path = cfg.output_dir / f'best_model_fold{fold}.pth'\n            torch.save(\n                model.module.state_dict() if isinstance(model, nn.DataParallel) else model.state_dict(),\n                model_path\n            )\n            print(f\"✨ R²更新! モデル保存: {model_path}\")\n    \n    # Stage 2: Backbone解凍\n    print(f\"\\n--- Stage 2: Backbone解凍 (Epochs {cfg.freeze_epochs + 1}-{cfg.epochs}) ---\")\n    \n    for param in backbone.parameters():\n        param.requires_grad = True\n    \n    optimizer = optim.Adam(model.parameters(), lr=cfg.finetune_lr)\n    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.2, patience=3)\n    \n    for epoch in range(cfg.freeze_epochs + 1, cfg.epochs + 1):\n        print(f\"\\nEpoch {epoch}/{cfg.epochs} (Stage 2)\")\n        \n        train_loss = train_one_epoch(model, train_loader, criterion, optimizer, cfg.device)\n        valid_loss, competition_score, individual_r2 = validate_one_epoch(\n            model, valid_loader, criterion, cfg.device, cfg.r2_weights\n        )\n        \n        history['train_losses'].append(train_loss)\n        history['valid_losses'].append(valid_loss)\n        history['valid_scores'].append(competition_score)\n        \n        scheduler.step(valid_loss)\n        \n        print(f\"Train Loss: {train_loss:.4f} | Valid Loss: {valid_loss:.4f} | R²: {competition_score:.4f}\")\n        \n        if competition_score > best_score:\n            best_score = competition_score\n            model_path = cfg.output_dir / f'best_model_fold{fold}.pth'\n            torch.save(\n                model.module.state_dict() if isinstance(model, nn.DataParallel) else model.state_dict(),\n                model_path\n            )\n            print(f\"✨ R²更新! モデル保存: {model_path}\")\n    \n    # 個別R²表示\n    print(\"\\n📊 Individual R² Scores:\")\n    for name, score in individual_r2.items():\n        print(f\"  {name}: {score:.4f}\")\n    \n    fold_elapsed = (time.time() - fold_start) / 60\n    print(f\"\\n🎉 Fold {fold} 完了 ({fold_elapsed:.2f} 分)\")\n    print(f\"Best R² Score: {best_score:.4f}\")\n    \n    # メモリクリーンアップ\n    del model, train_loader, valid_loader\n    gc.collect()\n    torch.cuda.empty_cache()\n    \n    return best_score, history, fold_elapsed\n\n\n# =============================================================================\n# 9. メイン実行\n# =============================================================================\n\ndef main():\n    \"\"\"パイプライン全体を実行\"\"\"\n    \n    # シード設定\n    set_seed(CFG.seed)\n    \n    # データ準備\n    df_wide = load_and_pivot_data(CFG.train_csv)\n    df_wide = create_stratified_folds(df_wide, n_folds=CFG.n_folds, random_state=CFG.seed)\n    \n    # 可視化クラス初期化\n    visualizer = UnifiedVisualizer(CFG.output_dir)\n    \n    # 全Foldの学習実行\n    fold_scores = {}\n    fold_histories = {}\n    fold_times = {}\n    \n    pipeline_start = time.time()\n    \n    for fold in range(CFG.n_folds):\n        try:\n            best_score, history, fold_time = run_training_fold(fold, df_wide, CFG)\n            fold_scores[fold] = best_score\n            fold_histories[fold] = history\n            fold_times[fold] = fold_time\n        except Exception as e:\n            print(f\"\\n❌ Error in Fold {fold}: {e}\")\n            import traceback\n            traceback.print_exc()\n            gc.collect()\n            torch.cuda.empty_cache()\n            continue\n    \n    total_elapsed = (time.time() - pipeline_start) / 60\n    \n    # CVスコア集計\n    if fold_scores:\n        scores_list = list(fold_scores.values())\n        times_list = list(fold_times.values())\n        mean_score = np.mean(scores_list)\n        std_score = np.std(scores_list)\n        \n        print(\"\\n\" + \"=\"*50)\n        print(\"📊 全Fold OOF予測を統合\")\n        print(\"=\"*50)\n        print(f\"\\n【最終CVスコア】\")\n        print(f\"✅ 重み付きR²: {mean_score:.4f} ± {std_score:.4f}\")\n        print(f\"   最小: {np.min(scores_list):.4f}\")\n        print(f\"   最大: {np.max(scores_list):.4f}\")\n        print(f\"\\n【学習時間】\")\n        print(f\"   合計: {total_elapsed:.2f} 分 ({total_elapsed/60:.2f} 時間)\")\n        print(f\"   平均: {np.mean(times_list):.2f} 分/Fold\")\n        \n        # 統合可視化\n        visualizer.plot_all_results(fold_histories, fold_scores, fold_times, CFG.freeze_epochs)\n        \n        # config.json保存\n        cv_results = {\n            'fold_scores': {f\"fold_{k}\": float(v) for k, v in fold_scores.items()},\n            'fold_times': {f\"fold_{k}\": float(v) for k, v in fold_times.items()},\n            'mean_score': float(mean_score),\n            'std_score': float(std_score),\n            'min_score': float(np.min(scores_list)),\n            'max_score': float(np.max(scores_list)),\n            'total_training_time_minutes': float(total_elapsed),\n            'total_training_time_hours': float(total_elapsed / 60),\n            'mean_training_time_per_fold_minutes': float(np.mean(times_list))\n        }\n        CFG.save(CFG.output_dir / 'config.json', cv_results)\n        \n        print(\"\\n🎊 パイプライン完了!\")\n\n\nif __name__ == '__main__':\n    main()","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-11-08T13:52:25.979984Z","iopub.execute_input":"2025-11-08T13:52:25.980252Z","iopub.status.idle":"2025-11-08T13:55:47.536823Z","shell.execute_reply.started":"2025-11-08T13:52:25.980231Z","shell.execute_reply":"2025-11-08T13:55:47.535678Z"}},"outputs":[],"execution_count":null}]}