{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.11","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":91844,"databundleVersionId":11361821,"sourceType":"competition"},{"sourceId":245555259,"sourceType":"kernelVersion"},{"sourceId":245558641,"sourceType":"kernelVersion"},{"sourceId":436048,"sourceType":"modelInstanceVersion","isSourceIdPinned":true,"modelInstanceId":355643,"modelId":376943}],"dockerImageVersionId":31040,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# In Previous Episodes...\n\nTurns out, it takes both *time* and a lot of notebook *space* to build this baseline. To keep things tidy, the EDA and Modelling parts were split into separate notebooks:\n\n* [BirdCLEF+ 2025 | EDA | CV Splits](https://www.kaggle.com/code/mariadodonova/birdclef-2025-eda-cv-splits)\n* [BirdCLEF+ 2025 | Modelling](https://www.kaggle.com/code/mariadodonova/birdclef-2025-modelling)\n\n# Setup\n\nImport the necessary modules and set up constants.","metadata":{}},{"cell_type":"code","source":"import os\nimport math\nimport json\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport IPython.display as ipd\nfrom scipy import signal\nfrom tqdm import tqdm\nimport timm\n\nimport librosa\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchaudio.transforms import (\n    TimeMasking,\n    FrequencyMasking,\n    MelSpectrogram,\n    AmplitudeToDB\n)\n\nMODELS_VERSION = 1\nMODELS_ROOT = f\"/kaggle/input/birdclef-2025-effb0-cv-ensemble/pytorch/default/{MODELS_VERSION}\"\n\nDATA_ROOT = \"/kaggle/input/birdclef-2025\"\nTRAIN_PATH = os.path.join(DATA_ROOT, \"train_soundscapes\")\nTEST_PATH = os.path.join(DATA_ROOT, \"test_soundscapes\")\n\nIS_SUBMISSION = len(os.listdir(TEST_PATH)) > 1\nDATA_PATH = TEST_PATH if IS_SUBMISSION else TRAIN_PATH\nSAMPLE_RATE = 32000","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:40:50.577628Z","iopub.execute_input":"2025-06-15T14:40:50.577909Z","iopub.status.idle":"2025-06-15T14:41:07.848218Z","shell.execute_reply.started":"2025-06-15T14:40:50.577890Z","shell.execute_reply":"2025-06-15T14:41:07.847248Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Stuff From Previous Notebooks\n\nHere's some code taken from the **Modelling Notebook** ([BirdCLEF+ 2025 | Modelling](https://www.kaggle.com/code/mariadodonova/birdclef-2025-modelling)) — unchanged, just reused here.\n\n### Plotting Tools\n\nFunction for displaying audio samples.","metadata":{}},{"cell_type":"code","source":"def plot_wave_spectrograms(waveforms, sample_rate, class_names, num_cols=2, specs=None):\n    \"\"\"\n    Plots the spectrograms and waveforms of given audio waveforms using a shared sample rate.\n    \n    Args:\n        waveforms (list of np.ndarray): List of audio waveforms (NumPy arrays of shape [samples, channels]).\n        sample_rate (int): Common sample rate for all waveforms.\n        class_names (list of str): List of corresponding class names.\n        num_cols (int): Number of columns in the plot layout. Default is 2.\n        specs (list of np.ndarray, optional): List of precomputed spectrograms (2D tensors, only `sxx` values).\n                                              If None, spectrograms will be computed automatically.\n    \"\"\"\n    num_files = len(waveforms)\n    num_rows = math.ceil(num_files / num_cols) * 2  # Each audio takes 2 rows (spectrogram + waveform)\n\n    fig, axs = plt.subplots(num_rows, num_cols, figsize=(num_cols * 2.6, num_rows * 2))\n\n    if num_rows == 2:\n        axs = np.reshape(axs, (num_rows, num_cols))  # Ensure correct indexing for small cases\n\n    for idx, (waveform, class_name) in enumerate(zip(waveforms, class_names)):\n\n        # Determine row and column indices\n        i, j = (idx // num_cols) * 2, idx % num_cols  # Spectrogram in row i, waveform in i+1\n        \n        # Compute spectrogram if not provided\n        if specs is None:\n            sampleFreqs, segmentTimes, sxx = signal.spectrogram(waveform, sample_rate)\n\n            # Plot spectrogram\n            axs[i][j].pcolormesh(segmentTimes, sampleFreqs, 10 * np.log10(sxx + 1e-15))\n            axs[i][j].set_title(f\"{class_name}\", fontsize=10)\n            axs[i][j].set_axis_off()\n        else:\n            # Plot spectrogram\n            axs[i][j].imshow(specs[idx])\n            axs[i][j].set_title(f\"{class_name}\", fontsize=10)\n            axs[i][j].set_axis_off()\n\n        # Plot waveform\n        axs[i + 1][j].plot(waveform)\n        axs[i + 1][j].set_axis_off()\n\n    plt.tight_layout()\n    plt.show()\n\n    # Play audio\n    for waveform, class_name in zip(waveforms, class_names):\n        print(f\"Playing: {class_name}\")\n        ipd.display(ipd.Audio(waveform, rate=sample_rate))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:41:07.849909Z","iopub.execute_input":"2025-06-15T14:41:07.850423Z","iopub.status.idle":"2025-06-15T14:41:07.859143Z","shell.execute_reply.started":"2025-06-15T14:41:07.850399Z","shell.execute_reply":"2025-06-15T14:41:07.858333Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Mel Spectrogram Extractor\n\nThe `MelSpecExtractor` extracts mel spectrograms, transforms amplitudes to dB, and applies min-max normalization. It also performs time and frequency masking on each sample in the batch - but only during training.","metadata":{}},{"cell_type":"code","source":"class SpecNormalization(nn.Module):\n    def __init__(self, eps=1e-6):\n        super().__init__()\n        self.eps = eps\n\n    def forward(self, x):\n        max_vals = torch.amax(x, dim=(-2, -1), keepdim=True)\n        min_vals = torch.amin(x, dim=(-2, -1), keepdim=True)\n        x = (x - min_vals) / (max_vals - min_vals + self.eps)\n        return x\n\n\nclass Masking(nn.Module):\n    def __init__(self, max_masks_num, mask_module, p):\n        super().__init__()\n        self.max_masks_num = max_masks_num\n        self.mask_module = mask_module\n        self.p = p\n\n    def forward(self, x):\n        for i in range(x.shape[0]):\n            if np.random.random() < self.p:\n                n_applies = np.random.randint(low=1, high=self.max_masks_num + 1)\n                for _ in range(n_applies):\n                    x[i : i + 1][0] = self.mask_module(x[i : i + 1][0])\n        return x\n\n\nclass MelSpecExtractor(nn.Module):\n    def __init__(\n        self,\n        sample_rate=32000,\n        n_fft=1024,\n        hop_length=512,\n        n_mels=128,\n        top_db=80.0,\n        augmentation_prob=0.5,\n        time_mask_param=20,\n        max_time_masks=5,\n        freq_mask_param=20,\n        max_freq_masks=5\n    ):\n        super().__init__()\n\n        self.extractor = nn.Sequential(\n            MelSpectrogram(\n                sample_rate=sample_rate,\n                n_fft=n_fft,\n                hop_length=hop_length,\n                n_mels=n_mels\n            ),\n            AmplitudeToDB(top_db=top_db),\n            SpecNormalization()\n        )\n\n        # Time and frequency masking augmentations\n        self.time_masking = Masking(\n            max_time_masks, \n            TimeMasking(time_mask_param=time_mask_param),\n            augmentation_prob\n        )\n        self.freq_masking = Masking(\n            max_freq_masks,\n            FrequencyMasking(freq_mask_param=freq_mask_param),\n            augmentation_prob\n        )\n\n    def forward(self, x, test_mode=False):\n        x = self.extractor(x)\n        \n        if not test_mode:\n            x = self.time_masking(x)\n            x = self.freq_masking(x)\n        return x","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:41:07.860360Z","iopub.execute_input":"2025-06-15T14:41:07.861298Z","iopub.status.idle":"2025-06-15T14:41:07.884105Z","shell.execute_reply.started":"2025-06-15T14:41:07.861265Z","shell.execute_reply":"2025-06-15T14:41:07.883263Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### BirdCLEFModel\n\nThe `BirdCLEFModel` uses EfficientNet B0 from `timm` as backbone, with a pooling layer and a linear layer as the classification head. Two pooling strategies are available: the classic `AdaptiveAvgPool2d` and the more flexible `GeneralizedMeanPool`.","metadata":{}},{"cell_type":"code","source":"class GeneralizedMeanPool(nn.Module):\n    def __init__(self, p=3.0, eps=1e-6):\n        super().__init__()\n        self.eps = eps\n        self.p = nn.Parameter(torch.ones(1) * p)\n\n    def forward(self, x):\n        x = x.clamp(min=self.eps)\n        x = x.pow(self.p)\n        x = F.avg_pool2d(x, (x.size(-2), x.size(-1)))\n        x = x.pow(1. / self.p)\n        return x\n\n\nclass BirdCLEFModel(nn.Module):\n    def __init__(\n        self,\n        backbone,\n        num_classes,\n        pretrained=True,\n        use_gem_pooling=True,\n        gem_p=3.0\n    ):\n        super().__init__()\n        self.num_classes = num_classes\n        self.use_gem_pooling = use_gem_pooling\n\n        self.backbone = timm.create_model(\n            backbone,\n            features_only=True,\n            pretrained=pretrained,\n            in_chans=1\n        )\n        n_features = self.backbone.feature_info.channels()[-1]\n\n        if self.use_gem_pooling:\n            self.pool = GeneralizedMeanPool(p=gem_p) \n        else:\n            self.pool = nn.AdaptiveAvgPool2d(1)\n\n        self.classifier = nn.Linear(n_features, num_classes)\n\n    def forward(self, x):\n        x = self.backbone(x)[-1]\n        x = self.pool(x).squeeze(-1).squeeze(-1) # (B, C, 1, 1) -> (B, C)\n        logits = self.classifier(x)\n        return logits","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:41:07.885028Z","iopub.execute_input":"2025-06-15T14:41:07.885310Z","iopub.status.idle":"2025-06-15T14:41:07.902415Z","shell.execute_reply.started":"2025-06-15T14:41:07.885289Z","shell.execute_reply":"2025-06-15T14:41:07.901434Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Data\n\nLet's load the `sample_submission.csv` to understand how the final output should look.","metadata":{}},{"cell_type":"code","source":"df_sample_submission = pd.read_csv(os.path.join(DATA_ROOT, \"sample_submission.csv\"))\ndf_sample_submission","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:41:07.904336Z","iopub.execute_input":"2025-06-15T14:41:07.904582Z","iopub.status.idle":"2025-06-15T14:41:07.969180Z","shell.execute_reply.started":"2025-06-15T14:41:07.904566Z","shell.execute_reply":"2025-06-15T14:41:07.968224Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Load the soundscapes data and create a `base_name` column. We'll use it later for creating the submission file.","metadata":{}},{"cell_type":"code","source":"# Get all file paths within the soundscape folder\nsoundscapes_data = [\n    [os.path.join(DATA_PATH, filename), filename.split(\".\")[0]]\n    for filename in os.listdir(DATA_PATH)\n    if filename.split(\".\")[-1].lower() == \"ogg\"\n]\n\ndf = pd.DataFrame(soundscapes_data, columns=[\"filepath\", \"base_name\"])\ndf.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:41:07.970051Z","iopub.execute_input":"2025-06-15T14:41:07.970287Z","iopub.status.idle":"2025-06-15T14:41:08.112969Z","shell.execute_reply.started":"2025-06-15T14:41:07.970269Z","shell.execute_reply":"2025-06-15T14:41:08.112199Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Dataset\n\nThe `BirdCLEFInferenceDataset` loads the soundscape, splits it into chunks, and normalizes each chunk if needed. Since soundscapes are expected to be 60 seconds long, chunking results in 12 chunks of 5 seconds each. If the audio has a few extra samples, its length is truncated. Here's a little trick in the dataset: instead of returning a single chunk, it actually returns a batch of chunks from the same soundscape. So, the input feature shape is `(n_chunks, n_channels, n_chunk_samples)`.\n\nAdditionally, the dataset creates names for the chunks to use in the submission.","metadata":{}},{"cell_type":"code","source":"class BirdCLEFInferenceDataset(Dataset):\n    def __init__(\n        self,\n        df,\n        input_col=\"filepath\",\n        name_col=\"base_name\",\n        sample_rate=32000,\n        chunk_duration=5,\n        normalize_chunk=True\n    ):\n        self.df = df.reset_index(drop=True)\n\n        self.input_col = input_col\n        self.name_col = name_col\n        self.sample_rate = sample_rate\n        self.chunk_duration = chunk_duration\n        self.chunk_samples = self.chunk_duration * self.sample_rate\n        self.normalize_chunk = normalize_chunk\n\n    def _get_chunks(self, audio):\n        # Truncate to fit exact chunks\n        n_chunks = audio.size // self.chunk_samples\n        audio = audio[:n_chunks * self.chunk_samples]\n\n        return audio.reshape(n_chunks, self.chunk_samples)\n    \n    def _prepare_features(self, idx):\n        # Load the audio file\n        audio_path = self.df.loc[idx, self.input_col]\n        audio, sr = librosa.load(audio_path, sr=self.sample_rate)\n\n        # Sanity checks\n        assert sr == self.sample_rate, \"loaded sample has invalid sample rate\"\n        assert len(audio.shape) == 1, \"loaded sample has invalid shape\"\n\n        # Split audio into chunks\n        chunks = self._get_chunks(audio)\n\n        # Normalize each chunk independently if required\n        if self.normalize_chunk:\n            chunks = [librosa.util.normalize(chunk) for chunk in chunks]\n\n        # Convert to shape (num_chunks, 1, num_samples) tensor\n        return torch.tensor(chunks).float().unsqueeze(1)\n\n    def _prepare_names(self, idx, n_chunks):\n        # Get audio name\n        base_name = self.df.loc[idx, self.name_col]\n        names = [f\"{base_name}_{self.chunk_duration * (i + 1)}\" for i in range(n_chunks)]\n        return names\n\n    def __getitem__(self, idx):\n        features = self._prepare_features(idx)\n        n_chunks = features.shape[0]\n        names = self._prepare_names(idx, n_chunks)\n        return { \n            \"input_features\": features,\n            \"names\": names\n        }\n\n    def __len__(self):\n        return len(self.df)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:41:08.113843Z","iopub.execute_input":"2025-06-15T14:41:08.114376Z","iopub.status.idle":"2025-06-15T14:41:08.123560Z","shell.execute_reply.started":"2025-06-15T14:41:08.114347Z","shell.execute_reply":"2025-06-15T14:41:08.122838Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Check that the dataset is working correctly.","metadata":{}},{"cell_type":"code","source":"def test_dataset(dataset, idx=10, display_n=4):\n    features = dataset[idx][\"input_features\"]\n    names = dataset[idx][\"names\"]\n\n    print(f\"Dataset length: {len(dataset)} samples\")\n    print(f\"Samples at index {idx}:\\n\")\n\n    print(f\"Batch input shape: {features.shape} (batch_size, channels, samples)\")\n    print(\"First input tensor:\")\n    print(features)\n\n    print(f\"\\nNames length: {len(names)}\")\n    print(\"Names:\")\n    print(names)\n\n    plot_wave_spectrograms(\n        features.squeeze(1)[:display_n], # remove channel dim for plotting (B, T)\n        sample_rate=SAMPLE_RATE,\n        class_names=names[:display_n],\n        num_cols=display_n\n    )\n\nif not IS_SUBMISSION:\n    dataset = BirdCLEFInferenceDataset(df)\n    test_dataset(dataset)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:41:08.124395Z","iopub.execute_input":"2025-06-15T14:41:08.124606Z","iopub.status.idle":"2025-06-15T14:41:40.847399Z","shell.execute_reply.started":"2025-06-15T14:41:08.124589Z","shell.execute_reply":"2025-06-15T14:41:40.846514Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## DataLoader\n\nSince we have a trick with `BirdCLEFInferenceDataset` output, we need a custom `collate_fn` to properly handle it in the `Dataloader`. We'll check that the dataloader is working as expected.\n\n**IMPORTANT:** The actual batch size is the `batch_size` from the `DataLoader` multiplied by the dataset’s internal \"batch size\".","metadata":{}},{"cell_type":"code","source":"def collate_fn(batch):\n    input_features = [item[\"input_features\"] for item in batch]\n    stacked = torch.cat(input_features, dim=0)\n\n    # Flatten names\n    names = [name for item in batch for name in item[\"names\"]]\n\n    return {\n        \"input_features\": stacked,\n        \"names\": names\n    }\n\ndef test_dataloader(dataloader, display_n=4):\n    for batch in dataloader:\n        features = batch[\"input_features\"]\n        names = batch[\"names\"]\n\n        print(f\"Batch input shape: {features.shape} (batch_size, channels, samples)\")\n        print(\"First input tensor:\")\n        print(features[0])\n\n        print(f\"\\nNames length: {len(names)}\")\n        print(\"Names:\")\n        print(names)\n\n        plot_wave_spectrograms(\n            features.squeeze(1)[:display_n], # remove channel dim for plotting (B, T)\n            sample_rate=SAMPLE_RATE,\n            class_names=names[:display_n],\n            num_cols=display_n\n        )\n\n        break\n\nif not IS_SUBMISSION:\n    dataloader = DataLoader(\n        dataset,\n        batch_size=2,\n        shuffle=False,\n        num_workers=3,\n        collate_fn=collate_fn\n    )\n    test_dataloader(dataloader)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:46:52.274246Z","iopub.execute_input":"2025-06-15T14:46:52.274641Z","iopub.status.idle":"2025-06-15T14:47:12.327612Z","shell.execute_reply.started":"2025-06-15T14:46:52.274616Z","shell.execute_reply":"2025-06-15T14:47:12.326620Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Inference\n## Setting Configs\n\nSet the configs based on the training ones, with necessary adjustments for inference mode.","metadata":{}},{"cell_type":"code","source":"dataset_configs = {\n    \"input_col\": \"filepath\",\n    \"name_col\": \"base_name\",\n    \"sample_rate\": SAMPLE_RATE,\n    \"chunk_duration\": 5,\n    \"normalize_chunk\": True\n}\n\ndataloader_configs = {\n    \"batch_size\": 2,\n    \"num_workers\": 3,\n    \"shuffle\": False\n}\n\nspec_extractor_configs = {\n    \"sample_rate\": SAMPLE_RATE,\n    \"n_fft\": 1024,\n    \"hop_length\": 512,\n    \"n_mels\": 128,\n    \"top_db\": 80.0,\n    \"augmentation_prob\": 0,\n    \"time_mask_param\": 20,\n    \"max_time_masks\": 3,\n    \"freq_mask_param\": 10,\n    \"max_freq_masks\": 3\n}\n\nbirdclef_model_configs = {\n    \"backbone\": \"efficientnet_b0\",\n    \"num_classes\": 206,\n    \"pretrained\": False,\n    \"use_gem_pooling\": True,\n    \"gem_p\": 3.0\n}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:47:44.715334Z","iopub.execute_input":"2025-06-15T14:47:44.716071Z","iopub.status.idle":"2025-06-15T14:47:44.721433Z","shell.execute_reply.started":"2025-06-15T14:47:44.716046Z","shell.execute_reply":"2025-06-15T14:47:44.720450Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Ensemble of Models\n\nWe create the dataset, dataloader, and `MelSpecExtractor`. To speed things up, we use a smaller subset of the data just to verify everything runs correctly - unless we're doing a full submission. Then, we load the weights of the `BirdCLEFModel`s and create an ensemble, setting each model to evaluation mode.","metadata":{}},{"cell_type":"code","source":"if not IS_SUBMISSION:\n    df = df[:4]\n\n# Create Dataset and DataLoader\ndataset = BirdCLEFInferenceDataset(df, **dataset_configs)\ndataloader = DataLoader(dataset, collate_fn=collate_fn, **dataloader_configs)\n\n# Create Spectrogram Extractor\nspec_extractor = MelSpecExtractor(**spec_extractor_configs)\n\n# Load pretrained BirdCLEF models\nensemble = []\nfor filename in os.listdir(MODELS_ROOT):\n    path = os.path.join(MODELS_ROOT, filename)\n    model = BirdCLEFModel(**birdclef_model_configs)\n    model.load_state_dict(torch.load(path))\n    model.eval()\n    ensemble.append(model)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:47:51.636252Z","iopub.execute_input":"2025-06-15T14:47:51.636540Z","iopub.status.idle":"2025-06-15T14:47:53.405749Z","shell.execute_reply.started":"2025-06-15T14:47:51.636521Z","shell.execute_reply":"2025-06-15T14:47:53.405003Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Running Inference\n\nHere comes the main inference part. We use an ensemble of models trained during cross-validation. To get the final probabilities, we compute the mean of all models’ predicted probabilities.\n\nTo improve the results, the idea is to try different aggregation strategies (e.g., weighting models according to their test scores).","metadata":{}},{"cell_type":"code","source":"all_probs, all_names = [], []\n\nfor batch in tqdm(dataloader, desc=\"Inferencing\"):\n    # Convert raw audio chunks to mel spectrogram features\n    spec_inputs = spec_extractor(batch[\"input_features\"], test_mode=True)\n\n    # Collect probability predictions from each model in the ensemble\n    models_probs = []\n    for model in ensemble:\n        with torch.no_grad():\n            logits = model(spec_inputs)\n        probs = torch.sigmoid(logits)\n        models_probs.append(probs)\n\n    # Average the probabilities across all models for ensemble prediction\n    stacked = torch.stack(models_probs, dim=0) # (num_models, batch_size, num_classes)\n    avg_probs = stacked.mean(dim=0) # (batch_size, num_classes)\n\n    all_names += batch[\"names\"]\n    all_probs += avg_probs.tolist()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:47:58.507897Z","iopub.execute_input":"2025-06-15T14:47:58.508624Z","iopub.status.idle":"2025-06-15T14:48:10.226832Z","shell.execute_reply.started":"2025-06-15T14:47:58.508594Z","shell.execute_reply":"2025-06-15T14:48:10.225827Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Submission\n\nThe last step: we create and save a submission file.","metadata":{}},{"cell_type":"code","source":"submission_data = []\nfor name, probs in zip(all_names, all_probs):\n    submission_data.append([name] + probs)\n\ndf_submission = pd.DataFrame(submission_data, columns=df_sample_submission.columns)\ndf_submission.to_csv(\"submission.csv\", index=False)\ndf_submission.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-15T14:48:23.375873Z","iopub.execute_input":"2025-06-15T14:48:23.376248Z","iopub.status.idle":"2025-06-15T14:48:23.430051Z","shell.execute_reply.started":"2025-06-15T14:48:23.376219Z","shell.execute_reply":"2025-06-15T14:48:23.429124Z"}},"outputs":[],"execution_count":null}]}