{"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":"gpu","dataSources":[{"sourceId":112899,"databundleVersionId":13449579,"sourceType":"competition"}],"dockerImageVersionId":31090,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"from __future__ import annotations\nfrom pathlib import Path\nfrom typing import List, Tuple\nimport os\nimport numpy as np\nfrom numpy.lib.format import open_memmap\nimport pandas as pd\nimport cv2\nfrom concurrent.futures import ProcessPoolExecutor, as_completed\nfrom tqdm import tqdm\n\n# =====================\n# CONFIG (ajuste aqui)\n# =====================\nTRAIN_CSV = \"/kaggle/input/grand-xray-slam-division-a/train1.csv\"\nTEST_CSV  = \"/kaggle/input/grand-xray-slam-division-a/sample_submission_1.csv\"   # precisa ter 'Image_name'\nTRAIN_DIR = \"/kaggle/input/grand-xray-slam-division-a/train1\"\nTEST_DIR  = \"/kaggle/input/grand-xray-slam-division-a/test1\"\n\nOUT_DIR    = \"/kaggle/working/\"\nIMAGE_SIZE = 320\nCHANNELS   = 1            # grayscale\nOVERWRITE  = True\nN_WORKERS  = max(1, (os.cpu_count() or 2) - 1)\n\nLABEL_COLUMNS = [\n    'Atelectasis','Cardiomegaly','Consolidation','Edema','Enlarged Cardiomediastinum',\n    'Fracture','Lung Lesion','Lung Opacity','No Finding','Pleural Effusion',\n    'Pleural Other','Pneumonia','Pneumothorax','Support Devices'\n]\n\n# ==== Verificações de diretório (patch) ====  # TRAIN_DIR_CHECK_PATCH\nfrom pathlib import Path\nimport os\n\ndef _assert_exists_dir(path_str, hint=\"\"):\n    p = Path(path_str)\n    if not p.exists():\n        print(f\"[ERRO] Diretório não encontrado: {p}\")\n        # tentar auto-descobrir\n        candidates = [x for x in Path(\"/kaggle/input\").glob(\"*\") if x.is_dir() and \"xray\" in x.name.lower() or \"grand\" in x.name.lower()]\n        if candidates:\n            print(\"[DICA] Datasets potenciais em /kaggle/input:\", [c.name for c in candidates])\n        if hint:\n            print(\"Hint:\", hint)\n        raise FileNotFoundError(str(p))\n\n_assert_exists_dir(TRAIN_DIR, \"Ajuste TRAIN_DIR para o dataset correto em /kaggle/input\")\n_assert_exists_dir(TEST_DIR, \"Ajuste TEST_DIR para o dataset correto em /kaggle/input\")\n\n\n\n# =====================\n# Helpers\n# =====================\ndef _ensure_outdir(p: Path):\n    p.mkdir(parents=True, exist_ok=True)\n\ndef _validate_files(file_paths: List[Path]):\n    missing = [str(p) for p in file_paths if not p.is_file()]\n    if missing:\n        raise FileNotFoundError(f\"{len(missing)} imagens não encontradas. Ex.: {missing[:3]}\")\n\ndef _compute_slices(n: int, n_workers: int, chunk_size: int | None = None) -> List[Tuple[int,int]]:\n    if chunk_size and chunk_size > 0:\n        slices = []\n        for s in range(0, n, chunk_size):\n            e = min(n, s + chunk_size)\n            if s < e:\n                slices.append((s, e))\n        return slices\n    # fallback: dividir por workers\n    n_workers = max(1, min(n_workers, n))\n    base = n // n_workers\n    rem  = n % n_workers\n    slices = []\n    start = 0\n    for w in range(n_workers):\n        size = base + (1 if w < rem else 0)\n        end = start + size\n        if start < end:\n            slices.append((start, end))\n        start = end\n    return slices\n\ndef _worker_write_slice(\n    out_imgs: str,\n    files_chunk: List[str],\n    idx_start: int,\n    image_size: int,\n):\n    # Reabrir memmap neste processo\n    arr = open_memmap(out_imgs, mode='r+')\n    H = W = image_size\n\n    # Array scratch para evitar realocações\n    scratch = np.empty((H, W), dtype=np.uint8)\n\n    for k, path_str in enumerate(files_chunk):\n        i = idx_start + k\n        # Leitura grayscale rápida\n        img = cv2.imread(path_str, cv2.IMREAD_GRAYSCALE)\n        if img is None:\n            raise RuntimeError(f\"Falha ao ler: {path_str}\")\n\n        # Resize (INTER_AREA é adequado para downscale)\n        if img.shape[0] != H or img.shape[1] != W:\n            img = cv2.resize(img, (W, H), interpolation=cv2.INTER_AREA)\n\n        # Garantir dtype\n        if img.dtype != np.uint8:\n            img = img.astype(np.uint8, copy=False)\n\n        scratch[...] = img\n        # Escrever (C,H,W) com C=1\n        arr[i, 0, :, :] = scratch\n\n    # Fechar view do memmap neste processo\n    del arr\n    return True\n\ndef _pack_split(csv_path: str, img_dir: str, is_train: bool):\n    csv_path = Path(csv_path); img_dir = Path(img_dir)\n    out_dir  = Path(OUT_DIR);  _ensure_outdir(out_dir)\n\n    df = pd.read_csv(csv_path)\n    if 'Image_name' not in df.columns:\n        raise KeyError(\"CSV precisa conter a coluna 'Image_name'.\")\n\n    n = len(df)\n    files: List[Path] = [img_dir / str(nm) for nm in df['Image_name'].tolist()]\n    _validate_files(files)\n\n    c, h, w = CHANNELS, IMAGE_SIZE, IMAGE_SIZE\n\n    if is_train:\n        out_imgs = out_dir / f\"images_train_{IMAGE_SIZE}_c1_uint8.npy\"\n        out_lbls = out_dir / \"labels_train.npy\"\n        out_idx  = out_dir / \"index_train.csv\"\n    else:\n        out_imgs = out_dir / f\"images_test_{IMAGE_SIZE}_c1_uint8.npy\"\n        out_lbls = None\n        out_idx  = out_dir / \"index_test.csv\"\n\n    if out_imgs.exists() and not OVERWRITE:\n        raise FileExistsError(f\"{out_imgs} já existe. Defina OVERWRITE=True para sobrescrever.\")\n\n    print(f\"=== PACK {'TRAIN' if is_train else 'TEST'} ===\")\n    print(f\"N={n} | {h}x{w} | C={c} | dtype=uint8 -> {out_imgs}\")\n\n    # Criar arquivo .npy memmap e pré-alocar\n    arr = open_memmap(str(out_imgs), mode='w+', dtype=np.uint8, shape=(n, c, h, w))\n    del arr  # será reaberto pelos workers em 'r+'\n\n    # Estratégia de progresso:\n    # - blocos menores deixam a barra mais suave; 512/1024 são bons valores.\n    # - ajuste chunk_size se quiser barras mais granulares.\n    chunk_size = 1024\n    slices = _compute_slices(n, N_WORKERS, chunk_size=chunk_size)\n    paths_str = [str(p) for p in files]\n\n    total_done = 0\n    with ProcessPoolExecutor(max_workers=min(N_WORKERS, len(slices))) as ex, \\\n         tqdm(total=n, desc=\"Empacotando (paralelo)\", unit=\"img\", dynamic_ncols=True) as pbar:\n        futures = []\n        for (s, e) in slices:\n            fut = ex.submit(\n                _worker_write_slice,\n                str(out_imgs),\n                paths_str[s:e],\n                s,\n                IMAGE_SIZE,\n            )\n            futures.append((fut, e - s))\n\n        # Conforme cada slice concluir, atualizamos o progresso\n        for fut, size in futures:\n            fut.result()  # Propaga erro, se houver\n            total_done += size\n            pbar.update(size)\n\n    # CSV rápido\n    if is_train:\n        # Checar colunas\n        missing = [c for c in LABEL_COLUMNS if c not in df.columns]\n        if missing:\n            raise KeyError(f\"Colunas faltantes no CSV de treino: {missing}\")\n\n        out_df = pd.DataFrame({\n            \"idx\": np.arange(n, dtype=np.int32),\n            \"path\": paths_str,  # opcional: salvar apenas Image_name\n        })\n        for col in LABEL_COLUMNS:\n            out_df[col] = df[col].astype(np.float32).values\n        out_df.to_csv(out_idx, index=False)\n\n        # Labels separados (compatível com treino)\n        labels = df[LABEL_COLUMNS].astype(np.float32).values\n        np.save(out_lbls, labels)\n        print(f\"Labels salvos em: {out_lbls}\")\n\n    else:\n        out_df = pd.DataFrame({\n            \"idx\": np.arange(n, dtype=np.int32),\n            \"path\": paths_str,\n        })\n        out_df.to_csv(out_idx, index=False)\n\n    print(f\"Imagens salvas em: {out_imgs}\")\n    print(f\"Índice salvo em:  {out_idx}\")\n\n# =====================\n# Main\n# =====================\nif __name__ == \"__main__\":\n    _pack_split(TRAIN_CSV, TRAIN_DIR, is_train=True)\n    _pack_split(TEST_CSV,  TEST_DIR,  is_train=False)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# -*- coding: utf-8 -*-\nfrom __future__ import annotations\nfrom pathlib import Path\nfrom typing import Optional, List, Dict, Any, Tuple\nimport warnings, random, json, os, sys, math\nimport numpy as np\nimport pandas as pd\nimport timm\nfrom tqdm import tqdm\nfrom sklearn.metrics import roc_auc_score, f1_score\nfrom sklearn.exceptions import UndefinedMetricWarning\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader, Subset\nfrom torchvision.transforms import functional as TF\nfrom copy import deepcopy\n\n# =========================\n# CONFIG\n# =========================\nOUT_DIR       = Path(\"/kaggle/working\")\nOUT_DIR.mkdir(parents=True, exist_ok=True)\nIMG_TRAIN_NPY = r\"/kaggle/working/images_train_320_c1_uint8.npy\"\nIMG_TEST_NPY  = r\"/kaggle/working/images_test_320_c1_uint8.npy\"\nLBL_TRAIN_NPY = r\"/kaggle/working/labels_train.npy\"\nINDEX_TRAIN_CSV = r\"/kaggle/working/index_train.csv\"\nINDEX_TEST_CSV  = r\"/kaggle/working/index_test.csv\"\nTRAIN1_CSV      = r\"/kaggle/input/grand-xray-slam-division-a/train1.csv\"\nTEST_CSV_PATH   = r\"/kaggle/input/grand-xray-slam-division-a/sample_submission_1.csv\"\nRUNS = Path(\"/kaggle/working/Runs\")\nRUNS.mkdir(parents=True, exist_ok=True)\nPATIENT_MAP_JSON = OUT_DIR/\"patient_map.json\"\n\n# Básico\nBATCH_SIZE = 64\nEPOCHS     = 10\nSEED       = 42\nK_FOLDS    = 5\nNUM_WORKERS = 0\n\n# Opt (LLRD/WD/etc.)\nBASE_LR_HEAD    = 1e-3\nBASE_LR_BACKB   = 1e-4\nLR_DECAY_STAGE  = 0.5\nWEIGHT_DECAY    = 5e-2\nWARMUP_STEPS    = 800\nCLIP_NORM       = 1.0\nUSE_AMP         = True\n\n# Modelo (ConvNeXt)\nDROP_RATE       = 0.10\nDROP_PATH_RATE  = 0.10\n\nLABEL_COLUMNS = [\n    'Atelectasis','Cardiomegaly','Consolidation','Edema','Enlarged Cardiomediastinum',\n    'Fracture','Lung Lesion','Lung Opacity','No Finding','Pleural Effusion',\n    'Pleural Other','Pneumonia','Pneumothorax','Support Devices'\n]\n# Loss config\nLOSS_MODE        = \"bce\" # \"bce\" | \"focal\"\nFOCAL_GAMMA      = 1.5\nFOCAL_ALPHA      = None  # None | float | list de tamanho C *None se usando AUTO_ALPHA\nAUTO_ALPHA_MODE  = None  # None | \"pos_prior\" | \"effective_num\"\nCB_BETA          = 0.999\nALPHA_CLIP_MIN   = 0.05\nALPHA_CLIP_MAX   = 0.95\n\n# Early stop\nEARLYSTOP_PATIENCE  = 1\nEARLYSTOP_MIN_DELTA = 1e-4\n\n# Normalização (ImageNet)\nIMAGENET_MEAN = (0.485, 0.456, 0.406)\nIMAGENET_STD  = (0.229, 0.224, 0.225)\n\n# NF exclusividade (apenas para métrica opcional)\nAPPLY_NF_EXCL_IN_VAL = False\nT_NF_VAL = 0.5\n\n# Reg. exclusividade\nLAMBDA_EXCL_START = 0.0\nLAMBDA_EXCL_TARGET = 0.0\nANNEAL_EPOCHS = 1\n\n# DRY RUN\nDRY_RUN = True\nDRY_RUN_N_BATCHES_TRAIN = 3\nDRY_RUN_N_BATCHES_VAL   = 3\nDRY_RUN_N = 256         # máx. imagens para inferir\nDRY_RUN_FOLDS = 1       # máx. folds no ensemble\nDRY_RUN_RANDOM = False  # True para amostra aleatória\n\n# EMA\nUSE_EMA   = True\nEMA_DECAY = 0.999\n\n# Aug fracas\nAUG_WEAK          = False\nAUG_DEG           = 7\nAUG_TRANSLATE_FR  = 0.02\nAUG_SCALE_FR      = 0.05\nAUG_HFLIP_PROB    = 0.0 #Usar com cuidado, muda significado semântico.\n\n# WD dif.\nWD_BACKBONE = 5e-2\nWD_HEAD     = 1e-2\n\n# Accum\nGRAD_ACCUM_STEPS = 1\n\nwarnings.filterwarnings(\"ignore\", category=UndefinedMetricWarning)\n\n# =========================\n# Utils\n# =========================\ndef set_seed(seed: int = 42):\n    random.seed(seed); np.random.seed(seed)\n    torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)\n\ndef make_pbar(iterable, desc):\n    disable_env = os.environ.get(\"TQDM_DISABLE\", \"\")\n    disable_flag = (disable_env == \"1\")\n    try:\n        is_tty = hasattr(sys.stdout, \"isatty\") and sys.stdout.isatty()\n    except Exception:\n        is_tty = False\n    disable = disable_flag or (not is_tty)\n    try:\n        return tqdm(iterable, desc=desc, leave=False, ncols=80, file=sys.stdout, disable=disable)\n    except Exception:\n        return iterable\n\n# Exclusividade NF (para métrica/regularização)\ndef apply_nf_excl_strict_with_tnf(probs: np.ndarray, label_columns: List[str], t_nf: float):\n    out = probs.copy()\n    idx_nf = label_columns.index(\"No Finding\")\n    pick_nf = (probs[:, idx_nf] >= float(t_nf))\n    if np.any(pick_nf):\n        out[pick_nf, :] = 0.0\n        out[pick_nf, idx_nf] = 1.0\n    out[~pick_nf, idx_nf] = 0.0\n    return out\n\ndef exclusivity_regularizer(p: torch.Tensor, y: torch.Tensor, idx_nf: int,\n                            cond_on_ynf: bool = True) -> torch.Tensor:\n    mask = torch.ones(p.size(1), dtype=torch.bool, device=p.device)\n    mask[idx_nf] = False\n    p_any = 1.0 - torch.prod(1.0 - p[:, mask], dim=1)\n    reg = p[:, idx_nf] * p_any\n    if cond_on_ynf: reg = reg * y[:, idx_nf]\n    return reg.mean()\n\ndef lambda_excl_at_epoch(epoch: int) -> float:\n    if epoch <= 1: return float(LAMBDA_EXCL_START)\n    if epoch >= ANNEAL_EPOCHS: return float(LAMBDA_EXCL_TARGET)\n    t = (epoch-1) / max(ANNEAL_EPOCHS-1, 1)\n    return float(LAMBDA_EXCL_START + t*(LAMBDA_EXCL_TARGET - LAMBDA_EXCL_START))\n\ndef safe_macro_auc(y_true: np.ndarray, y_pred: np.ndarray) -> float:\n    from sklearn.metrics import roc_auc_score\n    try:\n        return float(roc_auc_score(y_true, y_pred, average='macro'))\n    except Exception:\n        aucs = []\n        C = y_true.shape[1]\n        for c in range(C):\n            yt = y_true[:, c]; yp = y_pred[:, c]\n            if len(np.unique(yt)) == 2:\n                aucs.append(roc_auc_score(yt, yp))\n        return float(np.mean(aucs)) if aucs else float(\"nan\")\n\n# =========================\n# Dataset .NPY\n# =========================\n\nIMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406]).view(3,1,1)\nIMAGENET_STD  = torch.tensor([0.229, 0.224, 0.225]).view(3,1,1)\n\nclass NpyDatasetCHW(Dataset):\n    def __init__(self, images_path, labels_path=None, to_rgb=True, normalize=True):\n        self.images_path = str(images_path)\n        self.labels_path = labels_path\n        self.to_rgb = to_rgb\n        self.normalize = normalize        \n        # Memmap: baixo uso de RAM\n        self._arr = np.load(self.images_path, mmap_mode='r')  # (N, C, H, W), uint8\n        assert self._arr.ndim == 4, f\"Esperado (N,C,H,W); obtido {self._arr.shape}\"\n        self.n, self.c, self.h, self.w = self._arr.shape\n        assert self.c in (1, 3), f\"C deve ser 1 ou 3; obtido {self.c}\"\n        self._labels = None\n        if self.labels_path is not None:\n            y = np.load(self.labels_path)\n            if y.dtype != np.float32:\n                y = y.astype(np.float32, copy=False)\n            assert y.shape[0] == self.n, \"#imgs e #labels diferentes\"\n            self._labels = y\n            \n    def __len__(self):\n        return self.n\n        \n    def __getitem__(self, idx):\n        img = self._arr[idx]                  # (C,H,W), uint8\n        if not img.flags.writeable:\n            img = img.copy()                  # pequena, só 1 imagem\n        if img.shape[0] == 1 and self.to_rgb:\n            img = np.repeat(img, 3, axis=0)   # (1,H,W) -> (3,H,W)\n        x = torch.from_numpy(img).float()     # (C,H,W)\n        if self._arr.dtype == np.uint8:\n            x = x / 255.0\n        if self.normalize:\n            if x.shape[0] == 3:\n                x = (x - IMAGENET_MEAN) / IMAGENET_STD\n            else:\n                x = (x - 0.5) / 0.5\n        if self._labels is None:\n            return x\n        else:\n            y = torch.from_numpy(self._labels[idx]).float()\n            return x, y\n\nclass NpyDatasetWithGroups(NpyDatasetCHW):\n    def __init__(self, images_path: str, labels_path: Optional[str], groups: np.ndarray,to_rgb: bool=True, normalize: bool=True):\n        super().__init__(images_path, labels_path, to_rgb, normalize)\n        assert len(groups) == self.n\n        self.groups_arr = groups.astype(np.int64)\n    def __getitem__(self, idx: int):\n        x = super().__getitem__(idx)\n        if isinstance(x, tuple):\n            img, y = x; g = torch.tensor(self.groups_arr[idx], dtype=torch.int64); return (img, y, g)\n        else:\n            img = x; g = torch.tensor(self.groups_arr[idx], dtype=torch.int64); return (img, g)\n\n# =========================\n# Split patient-wise\n# =========================\ndef load_patient_groups_for_npy(index_csv: str, train1_csv: str):\n    if not Path(index_csv).exists():\n        raise FileNotFoundError(f\"index_train.csv não encontrado: {index_csv}\")\n    idx_df = pd.read_csv(index_csv)\n    name_col = None\n    for cand in [\"Image_name\",\"Image_Name\",\"image_name\",\"path\"]:\n        if cand in idx_df.columns: name_col=cand; break\n    if name_col is None:\n        raise ValueError(\"index_train.csv precisa conter 'Image_name' ou 'path'.\")\n    if name_col == \"path\":\n        idx_df[\"Image_name\"] = idx_df[\"path\"].map(lambda p: Path(str(p)).name)\n    else:\n        idx_df[\"Image_name\"] = idx_df[name_col].map(lambda p: Path(str(p)).name)\n    tr1 = pd.read_csv(train1_csv).rename(columns={\"Image_Name\":\"Image_name\",\"PatientId\":\"Patient_ID\"})\n    if \"Patient_ID\" not in tr1.columns or \"Image_name\" not in tr1.columns:\n        raise ValueError(\"train1.csv precisa ter 'Patient_ID' e 'Image_name'.\")\n    merged = idx_df.merge(tr1[[\"Image_name\",\"Patient_ID\"]], on=\"Image_name\", how=\"left\")\n    patients = merged[\"Patient_ID\"].astype(str).values\n    uniq = pd.unique(patients); pid2int = {p:i for i,p in enumerate(uniq)}\n    groups = np.array([pid2int[p] for p in patients], dtype=np.int64)\n    meta = {\"n_samples\": int(len(merged)), \"n_patients\": int(len(uniq))}\n    return groups, meta\n\ndef build_patient_stratification(labels_all: np.ndarray, groups: np.ndarray) -> np.ndarray:\n    idx_nf = LABEL_COLUMNS.index('No Finding')\n    df = pd.DataFrame({\"group\": groups, \"NF\": labels_all[:, idx_nf].astype(int)})\n    return df.groupby(\"group\")[\"NF\"].max().reindex(df[\"group\"]).to_numpy().astype(int)\n\ndef split_patientwise(groups: np.ndarray, labels_all: np.ndarray, n_splits: int, seed: int):\n    pat_target = build_patient_stratification(labels_all, groups)\n    try:\n        from sklearn.model_selection import StratifiedGroupKFold\n        sgkf = StratifiedGroupKFold(n_splits=n_splits, shuffle=True, random_state=seed)\n        for tr_idx, va_idx in sgkf.split(np.arange(len(groups)), y=pat_target, groups=groups):\n            yield tr_idx, va_idx\n        return\n    except Exception:\n        pass\n    # fallback\n    rng = np.random.RandomState(seed)\n    df_p = pd.DataFrame({\"group\": groups, \"t\": pat_target}).drop_duplicates(\"group\")\n    pos_p = df_p[df_p[\"t\"]==1][\"group\"].values; rng.shuffle(pos_p)\n    neg_p = df_p[df_p[\"t\"]==0][\"group\"].values; rng.shuffle(neg_p)\n    pos_folds = np.array_split(pos_p, n_splits); neg_folds = np.array_split(neg_p, n_splits)\n    for i in range(n_splits):\n        va_groups = set(np.concatenate([pos_folds[i], neg_folds[i]]).tolist())\n        va_mask = np.array([g in va_groups for g in groups])\n        va_idx = np.where(va_mask)[0]; tr_idx = np.where(~va_mask)[0]\n        yield tr_idx, va_idx\n\n# =========================\n# Modelo + Opt\n# =========================\ndef create_model(num_classes=14, in_chans=3):\n    try:\n        model = timm.create_model(\"convnext_tiny\", pretrained=True,num_classes=num_classes, in_chans=in_chans,drop_rate=DROP_RATE, drop_path_rate=DROP_PATH_RATE)\n    except Exception:\n        model = timm.create_model(\"convnext_tiny\", pretrained=False,num_classes=num_classes, in_chans=in_chans,drop_rate=DROP_RATE, drop_path_rate=DROP_PATH_RATE)\n    for p in model.parameters(): p.requires_grad = True\n    return model\n\ndef _add_param_group(params, module, lr, wd):\n    if module is None: return\n    decay, no_decay = [], []\n    for n, p in module.named_parameters():\n        if not p.requires_grad: continue\n        if p.ndim < 2 or any(k in n.lower() for k in [\"bias\",\"norm\",\"bn\",\"ln\",\"gamma\",\"beta\"]):\n            no_decay.append(p)\n        else:\n            decay.append(p)\n    if decay:    params.append({\"params\": decay, \"lr\": lr, \"weight_decay\": wd})\n    if no_decay: params.append({\"params\": no_decay, \"lr\": lr, \"weight_decay\": 0.0})\n\ndef build_optimizer_llrd(model: nn.Module):\n    assert hasattr(model, \"stem\") and hasattr(model, \"stages\") and hasattr(model, \"head\")\n    param_groups = []\n    _add_param_group(param_groups, model.head, lr=BASE_LR_HEAD, wd=WD_HEAD)\n    stages = list(model.stages)\n    stage_lrs = [BASE_LR_BACKB * (LR_DECAY_STAGE ** 3),BASE_LR_BACKB * (LR_DECAY_STAGE ** 2),BASE_LR_BACKB * (LR_DECAY_STAGE ** 1),BASE_LR_BACKB * (LR_DECAY_STAGE ** 0),]\n    for stg, lr in zip(stages, stage_lrs):\n        _add_param_group(param_groups, stg, lr=lr, wd=WD_BACKBONE)\n    stem_lr = BASE_LR_BACKB * (LR_DECAY_STAGE ** 4)\n    _add_param_group(param_groups, model.stem, lr=stem_lr, wd=WD_BACKBONE)\n    return torch.optim.AdamW(param_groups)\n\ndef build_warmup_cosine(optimizer, num_warmup_steps, num_training_steps):\n    def lr_lambda(current_step):\n        if current_step < num_warmup_steps:\n            return float(current_step) / float(max(1, num_warmup_steps))\n        progress = float(current_step - num_warmup_steps) / float(max(1, num_training_steps - num_warmup_steps))\n        return 0.5 * (1.0 + math.cos(math.pi * progress))\n    return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\n\n# =========================\n# Losses\n# =========================\nclass FocalLoss(nn.Module):\n    def __init__(self, alpha=None, gamma: float = 2.0, reduction: str = \"mean\"):\n        super().__init__()\n        self.gamma = float(gamma); self.reduction = reduction\n        if isinstance(alpha, (list,tuple,np.ndarray)):\n            alpha = torch.tensor(alpha, dtype=torch.float32)\n        self.register_buffer(\"alpha\", alpha if isinstance(alpha, torch.Tensor) else None)\n        self.bce = nn.BCEWithLogitsLoss(reduction=\"none\")\n    def forward(self, logits, targets):\n        bce = self.bce(logits, targets)\n        p = torch.sigmoid(logits)\n        pt = targets*p + (1.0-targets)*(1.0-p)\n        loss = (1.0-pt).pow(self.gamma) * bce\n        if self.alpha is not None:\n            ap, an = self.alpha, 1.0-self.alpha if self.alpha.ndim==0 else (self.alpha, 1.0-self.alpha)\n            a = targets*ap + (1.0-targets)*an\n            loss = a*loss\n        return loss.mean() if self.reduction==\"mean\" else loss.sum()\n\ndef compute_alpha_from_labels(labels: np.ndarray, mode: str, beta: float,\n                              clip_min: float, clip_max: float) -> np.ndarray:\n    pos_counts = labels.sum(axis=0).astype(np.float64); n = float(labels.shape[0])\n    if mode == \"pos_prior\":\n        p = np.clip(pos_counts/max(n,1.0), 0.0, 1.0); alpha = 1.0 - p\n    elif mode == \"effective_num\":\n        w = np.zeros_like(pos_counts, dtype=np.float64); has_pos = pos_counts>0\n        w[has_pos] = (1.0-beta)/(1.0-np.power(beta, pos_counts[has_pos]))\n        alpha = w/(w.max() if w.max()>0 else 1.0)\n    else:\n        raise ValueError(\"AUTO_ALPHA_MODE inválido\")\n    return np.clip(alpha, clip_min, clip_max).astype(np.float32)\n\ndef build_criterion(device, labels_for_alpha: Optional[np.ndarray], n_classes: int):\n    if str(LOSS_MODE).lower() == \"bce\":\n        return nn.BCEWithLogitsLoss()\n    alpha_tensor = None\n    if FOCAL_ALPHA is not None:\n        if isinstance(FOCAL_ALPHA, (list,tuple,np.ndarray)):\n            arr = np.asarray(FOCAL_ALPHA, dtype=np.float32); assert arr.shape[0]==n_classes\n            alpha_tensor = torch.tensor(arr, dtype=torch.float32, device=device)\n        else:\n            alpha_tensor = torch.tensor(float(FOCAL_ALPHA), dtype=torch.float32, device=device)\n    elif AUTO_ALPHA_MODE is not None and labels_for_alpha is not None:\n        alpha_vec = compute_alpha_from_labels(labels_for_alpha, AUTO_ALPHA_MODE, CB_BETA,\n                                              ALPHA_CLIP_MIN, ALPHA_CLIP_MAX)\n        print(f\"[AUTO α] modo={AUTO_ALPHA_MODE} | alpha(min,max,mean)=({alpha_vec.min():.3f}, {alpha_vec.max():.3f}, {alpha_vec.mean():.3f})\")\n        alpha_tensor = torch.tensor(alpha_vec, dtype=torch.float32, device=device)\n    return FocalLoss(alpha=alpha_tensor, gamma=float(FOCAL_GAMMA), reduction=\"mean\")\n\n# =========================\n# EMA helper\n# =========================\nclass ModelEMA:\n    def __init__(self, model: nn.Module, decay: float = 0.999):\n        self.ema = deepcopy(model).eval()\n        for p in self.ema.parameters(): p.requires_grad=False\n        self.decay = decay\n    @torch.no_grad()\n    def update(self, model: nn.Module):\n        d = self.decay; msd = model.state_dict(); esd = self.ema.state_dict()\n        for k in esd.keys(): esd[k].mul_(d).add_(msd[k], alpha=1.0-d)\n            \n# =========================\n# Validação / Treino\n# =========================\ndef validate(model, loader, criterion, device, return_preds=False):\n    model.eval(); loss_sum=0.0; preds=[]; labels_all=[]; groups=[]\n    def _unpack(batch):\n        if isinstance(batch, (tuple,list)):\n            if len(batch)==3: return batch[0], batch[1], batch[2]\n            if len(batch)==2: x,y = batch; return x,y,None\n        raise ValueError(\"Batch em formato inesperado\")\n    pbar = make_pbar(loader, \"Val\")\n    for ib, batch in enumerate(pbar):\n        x,y,g = _unpack(batch)\n        x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True)\n        with torch.amp.autocast('cuda', enabled=(USE_AMP and device.type=='cuda')):\n            logits = model(x); loss = criterion(logits, y); ps = torch.sigmoid(logits)\n        loss_sum += loss.item()*x.size(0)\n        preds.append(ps.detach().cpu().numpy()); labels_all.append(y.detach().cpu().numpy())\n        if g is not None: groups.extend(g.detach().cpu().numpy().tolist())\n        if DRY_RUN and (ib+1)>=DRY_RUN_N_BATCHES_VAL: break\n    epoch_loss = loss_sum/len(loader.dataset)\n    preds = np.vstack(preds); labels_all = np.vstack(labels_all)\n    auc_raw_img = safe_macro_auc(labels_all, preds)\n    auc_gated_img = auc_raw_img\n    if APPLY_NF_EXCL_IN_VAL:\n        preds_g = apply_nf_excl_strict_with_tnf(preds, LABEL_COLUMNS, T_NF_VAL)\n        auc_gated_img = safe_macro_auc(labels_all, preds_g)\n    if len(groups)>0:\n        groups = np.asarray(groups)\n        df_img = pd.DataFrame({\"group\": groups})\n        for j,c in enumerate(LABEL_COLUMNS):\n            df_img[c+\"_p\"]=preds[:,j]; df_img[c+\"_y\"]=labels_all[:,j]\n        grp = df_img.groupby(\"group\", sort=False)\n        Yp = grp[[f\"{c}_p\" for c in LABEL_COLUMNS]].mean().to_numpy()\n        Yt = grp[[f\"{c}_y\" for c in LABEL_COLUMNS]].max().to_numpy()\n        auc_raw_pat = safe_macro_auc(Yt, Yp); auc_gated_pat = auc_raw_pat\n        if APPLY_NF_EXCL_IN_VAL:\n            Yp_g = apply_nf_excl_strict_with_tnf(Yp, LABEL_COLUMNS, T_NF_VAL)\n            auc_gated_pat = safe_macro_auc(Yt, Yp_g)\n    else:\n        groups = np.array([], dtype=np.int64)\n        auc_raw_pat = auc_raw_img; auc_gated_pat = auc_gated_img\n    if return_preds:\n        return (epoch_loss, auc_raw_img, auc_gated_img, auc_raw_pat, auc_gated_pat,preds, labels_all, groups)\n    return epoch_loss, auc_raw_img, auc_gated_img, auc_raw_pat, auc_gated_pat\n\ndef weak_aug_batch(x: torch.Tensor) -> torch.Tensor:\n    if not AUG_WEAK: return x\n    B,C,H,W = x.shape; out=x.clone()\n    for i in range(B):\n        angle = float(torch.empty(1).uniform_(-AUG_DEG, AUG_DEG))\n        tx  = int(torch.empty(1).uniform_(-AUG_TRANSLATE_FR*W, AUG_TRANSLATE_FR*W))\n        ty  = int(torch.empty(1).uniform_(-AUG_TRANSLATE_FR*H, AUG_TRANSLATE_FR*H))\n        scale = float(1.0 + torch.empty(1).uniform_(-AUG_SCALE_FR, AUG_SCALE_FR))\n        out[i] = TF.affine(out[i], angle=angle, translate=[tx,ty], scale=scale, shear=[0.0,0.0],interpolation=InterpolationMode.BILINEAR)\n        if AUG_HFLIP_PROB>0 and torch.rand(1).item()<AUG_HFLIP_PROB: out[i]=TF.hflip(out[i])\n    return out\n\ndef train_one_epoch(model, loader, optimizer, criterion, device,lambda_excl: float, scaler: torch.amp.GradScaler,scheduler: torch.optim.lr_scheduler._LRScheduler | None = None,step_scheduler_per_batch: bool = True,ema_helper: ModelEMA | None = None):\n    model.train(); loss_sum=0.0; preds=[]; labels_all=[]; groups=[]\n    idx_nf = LABEL_COLUMNS.index(\"No Finding\")\n    reg_meter=0.0; n_seen=0\n    pbar = make_pbar(loader, \"Train\"); optimizer.zero_grad(set_to_none=True)\n    for ib, batch in enumerate(pbar):\n        if isinstance(batch,(tuple,list)):\n            if len(batch)==3: x,y,g=batch\n            elif len(batch)==2: x,y=batch; g=None\n            else: raise ValueError(\"Batch com estrutura inesperada\")\n        else: raise ValueError(\"Batch não é tupla/lista\")\n        x=x.to(device, non_blocking=True); y=y.to(device, non_blocking=True)\n        x=weak_aug_batch(x)\n        with torch.amp.autocast('cuda', enabled=(USE_AMP and device.type=='cuda')):\n            logits=model(x); base_loss=criterion(logits,y); p=torch.sigmoid(logits)\n            reg = exclusivity_regularizer(p,y,idx_nf,cond_on_ynf=True); loss=base_loss + lambda_excl*reg\n        loss = loss / max(1, GRAD_ACCUM_STEPS)\n        scaler.scale(loss).backward()\n        if CLIP_NORM and CLIP_NORM>0:\n            scaler.unscale_(optimizer); torch.nn.utils.clip_grad_norm_(model.parameters(), CLIP_NORM)\n        if ((ib+1) % max(1, GRAD_ACCUM_STEPS))==0:\n            scale_before = scaler.get_scale(); scaler.step(optimizer); scaler.update()\n            did_step = (scaler.get_scale() >= scale_before)\n            if USE_EMA and ema_helper is not None and did_step: ema_helper.update(model)\n            if step_scheduler_per_batch and scheduler is not None and did_step: scheduler.step()\n            optimizer.zero_grad(set_to_none=True)\n        bs=x.size(0); loss_sum += loss.item()*bs*max(1, GRAD_ACCUM_STEPS)\n        reg_meter += reg.item()*bs; n_seen+=bs\n        preds.append(p.detach().cpu().numpy()); labels_all.append(y.detach().cpu().numpy())\n        if g is not None: groups.extend(g.numpy().tolist())\n        try: pbar.set_postfix(loss=float(loss.item()*max(1, GRAD_ACCUM_STEPS)), reg=float((lambda_excl*reg).item()))\n        except Exception: pass\n        if DRY_RUN and (ib+1)>=DRY_RUN_N_BATCHES_TRAIN: break\n    epoch_loss = loss_sum/len(loader.dataset); mean_reg = reg_meter/max(n_seen,1)\n    preds=np.vstack(preds); labels_all=np.vstack(labels_all); groups=np.asarray(groups) if len(groups)>0 else np.array([],dtype=np.int64)\n    auc_raw_img = safe_macro_auc(labels_all, preds); auc_gated_img = auc_raw_img\n    if APPLY_NF_EXCL_IN_VAL:\n        preds_g = apply_nf_excl_strict_with_tnf(preds, LABEL_COLUMNS, T_NF_VAL)\n        auc_gated_img = safe_macro_auc(labels_all, preds_g)\n    if groups.size>0:\n        df_img = pd.DataFrame({\"group\": groups})\n        for j,c in enumerate(LABEL_COLUMNS):\n            df_img[c+\"_p\"]=preds[:,j]; df_img[c+\"_y\"]=labels_all[:,j]\n        grp=df_img.groupby(\"group\",sort=False)\n        Yp=grp[[f\"{c}_p\" for c in LABEL_COLUMNS]].mean().to_numpy()\n        Yt=grp[[f\"{c}_y\" for c in LABEL_COLUMNS]].max().to_numpy()\n        auc_raw_pat=safe_macro_auc(Yt,Yp); auc_gated_pat=auc_raw_pat\n        if APPLY_NF_EXCL_IN_VAL:\n            Yp_g=apply_nf_excl_strict_with_tnf(Yp,LABEL_COLUMNS,T_NF_VAL); auc_gated_pat=safe_macro_auc(Yt,Yp_g)\n    else:\n        auc_raw_pat=auc_raw_img; auc_gated_pat=auc_gated_img\n    return (epoch_loss, auc_raw_img, auc_gated_img, auc_raw_pat, auc_gated_pat, mean_reg)\n\n# =========================\n# Threshold search (F1)\n# =========================\ndef compute_best_thresholds(y_true: np.ndarray, y_prob: np.ndarray,label_columns: List[str], n_steps:int=101) -> Dict:\n    C = y_true.shape[1]; ths = np.zeros(C, dtype=np.float32); grid = np.linspace(0,1,n_steps)\n    for c in range(C):\n        best_f1, best_t = -1.0, 0.5\n        yt = y_true[:,c].astype(np.int32); yp=y_prob[:,c]\n        if len(np.unique(yt))<2: ths[c]=0.5; continue\n        for t in grid:\n            f1 = f1_score(yt, (yp>=t).astype(np.uint8), zero_division=0)\n            if f1>best_f1: best_f1, best_t = f1, float(t)\n        ths[c]=best_t\n    idx_nf = label_columns.index(\"No Finding\")\n    return {\"label_order\": label_columns, \"idx_nf\": idx_nf,\"thresholds\": ths.tolist(), \"meta\": {\"n_val\": int(y_true.shape[0])}}\n\n# =========================\n# Treino por fold\n# =========================\ndef train_fold(fold_id: int, train_idx: np.ndarray, val_idx: np.ndarray,labels_all: np.ndarray, groups_all: np.ndarray, device: torch.device):\n    print(f\"\\n========== FOLD {fold_id+1}/{K_FOLDS} ==========\")\n    ds_full = NpyDatasetWithGroups(IMG_TRAIN_NPY, LBL_TRAIN_NPY, groups=groups_all, to_rgb=True, normalize=True)    \n    pin_mem = (device.type=='cuda')\n    ds_tr = NpyDatasetCHW(IMG_TRAIN_NPY, labels_path=LBL_TRAIN_NPY, to_rgb=True, normalize=True)\n    dl_tr = DataLoader(ds_tr, batch_size=BATCH_SIZE, shuffle=True, num_workers=NUM_WORKERS,pin_memory=(device.type=='cuda'))\n    ds_va = NpyDatasetCHW(IMG_TRAIN_NPY, labels_path=LBL_TRAIN_NPY, to_rgb=True, normalize=True)\n    dl_va = DataLoader(ds_va, batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS, pin_memory=pin_mem)\n    criterion = build_criterion(device, labels_all[train_idx], n_classes=len(LABEL_COLUMNS))\n    model = create_model(num_classes=len(LABEL_COLUMNS), in_chans=3).to(device)\n    optimizer = build_optimizer_llrd(model)\n    total_steps = len(dl_tr) * (1 if DRY_RUN else EPOCHS)\n    scheduler = build_warmup_cosine(optimizer, num_warmup_steps=WARMUP_STEPS, num_training_steps=total_steps)\n    scaler = torch.amp.GradScaler('cuda', enabled=(USE_AMP and device.type=='cuda'))\n    ema_helper = ModelEMA(model, EMA_DECAY) if USE_EMA else None\n    best_auc=-1.0; no_improve=0\n    best_path = RUNS / f\"best_model_fold{fold_id}.pth\"    \n    history=[]; local_epochs = 1 if DRY_RUN else EPOCHS\n    for epoch in range(1, local_epochs+1):\n        lam_excl = lambda_excl_at_epoch(epoch)\n        print(f\"\\n--- Fold {fold_id+1} | Epoch {epoch}/{local_epochs} ---\")\n        print(f\"λ_excl={lam_excl:.3f} | DROP_RATE={DROP_RATE} | WD(head/back)={WD_HEAD}/{WD_BACKBONE} | \"f\"LRs(head={BASE_LR_HEAD:.1e}, back={BASE_LR_BACKB:.1e}, decay={LR_DECAY_STAGE}) | \"f\"EMA={USE_EMA} | AUG_WEAK={AUG_WEAK} | ACCUM={GRAD_ACCUM_STEPS}\")\n        tr_loss, tr_auc_raw_img, tr_auc_gated_img, tr_auc_raw_pat, tr_auc_gated_pat, tr_reg = \\\n            train_one_epoch(model, dl_tr, optimizer, criterion, device,lambda_excl=lam_excl, scaler=scaler, scheduler=scheduler,step_scheduler_per_batch=True, ema_helper=ema_helper)\n        va = validate(ema_helper.ema if (USE_EMA and ema_helper is not None) else model, dl_va, criterion, device)\n        va_loss, va_auc_raw_img, va_auc_gated_img, va_auc_raw_pat, va_auc_gated_pat = va\n        print(f\"[Fold {fold_id}] Train | loss={tr_loss:.4f} | AUROC(img raw)={tr_auc_raw_img:.4f} | AUROC(pat raw)={tr_auc_raw_pat:.4f} | reg={tr_reg:.4f}\")\n        print(f\"[Fold {fold_id}] Val   | loss={va_loss:.4f} | AUROC(img raw)={va_auc_raw_img:.4f} | AUROC(pat raw)={va_auc_raw_pat:.4f}\")\n        history.append({\"epoch\": epoch,\"train_loss\": tr_loss,\"train_auc_raw_img\": tr_auc_raw_img, \"train_auc_raw_pat\": tr_auc_raw_pat,\"train_reg\": tr_reg,\"val_loss\": va_loss, \"val_auc_raw_img\": va_auc_raw_img, \"val_auc_raw_pat\": va_auc_raw_pat})\n        score_for_es = va_auc_raw_img if not np.isnan(va_auc_raw_img) else va_auc_gated_img\n        if score_for_es > best_auc + EARLYSTOP_MIN_DELTA:\n            best_auc = score_for_es; no_improve=0\n            to_save = (ema_helper.ema if (USE_EMA and ema_helper is not None) else model)\n            torch.save(to_save.state_dict(), best_path)\n            print(f\"[Fold {fold_id}] BEST AUROC(img raw)={best_auc:.4f} salvo em {best_path}\")\n        else:\n            no_improve += 1\n            print(f\"[Fold {fold_id}] Sem melhora ({no_improve}/{EARLYSTOP_PATIENCE}).\")\n        if not DRY_RUN and no_improve >= EARLYSTOP_PATIENCE:\n            print(f\"[Fold {fold_id}] Early stopping.\"); break\n\n    # carrega best e gera OOF do fold\n    state = torch.load(best_path, map_location=device)\n    model.load_state_dict(state)\n    if USE_EMA and ema_helper is not None: ema_helper.ema.load_state_dict(state)\n    (va_loss, va_auc_raw_img, va_auc_gated_img, va_auc_raw_pat, va_auc_gated_pat,va_probs, va_labels, va_groups) = validate(ema_helper.ema if (USE_EMA and ema_helper is not None) else model,dl_va, criterion, device, return_preds=True)\n\n    # thresholds por imagem (F1)\n    thr_info = compute_best_thresholds(va_labels, va_probs, LABEL_COLUMNS, n_steps=201)\n    thr_path = RUNS / f\"thresholds_fold{fold_id}.json\"\n    with open(thr_path, \"w\", encoding=\"utf-8\") as f:\n        json.dump(thr_info, f, indent=2)\n    print(f\"[Fold {fold_id}] Thresholds salvos: {thr_path}\")\n    return best_path\n\n# =========================\n# K-FOLD\n# =========================\ndef kfold_train_and_ensemble():\n    set_seed(SEED)\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    print(\"Device:\", device)\n    if device.type=='cuda': torch.backends.cudnn.benchmark=True\n    labels_all = np.load(LBL_TRAIN_NPY)\n    groups, meta = load_patient_groups_for_npy(INDEX_TRAIN_CSV, TRAIN1_CSV)\n    assert len(groups) == labels_all.shape[0]\n    with open(PATIENT_MAP_JSON, \"w\", encoding=\"utf-8\") as f:\n        json.dump(meta, f, indent=2)\n    print(f\"[Groups] N={meta['n_samples']} | pacientes únicos={meta['n_patients']} | mapa salvo em {PATIENT_MAP_JSON}\")\n    fold_paths = []\n    for fold_id, (tr_idx, va_idx) in enumerate(split_patientwise(groups, labels_all, K_FOLDS, SEED)):\n        best_path = train_fold(fold_id, tr_idx, va_idx, labels_all, groups, device)\n        fold_paths.append(best_path)\n        if DRY_RUN:\n            print(\"[DRY RUN] Encerrando após 1 fold.\"); break\n\n    # ===== Inferência ENSEMBLE por imagem (padrão) =====\n    test_names = pd.read_csv(TEST_CSV_PATH)['Image_name'].values  # ou TEST_CSV_PATH['Image_name']\n    ds_te = NpyDatasetCHW(IMG_TEST_NPY, labels_path=None, to_rgb=True, normalize=True)\n    # Subamostragem segura (mantém alinhamento dataset <-> test_names)\n    if DRY_RUN:\n        from torch.utils.data import Subset    \n        n_total = len(ds_te)\n        n_use = min(DRY_RUN_N, n_total)\n        if DRY_RUN_RANDOM:\n            rng = np.random.default_rng(SEED)\n            idx = np.sort(rng.choice(n_total, size=n_use, replace=False))\n        else:\n            idx = np.arange(n_use, dtype=int)    \n        ds_te = Subset(ds_te, idx)\n        test_names = test_names[idx]\n    dl_te = DataLoader(ds_te, batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS,pin_memory=(device.type=='cuda'))\n\n    logits_all_folds = []\n    for fold_id, path in enumerate(fold_paths):\n        print(f\"[Ensemble] Fold {fold_id}: inferindo com {path}\")\n        model = create_model(num_classes=len(LABEL_COLUMNS), in_chans=3).to(device)\n        state = torch.load(path, map_location=device); model.load_state_dict(state); model.eval()    \n        fold_logits = []\n        with torch.inference_mode(), torch.amp.autocast('cuda', enabled=(USE_AMP and device.type=='cuda')):\n            for x in make_pbar(dl_te, f\"Test fold {fold_id}\"):\n                if isinstance(x, (tuple, list)): x = x[0]\n                x = x.to(device, non_blocking=True)\n                logits = model(x)  # (B, C)\n                fold_logits.append(logits.detach().cpu().float().numpy())\n        logits_all_folds.append(np.vstack(fold_logits))  # (N, C) por fold    \n    logits_all_folds = np.stack(logits_all_folds, axis=0)   # (K, N, C)\n    logits_mean = logits_all_folds.mean(axis=0)             # (N, C)\n    \n    # Probabilidades = sigmoid(média dos logits)\n    probs_mean = torch.sigmoid(torch.from_numpy(logits_mean)).numpy()  # (N, C)    \n    assert probs_mean.shape[0] == len(test_names), \\\n        f\"N de imagens divergente: probs={probs_mean.shape[0]} vs names={len(test_names)}\"    \n    sub_probs = pd.DataFrame({\"Image_name\": test_names})\n    for i, col in enumerate(LABEL_COLUMNS):\n        sub_probs[col] = probs_mean[:, i]    \n    probs_path = Path(\"/kaggle/working/submission.csv\")\n    sub_probs.to_csv(probs_path, index=False)\n    print(\"Submissão (sigmoid da MÉDIA dos LOGITS):\", probs_path)\n\nif __name__ == \"__main__\":\n    kfold_train_and_ensemble()\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"_kg_hide-output":true,"_kg_hide-input":false,"execution":{"iopub.status.busy":"2025-09-22T02:29:00.338926Z","iopub.execute_input":"2025-09-22T02:29:00.339169Z","iopub.status.idle":"2025-09-22T02:30:09.769472Z","shell.execute_reply.started":"2025-09-22T02:29:00.339151Z","shell.execute_reply":"2025-09-22T02:30:09.768633Z"}},"outputs":[],"execution_count":null}]}