{"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":21669,"databundleVersionId":1692278,"sourceType":"competition"}],"dockerImageVersionId":31240,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# RFCx Species Audio Detection — ResNet50 на mel-спектрограммах\n\n1. Читаем разметку `train_tp.csv` и подбираем диапазон частот.  \n2. Достаём 10-секундные фрагменты вокруг события, строим mel-спектрограммы и конвертируем их в “картинки”.  \n3. Обучаем `ResNet50` с кросс-валидацией (KFold).  \n4. Делаем инференс на тесте: по каждому файлу берём max по сегментам, усредняем по фолдам и сохраняем `submission.csv`.\n\n","metadata":{}},{"cell_type":"code","source":"# =========================\n# 1) Импорты и окружение\n# =========================\n\nimport os\nimport csv\nimport copy\nimport random\nimport warnings\nfrom dataclasses import dataclass\nfrom concurrent.futures import ThreadPoolExecutor\n\nimport numpy as np\nimport pandas as pd\nimport librosa\nimport matplotlib.pyplot as plt\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\n\nfrom sklearn.model_selection import KFold\nfrom tqdm.auto import tqdm\n\nfrom torchvision.models import resnet50\ntry:\n    # torchvision>=0.13\n    from torchvision.models import ResNet50_Weights\n    _HAS_NEW_TORCHVISION = True\nexcept Exception:\n    _HAS_NEW_TORCHVISION = False\n\nfrom skimage.transform import resize\nfrom skimage import exposure, util\n\nwarnings.filterwarnings(\"ignore\")\n\nSEED = 563\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\n\ndevice = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"✅ Device: {device}\")\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:09:13.314315Z","iopub.execute_input":"2025-12-27T02:09:13.314513Z","iopub.status.idle":"2025-12-27T02:09:23.357948Z","shell.execute_reply.started":"2025-12-27T02:09:13.314495Z","shell.execute_reply":"2025-12-27T02:09:23.356947Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =========================\n# 2) Конфигурация\n# =========================\n\n@dataclass(frozen=True)\nclass CFG:\n    labels: int = 24\n    sr: int = 48_000\n    clip_seconds: int = 10\n    length: int = sr * clip_seconds\n\n    # Частоты вычислим из train_tp.csv\n    fmin_pad: float = 0.90\n    fmax_pad: float = 1.10\n\n    img_h: int = 224\n    img_w: int = 400\n\n    batch_size: int = 8\n    epochs: int = 20\n    n_folds: int = 5\n    lr: float = 2e-4\n\n    num_workers: int = 2\n    cache_specs: bool = True  # кэшировать спектры в RAM (ускоряет обучение)\n\nCFG = CFG()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:09:23.360009Z","iopub.execute_input":"2025-12-27T02:09:23.360567Z","iopub.status.idle":"2025-12-27T02:09:23.367171Z","shell.execute_reply.started":"2025-12-27T02:09:23.360542Z","shell.execute_reply":"2025-12-27T02:09:23.366388Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3) Аугментации “картинки” спектрограммы\n\nИспользуем простые преобразования: шум, контраст, отражения по горизонтали/вертикали.\n","metadata":{}},{"cell_type":"code","source":"class AudioAugmentations:\n    \"\"\"Набор простых аугментаций для mel-спектрограмм.\"\"\"\n\n    def __init__(self, p: float = 0.5):\n        self.p = p\n        self.augs = [self.add_noise, self.contrast_stretch, self.h_flip, self.v_flip]\n\n    def h_flip(self, image_2d: np.ndarray) -> np.ndarray:\n        return image_2d[:, ::-1]\n\n    def v_flip(self, image_2d: np.ndarray) -> np.ndarray:\n        return image_2d[::-1, :]\n\n    def add_noise(self, image_2d: np.ndarray) -> np.ndarray:\n        return util.random_noise(image_2d)\n\n    def contrast_stretch(self, image_2d: np.ndarray) -> np.ndarray:\n        return exposure.rescale_intensity(image_2d)\n\n    def __call__(self, image_2d: np.ndarray) -> np.ndarray:\n        if random.random() > self.p:\n            return image_2d\n        aug_func = random.choice(self.augs)\n        return aug_func(image_2d)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:09:23.367894Z","iopub.execute_input":"2025-12-27T02:09:23.368222Z","iopub.status.idle":"2025-12-27T02:09:23.379068Z","shell.execute_reply.started":"2025-12-27T02:09:23.368194Z","shell.execute_reply":"2025-12-27T02:09:23.378404Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4) Преобразования: mel-спектрограмма → изображение\n\n- приводим к размеру `(224, 400)`  \n- нормируем по mean/std  \n- масштабируем в `uint8 [0..255]`\n","metadata":{}},{"cell_type":"code","source":"def spec_to_image(spec: np.ndarray, img_h: int = CFG.img_h, img_w: int = CFG.img_w) -> np.ndarray:\n    \"\"\"Нормализация и масштабирование спектрограммы в uint8 картинку.\"\"\"\n    spec = resize(spec, (img_h, img_w), anti_aliasing=True)\n    eps = 1e-6\n\n    mean = spec.mean()\n    std = spec.std()\n    spec_norm = (spec - mean) / (std + eps)\n\n    spec_min, spec_max = spec_norm.min(), spec_norm.max()\n    spec_scaled = 255.0 * (spec_norm - spec_min) / (spec_max - spec_min + eps)\n\n    return spec_scaled.astype(np.uint8)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:09:23.379942Z","iopub.execute_input":"2025-12-27T02:09:23.380256Z","iopub.status.idle":"2025-12-27T02:09:23.392938Z","shell.execute_reply.started":"2025-12-27T02:09:23.380220Z","shell.execute_reply":"2025-12-27T02:09:23.392136Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5) Чтение train_tp.csv и подбор диапазона частот\n\nБерём глобальные min/max по `f_min`, `f_max` и слегка расширяем диапазон паддингом.\n","metadata":{}},{"cell_type":"code","source":"DATA_DIR = \"/kaggle/input/rfcx-species-audio-detection\"\nTRAIN_CSV = os.path.join(DATA_DIR, \"train_tp.csv\")\nTRAIN_AUDIO_DIR = os.path.join(DATA_DIR, \"train\")\nTEST_AUDIO_DIR = os.path.join(DATA_DIR, \"test\")\n\ntrain_tp = pd.read_csv(TRAIN_CSV)\n\nfmin_hz = int(train_tp[\"f_min\"].min() * CFG.fmin_pad)\nfmax_hz = int(train_tp[\"f_max\"].max() * CFG.fmax_pad)\n\nprint(\"✅ train_tp shape:\", train_tp.shape)\nprint(f\"✅ fmin_hz={fmin_hz}, fmax_hz={fmax_hz}\")\n\ndisplay(train_tp.head(3))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:09:23.393807Z","iopub.execute_input":"2025-12-27T02:09:23.394046Z","iopub.status.idle":"2025-12-27T02:09:23.446867Z","shell.execute_reply.started":"2025-12-27T02:09:23.394022Z","shell.execute_reply":"2025-12-27T02:09:23.446320Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6) Построение 10-сек. фрагмента вокруг события и mel-спектрограммы\n\n- грузим аудио (с ресемплингом к `CFG.sr` для стабильной длины)  \n- берём центр интервала `[t_min, t_max]`  \n- вырезаем 10 секунд (с аккуратной обработкой границ)  \n- считаем `mel-spectrogram` → `power_to_db` → `spec_to_image`\n","metadata":{}},{"cell_type":"code","source":"def load_wav(recording_id: str, audio_dir: str) -> np.ndarray:\n    path = os.path.join(audio_dir, f\"{recording_id}.flac\")\n    wav, _ = librosa.load(path, sr=CFG.sr, mono=True)\n    return wav\n\ndef slice_event(wav: np.ndarray, t_min_s: float, t_max_s: float) -> np.ndarray:\n    t_min = int(t_min_s * CFG.sr)\n    t_max = int(t_max_s * CFG.sr)\n\n    center = int(round((t_min + t_max) / 2))\n    start = max(center - CFG.length // 2, 0)\n    end = min(start + CFG.length, len(wav))\n    start = max(end - CFG.length, 0)  # гарантируем длину CFG.length\n\n    clip = wav[start:end]\n    if len(clip) < CFG.length:\n        clip = np.pad(clip, (0, CFG.length - len(clip)))\n    return clip\n\ndef make_mel_image(wav_clip: np.ndarray, fmin: int, fmax: int) -> np.ndarray:\n    mel = librosa.feature.melspectrogram(\n        y=wav_clip,\n        sr=CFG.sr,\n        fmin=fmin,\n        fmax=fmax,\n        n_mels=128,\n        hop_length=512,\n        n_fft=2048,\n        power=2.0,\n    )\n    mel_db = librosa.power_to_db(mel, top_db=80)\n    img = spec_to_image(mel_db)\n    return img\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:09:23.447676Z","iopub.execute_input":"2025-12-27T02:09:23.447943Z","iopub.status.idle":"2025-12-27T02:09:23.456201Z","shell.execute_reply.started":"2025-12-27T02:09:23.447912Z","shell.execute_reply":"2025-12-27T02:09:23.455332Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Быстрый sanity check: визуализация одной спектрограммы\n","metadata":{}},{"cell_type":"code","source":"sample_row = train_tp.iloc[0]\nrec_id = sample_row[\"recording_id\"]\n\nwav = load_wav(rec_id, TRAIN_AUDIO_DIR)\nclip = slice_event(wav, sample_row[\"t_min\"], sample_row[\"t_max\"])\nimg = make_mel_image(clip, fmin_hz, fmax_hz)\n\nplt.figure(figsize=(10, 3))\nplt.imshow(img, aspect=\"auto\", origin=\"lower\")\nplt.title(f\"Mel-spectrogram (as image) — recording_id={rec_id}\")\nplt.axis(\"off\")\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:09:23.458731Z","iopub.execute_input":"2025-12-27T02:09:23.459060Z","iopub.status.idle":"2025-12-27T02:09:37.495927Z","shell.execute_reply.started":"2025-12-27T02:09:23.459040Z","shell.execute_reply":"2025-12-27T02:09:37.495173Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7) (Опционально) Кэшируем спектрограммы train-части в RAM\n\nЕсли `CFG.cache_specs=True`, то один раз считаем картинки для **каждой строки** `train_tp.csv`  \nи кладём в словарь `spec_cache[row_idx] = img_uint8`.\n\nЭто обычно сильно ускоряет обучение, но требует памяти.\n","metadata":{}},{"cell_type":"code","source":"spec_cache = {}\n\ndef process_row(row_idx: int):\n    row = train_tp.iloc[row_idx]\n    rec_id = row[\"recording_id\"]\n    wav = load_wav(rec_id, TRAIN_AUDIO_DIR)\n    clip = slice_event(wav, row[\"t_min\"], row[\"t_max\"])\n    img = make_mel_image(clip, fmin_hz, fmax_hz)\n    return row_idx, img\n\nif CFG.cache_specs:\n    with ThreadPoolExecutor() as ex:\n        results = list(tqdm(ex.map(process_row, range(len(train_tp))),\n                            total=len(train_tp),\n                            desc=\"Caching specs\"))\n    spec_cache = {idx: img for idx, img in results}\n    print(f\"✅ Cached: {len(spec_cache)} spectrogram images\")\nelse:\n    print(\"ℹ️ Caching disabled — spectrograms will be computed on-the-fly in Dataset.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:09:37.496721Z","iopub.execute_input":"2025-12-27T02:09:37.497175Z","iopub.status.idle":"2025-12-27T02:10:43.866619Z","shell.execute_reply.started":"2025-12-27T02:09:37.497152Z","shell.execute_reply":"2025-12-27T02:10:43.865990Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8) Dataset / DataLoader\n\nНа выходе датасет отдаёт тензор `float32` размера `[3, H, W]` в диапазоне `[0..1]`  \nи целевую метку `species_id`.\n","metadata":{}},{"cell_type":"code","source":"class RFCXDataset(Dataset):\n    def __init__(self, indices, labels, data_type: str, augmenter=None):\n        self.indices = np.asarray(indices)\n        self.labels = np.asarray(labels)\n        self.data_type = data_type\n        self.augmenter = augmenter\n\n    def __len__(self):\n        return len(self.indices)\n\n    def __getitem__(self, i):\n        row_idx = int(self.indices[i])\n        y = int(self.labels[i])\n\n        if CFG.cache_specs:\n            img = spec_cache[row_idx]  # uint8 (H, W)\n        else:\n            row = train_tp.iloc[row_idx]\n            wav = load_wav(row[\"recording_id\"], TRAIN_AUDIO_DIR)\n            clip = slice_event(wav, row[\"t_min\"], row[\"t_max\"])\n            img = make_mel_image(clip, fmin_hz, fmax_hz)\n\n        if self.data_type == \"train\" and self.augmenter is not None:\n            img = self.augmenter(img)\n\n        # CHW float in [0..1]\n        img = np.stack([img, img, img], axis=0).astype(np.float32) / 255.0\n        return torch.from_numpy(img), torch.tensor(y, dtype=torch.long)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:10:43.867342Z","iopub.execute_input":"2025-12-27T02:10:43.867644Z","iopub.status.idle":"2025-12-27T02:10:43.875490Z","shell.execute_reply.started":"2025-12-27T02:10:43.867622Z","shell.execute_reply":"2025-12-27T02:10:43.874765Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9) Модель: ResNet50 (pretrained) + новый классификатор\n\nВ `torchvision` могут быть разные API для загрузки весов (зависит от версии).  \nВ ноутбуке предусмотрен безопасный fallback.\n","metadata":{}},{"cell_type":"code","source":"def get_model() -> nn.Module:\n    if _HAS_NEW_TORCHVISION:\n        model = resnet50(weights=ResNet50_Weights.DEFAULT)\n    else:\n        model = resnet50(pretrained=True)\n\n    in_features = model.fc.in_features\n    model.fc = nn.Linear(in_features, CFG.labels)\n    return model.to(device)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:10:43.876303Z","iopub.execute_input":"2025-12-27T02:10:43.876605Z","iopub.status.idle":"2025-12-27T02:10:43.886421Z","shell.execute_reply.started":"2025-12-27T02:10:43.876587Z","shell.execute_reply":"2025-12-27T02:10:43.885678Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 10) Обучение: аккуратные метрики и логи\n\n- `CrossEntropyLoss`  \n- accuracy по `argmax`  \n- scheduler: `ReduceLROnPlateau`  \n- сохраняем **лучшие веса по val accuracy** для каждого фолда\n","metadata":{}},{"cell_type":"code","source":"loss_fn = nn.CrossEntropyLoss()\naugmenter = AudioAugmentations(p=0.7)\n\n@torch.no_grad()\ndef evaluate(model, loader):\n    model.eval()\n    total_loss = 0.0\n    total_correct = 0\n    total = 0\n\n    for x, y in loader:\n        x = x.to(device, dtype=torch.float32)\n        y = y.to(device)\n\n        logits = model(x)\n        loss = loss_fn(logits, y)\n\n        total_loss += loss.item() * x.size(0)\n        total_correct += (logits.argmax(1) == y).sum().item()\n        total += x.size(0)\n\n    return total_loss / max(total, 1), total_correct / max(total, 1)\n\ndef train_one_fold(fold_id, train_idx, val_idx, save_dir=\"./models\"):\n    os.makedirs(save_dir, exist_ok=True)\n\n    X = np.arange(len(train_tp))\n    y = train_tp[\"species_id\"].values\n\n    ds_train = RFCXDataset(X[train_idx], y[train_idx], data_type=\"train\", augmenter=augmenter)\n    ds_val = RFCXDataset(X[val_idx], y[val_idx], data_type=\"valid\", augmenter=None)\n\n    dl_train = DataLoader(\n        ds_train,\n        batch_size=CFG.batch_size,\n        shuffle=True,\n        num_workers=CFG.num_workers,\n        pin_memory=True,\n        drop_last=True,\n    )\n    dl_val = DataLoader(\n        ds_val,\n        batch_size=CFG.batch_size,\n        shuffle=False,\n        num_workers=CFG.num_workers,\n        pin_memory=True,\n        drop_last=False,\n    )\n\n    model = get_model()\n    optimizer = torch.optim.Adam(model.parameters(), lr=CFG.lr)\n    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode=\"min\", patience=3)\n\n    best_acc = -1.0\n    best_wts = copy.deepcopy(model.state_dict())\n    history = []\n\n    for epoch in range(1, CFG.epochs + 1):\n        model.train()\n        running_loss = 0.0\n        running = 0\n\n        for x, yb in dl_train:\n            x = x.to(device, dtype=torch.float32)\n            yb = yb.to(device)\n\n            optimizer.zero_grad(set_to_none=True)\n            logits = model(x)\n            loss = loss_fn(logits, yb)\n            loss.backward()\n            optimizer.step()\n\n            running_loss += loss.item() * x.size(0)\n            running += x.size(0)\n\n        train_loss = running_loss / max(running, 1)\n        val_loss, val_acc = evaluate(model, dl_val)\n        scheduler.step(val_loss)\n\n        history.append((epoch, train_loss, val_loss, val_acc))\n\n        print(\n            f\"[Fold {fold_id}] Epoch {epoch:02d}/{CFG.epochs} | \"\n            f\"train_loss={train_loss:.4f} | val_loss={val_loss:.4f} | val_acc={val_acc:.4f}\"\n        )\n\n        if val_acc > best_acc:\n            best_acc = val_acc\n            best_wts = copy.deepcopy(model.state_dict())\n\n    model.load_state_dict(best_wts)\n    model_path = os.path.join(save_dir, f\"resnet50_fold{fold_id}.pt\")\n    torch.save(model.state_dict(), model_path)\n\n    return model_path, best_acc, history\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:10:43.887259Z","iopub.execute_input":"2025-12-27T02:10:43.887550Z","iopub.status.idle":"2025-12-27T02:10:43.902079Z","shell.execute_reply.started":"2025-12-27T02:10:43.887526Z","shell.execute_reply":"2025-12-27T02:10:43.901538Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 11) Кросс-валидация (KFold) и сохранение моделей\n","metadata":{}},{"cell_type":"code","source":"skf = KFold(n_splits=CFG.n_folds, shuffle=True, random_state=SEED)\n\nfold_models = []\nfold_scores = []\nfold_histories = []\n\nX_all = np.arange(len(train_tp))\ny_all = train_tp[\"species_id\"].values\n\nfor fold_id, (train_idx, val_idx) in enumerate(skf.split(X_all, y_all)):\n    print(\"=\" * 80)\n    print(f\"🚀 Start Fold {fold_id}/{CFG.n_folds-1}\")\n\n    model_path, best_acc, history = train_one_fold(\n        fold_id, train_idx, val_idx, save_dir=\"./models\"\n    )\n\n    fold_models.append(model_path)\n    fold_scores.append(best_acc)\n    fold_histories.append(history)\n\n    print(f\"✅ Fold {fold_id} best val_acc = {best_acc:.4f} | saved: {model_path}\")\n\nprint(\"=\" * 80)\nprint(\"📊 CV Summary\")\nfor i, s in enumerate(fold_scores):\n    print(f\"Fold {i}: {s:.4f}\")\nprint(f\"Mean: {np.mean(fold_scores):.4f}  |  Std: {np.std(fold_scores):.4f}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-27T02:10:43.902843Z","iopub.execute_input":"2025-12-27T02:10:43.903092Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### (Опционально) Графики loss/accuracy по последнему фолду\n","metadata":{}},{"cell_type":"code","source":"if len(fold_histories) > 0:\n    hist = fold_histories[-1]\n    epochs = [h[0] for h in hist]\n    tr_loss = [h[1] for h in hist]\n    va_loss = [h[2] for h in hist]\n    va_acc  = [h[3] for h in hist]\n\n    plt.figure(figsize=(8, 3))\n    plt.plot(epochs, tr_loss, label=\"train_loss\")\n    plt.plot(epochs, va_loss, label=\"val_loss\")\n    plt.title(\"Loss\")\n    plt.xlabel(\"epoch\")\n    plt.legend()\n    plt.show()\n\n    plt.figure(figsize=(8, 3))\n    plt.plot(epochs, va_acc, label=\"val_acc\")\n    plt.title(\"Validation accuracy\")\n    plt.xlabel(\"epoch\")\n    plt.legend()\n    plt.show()\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 12) Инференс на тесте и сохранение `submission.csv`\n\nЛогика:\n- режем файл на 10-сек. сегменты  \n- на каждом сегменте берём `softmax` (вероятности по классам)  \n- по файлу берём **max по сегментам**  \n- усредняем **по фолдам**  \n- сохраняем CSV\n","metadata":{}},{"cell_type":"code","source":"@torch.no_grad()\ndef load_test_segments(path: str) -> torch.Tensor:\n    wav, _ = librosa.load(path, sr=CFG.sr, mono=True)\n\n    segments = int(np.ceil(len(wav) / CFG.length))\n    images = []\n\n    for i in range(segments):\n        start = i * CFG.length\n        end = min((i + 1) * CFG.length, len(wav))\n\n        clip = wav[start:end]\n        if len(clip) < CFG.length:\n            clip = np.pad(clip, (0, CFG.length - len(clip)))\n\n        img = make_mel_image(clip, fmin_hz, fmax_hz)  # uint8 (H, W)\n        img = np.stack([img, img, img], axis=0).astype(np.float32) / 255.0\n        images.append(img)\n\n    return torch.from_numpy(np.stack(images, axis=0))  # [S, 3, H, W]\n\ndef load_members(model_paths):\n    members = []\n    for p in model_paths:\n        m = get_model()\n        m.load_state_dict(torch.load(p, map_location=device))\n        m.eval()\n        members.append(m)\n    return members\n\nmembers = load_members(fold_models)\nprint(f\"✅ Loaded {len(members)} models\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"@torch.no_grad()\ndef predict_one_file(test_file: str, members) -> np.ndarray:\n    path = os.path.join(TEST_AUDIO_DIR, test_file)\n    data = load_test_segments(path).to(device)\n\n    # softmax -> max по сегментам -> mean по моделям\n    per_model = []\n    for m in members:\n        logits = m(data)                       # [S, C]\n        probs = torch.softmax(logits, dim=1)   # [S, C]\n        file_probs, _ = probs.max(dim=0)       # [C]\n        per_model.append(file_probs)\n\n    avg_probs = torch.stack(per_model, dim=0).mean(dim=0)  # [C]\n    return avg_probs.detach().cpu().numpy()\n\ndef make_submission(test_files, members, out_path=\"submission.csv\"):\n    header = [\"recording_id\"] + [f\"s{i}\" for i in range(CFG.labels)]\n\n    with open(out_path, \"w\", newline=\"\") as f:\n        wr = csv.writer(f)\n        wr.writerow(header)\n\n        for tf in tqdm(test_files, desc=\"Predicting\"):\n            rec_id = os.path.splitext(tf)[0]\n            probs = predict_one_file(tf, members)\n            wr.writerow([rec_id] + probs.tolist())\n\n    print(f\"✅ Saved: {out_path}\")\n\ntest_files = sorted(os.listdir(TEST_AUDIO_DIR))\nprint(f\"✅ Test files: {len(test_files)}\")\n\nmake_submission(test_files, members, out_path=\"submission.csv\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}