{
  "id": 583380,
  "title": "3rd place solution: 3D/2D UNet with Gaussian Heatmap and  WBF",
  "url": "/competitions/byu-locating-bacterial-flagellar-motors-2025/writeups/mimi-3rd-place-solution-3d-2d-unet-with-gaussian-h",
  "author_name": "",
  "post_date": "2025-06-18T04:35:17.390Z",
  "votes": 42,
  "comment_count": 7,
  "views": 0,
  "content": "<p>Many thanks to the competition host and Kaggle for another engaging challenge - and big congratulations to all the participants!<br>\nThank you to every one who actively shared insights and notebooks to the community, especially <a href=\"https://www.kaggle.com/brendanartley\" target=\"_blank\">@brendanartley</a> for <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921\" target=\"_blank\">the external dataset</a>.</p>\n<p>As always, I had a great time learning throughout the competition and was fortunate enough to finish in the gold zone. I'm really happy to share a few thoughts here.</p>\n<h1>TLDR;</h1>\n<ul>\n<li>I'm focus on the 3D/2.5D/2D UNet approaches. The final submission PB 86.6 was an ensemble of 5 models: <code>3D Resnext50 (2xTTA)</code>, <code>3D Densenet121 (2xTTA)</code>, <code>3D X3D-M (2xTTA)</code>, <code>2D MaxViT (2xTTA)</code> and <code>2D CoaT (1xTTA)</code></li>\n<li>3D model use patch size of <code>(224, 448, 448)</code> while 2D use <code>(3, 896, 896)</code> patch size to incorporate enough global context.</li>\n<li>Gaussian heatmap with <code>stride=16</code> and <code>sigma=200A</code> for uncertainty modeling</li>\n<li>Simple FPN neck to fuse low-level features into stride-16-feature map</li>\n<li>All final selected models are trained on combine of train dataset + external dataset without local validation</li>\n<li>Heavy augmentations allowed to train for more epochs and be more robust to domain/distribution change.</li>\n<li>Pseudo labeling on train+external dataset, with manual review</li>\n<li>Resample all tomograms to 16A voxel spacing + Sliding window patches inference</li>\n<li>Simple BCE loss works best</li>\n<li>Weighted Box Fusion (WBF) to ensemble multiple model/TTA ’s predictions</li>\n</ul>\n<p><strong>What not work</strong></p>\n<ul>\n<li>Other losses: MSE, L1, Weighted BCE, Focal, Tversky, Combine multiple losses</li>\n</ul>\n<h1>Table of Contents</h1>\n<ul>\n<li><a href=\"#validation-strategy\">Validation strategy</a></li>\n<li><a href=\"#3d-unet-approach\">3D-UNet Approach</a><ul>\n<li><a href=\"#data-processing\">Data processing</a></li>\n<li><a href=\"#3d-augmentation\">3D augmentation</a></li>\n<li><a href=\"#3d-target-heatmap\">3D target heatmap</a></li>\n<li><a href=\"#3d-modeling\">3D modeling</a></li>\n<li><a href=\"#3d-encoder\">3D Encoder</a></li>\n<li><a href=\"#25d-encoder\">2.5D Encoder</a></li>\n<li><a href=\"#some-results\">Some results</a></li></ul></li>\n<li><a href=\"#2d-unet-approach\">2D-UNet Approach</a><ul>\n<li><a href=\"#data-processing-1\">Data processing</a></li>\n<li><a href=\"#2d-augmentation\">2D augmentation</a></li>\n<li><a href=\"#2d-target-heatmap\">2D target heatmap</a></li>\n<li><a href=\"#2d-modeling\">2D modeling</a></li></ul></li>\n<li><a href=\"#external-dataset--pseudo-label\">External dataset &amp; Pseudo label</a></li>\n<li><a href=\"#postprocessing\">Postprocessing</a></li>\n<li><a href=\"#source-code\">Source code</a></li>\n</ul>\n<hr>\n<h1>Validation strategy</h1>\n<p>A simple 4-StratifiedKfold by number of motors was conducted on train dataset. Validation samples are augmented by Rotate90+Flip (16 posible combinations) to increase the number of samples, making validation metrics more stable and trustworthy to track.</p>\n<p>Experiment progress was tracked using mainly F2 score and additionally Partial Average Precision, cutoff at a specific Recall, e.g &gt; 0.9. The second is not a binary metric so more stable and less sentitive to threshold, strong correlate with F2. Single-fold local CV was the main factor to select models and hyperparameters.</p>\n<p><strong>Train on all data, validate on LB:</strong> However, when my pipeline are bug-free and going better (when CV &gt; 0.96 and LB &gt; 0.8), CV is just saturated and not correlate to LB well. So at the later stage, CV is just used to guide a reasonable value of hyperparameter (mainly learning rate). In last week, all models were trained on all dataset without local validation and are submitted to LB.</p>\n<h1>3D-UNet Approach</h1>\n<h2>Data processing</h2>\n<p>I resample tomogram to a fixed voxel spacing of 16.0, use sliding windows (patching) technique, split tomogram to <code>224x448x448</code> (possibly overlap) patches as input to models to obtain <strong>16x</strong> downscaled prediction heatmap of shape <code>14x28x28</code>.  <br>\nThe labeling coordinate is a little bit noisy/uncertain, also the tolerance radius is high, so using high heatmap resolution seem to be redundant, significantly increase the computation. <code>Radius of 1000A</code> == <code>62.5 voxels in input volume</code> == <code>4 voxels in prediction heatmap</code>. I've experimented with prediction heatmap of <code>8x</code> and <code>32x</code> downscaled heatmap and found that <code>8x</code> ~ <code>16x</code> &gt;&gt; <code>32x</code>. To the end, I use <code>16x</code> downscaled heatmap for both 3D and 2D models.</p>\n<p><strong>Optimal voxel spacing is ~16.0</strong>  <br>\nFor this task, resampling to a suitable voxel spacing is important. Using small voxel spacing keep the very small details but computation intensive due to high resolution, in contrast large voxel spacing could erase out all the textures needed for detection. Experiment results confirm that very small detail/textures are matter:</p>\n<ul>\n<li>Tried various target voxel spacing 12, 16, 20, 24, 32, 64; found that CV degrade significantly when spacing &gt; 32. LB also degrade much faster right after spacing &gt; 20. I found 16.0 is the best balance value, matching with the typical <a href=\"https://github.com/MIC-DKFZ/nnUNet/issues/2701\" target=\"_blank\">median spacing</a> default practice of nnUnet (median spacing is 15.6 for the train dataset).</li>\n<li>Resampling interpolation mode (LINEAR, AREA, NEAREST) also could significantly affect performance. All tomograms are also noisy (Gaussian noise?) so resize/interpolation could erase thing in an unexpected manner.</li>\n</ul>\n<p><strong>The mean original voxel spacing in LB/PB is 13.1 ???</strong>  <br>\nHaha, just my stupid guess work and there're no way I could confirm. I trained model with <code>target_spacing=16.0</code> and submit to LB with different of assumtions about the original spacing. <code>13.1</code> brings the highest LB. Given that test samples have higher resolution (<code>~400x1400x1050</code> in average) and 44.7% of train samples have <code>voxel_spacing=13.1</code>, <strong>I safely assume that the average test voxel spacing is ~13.1, and resample to 16.0 for all experiments</strong></p>\n<blockquote>\n  <p>I tried to quickly train a voxel spacing prediction model on the last day, but failed, metric is about <code>MAE=5.1</code>, can't predict any useful thing :D</p>\n</blockquote>\n<p>Each sample is dominated by negative voxels, so I try to control the number of positive/negative patches ratio in the training progress. For 3D approach, the positive/negative samples ratio is 20/1.</p>\n<h2>3D augmentation</h2>\n<p>For each epoch, all positive samples are ensured to be augmented with all 16 transformations composed of Rotate90+Flip, go after a \"heavy\" MONAI augmentation pipeline. Keypoints passed to MONAI dictionary transform as well so that we get the Affine-transformed keypoints to render the heatmap with larger stride (16 in this case). I really like MONAI's <a href=\"https://docs.monai.io/en/stable/lazy_resampling.html\" target=\"_blank\">Lazy Resampling</a>, which make augmentation and dataloader become fast and efficient.<br>\nVarious MONAI 3D augmentations were used:</p>\n<ul>\n<li>Spatial: <code>RandZoom</code>, <code>RandAffined</code></li>\n<li>Intensity: <code>RandShiftIntensityd</code>, <code>RandStdShiftIntensityd</code>, <code>RandScaleIntensityFixedMeand</code>, <code>RandScaleIntensityd</code>, <code>RandAdjustContrastd</code>, <code>RandHistogramShiftd</code></li>\n<li>Reduce quality: <code>RandSimulateLowResolutiond</code></li>\n<li>Dropout: <code>RandCoarseDropoutKeepKeypoints</code></li>\n</ul>\n<p>Mixup and Cutmix were tried but bring no clear improvement so be ignored in latter experiments.</p>\n<p></p><br>\nPseudo code in Albumentations style<p></p>\n<pre><code> monai  transforms  T\n monai_custom  CT\n\nT.Compose(\n    [\n        \n        T.Spacingd(target_spacing=, p=),\n        CT.CustomCropBySlicesd(),\n        \n        T.RandZoomd(min_zoom=(, , ), max_zoom=(, , ), p=),\n        T.OneOf(\n            [\n                \n                T.RandAffined(\n                    rotate_range=((, ), (, ), (, )),\n                    scale_range=((-, ), (-, ), (-, )),\n                    p=,\n                ),\n                \n                T.RandAffined(\n                    rotate_range=((-, ), (-, ), (, )),\n                    shear_range=((-, ), (-, ), (-, )),\n                    scale_range=((-, ), (-, ), (-, )),\n                    p=,\n                ),\n            ],\n            p=,\n        ),\n        \n        T.OneOf(\n            [\n                T.Compose(\n                    [\n                        \n                        T.OneOf(\n                            [\n                                T.RandShiftIntensityd(offset=(-, ), p=),\n                                T.RandStdShiftIntensityd(factors=(-, ), p=),\n                            ],\n                            p=,\n                        ),\n                        \n                        T.OneOf(\n                            [\n                                \n                                T.RandScaleIntensityFixedMeand(factors=(-, ), p=),\n                                \n                                T.RandScaleIntensityFixedMeand(factors=(, ), p=),\n                            ],\n                            p=,\n                        ),\n                    ],\n                    p=,\n                ),\n                T.RandScaleIntensityd(factors=(-, ), channel_wise=, p=),\n                T.RandAdjustContrastd(gamma=(, ), p=),\n                T.RandHistogramShiftd(num_control_points=(, ), p=),\n            ],\n            p=,\n        ),\n        \n        \n        CT.ApplyTransformToNormalDistributionsd(keys=[]),\n        \n        T.RandSimulateLowResolutiond(\n            zoom_range=(, ), downsample_mode=, upsample_mode=, p=\n        ),\n        CT.RandCoarseDropoutWithKeypointsd(\n            keypoints_key=, holes=, spatial_size=(, , ),\n            dropout_holes=, fill_value=(, ), max_holes=,\n            max_spatial_size=(, , ), prob=\n        )\n    ]\n)\n</code></pre>\n<p></p><p></p>\n<h2>3D target heatmap</h2>\n<p>For a task where real distance/scale could be measured, e.g voxel spacing is known, I prefer a fixed sigma measured in real-life distance. Some advances in bottom-up human pose estimation have introduced techniques to adaptively rescale each keypoint's heatmap. Intuitively, these methods adjust the heatmap’s sigma values based on the size of each person, assigning larger/smaller sigmas (higher/lower uncertainty) to larger/smaller ones (relative to their pixel count/box area).</p>\n<p>I use a sigma value of 200A (0.2 times the radius). Note that going through Spatial augmentations like Spacing, Affine or Zoom, the voxel spacing is changed so the value of sigma used in rendering the heatmap is not a fixed number of voxels, but rather <code>0.2 * 1000 / cur_voxel_spacing</code>.  <br>\nA custom implementation of <a href=\"https://docs.monai.io/en/stable/transforms.html#applytransformtopoints\" target=\"_blank\">ApplyTransformToPoints</a> was implemented to apply Affine Transform to a Normal distribution, result in another Normal distribution to render on the target heatmap volume. Hence, instead of a ball-like shape, each keypoint looks like a rotated ellipsoid.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fd2d3ba10849048534fcc91ff265ac800%2F3d_heatmap.png?generation=1749212341383311&amp;alt=media\" alt=\"\"></p>\n<p>Does this make sense? Maybe not that significant. It's just an assumption that the likelihood of a motor <strong>strictly</strong> follows a 3D Normal distribution in the original tomogram, which is not likely for this task. Later, I decided to use a ball-like-shape Gaussian heatmap, with diagonal covariance matrix and single sigma for all 3 dimensions ZYX to make life easier for my model (just need to predict the mean and scale, intuitively).</p>\n<h2>3D modeling</h2>\n<p>3D-UNet with various 3D/2.5D Encoder was tried and be used in final submision. Taking input as a 3D volume of shape <code>(Z, Y, X) = (224, 448, 448)</code>, all model predict the output heatmap of shape <code>(Z/16, Y/16, X/16) = (14, 28, 28)</code>.  <br>\n3D-UNet Decoder borrowed from <a href=\"https://github.com/ZFTurbo/segmentation_models_pytorch_3d\" target=\"_blank\">segmentation_models_pytorch_3d</a> was tweak to output multiscale heatmaps for ultilizing deep supervision. Since for this task, finest heatmap has stride 16 right after the coarsest one of 32 (not too deep), deep supervision seem to be redundant experimentally, later I just supervise the 1/16 heatmap only.</p>\n<h3>3D Encoder</h3>\n<p>I mainly tweak and use models from either <a href=\"https://github.com/ZFTurbo/timm_3d\" target=\"_blank\">timm3d</a> or <a href=\"https://github.com/facebookresearch/SlowFast\" target=\"_blank\">SlowFast</a>. By default, video models like X3D and I3D in SlowFast did not use temporal pooling, keeping the temporal dim unchanged to keep as much as temporal resolution as posible. For this task, this is unnecessary, I make change so that depth (temporal in video term) is progressively downsampled by 2 at each stage, synchronized with spatial downsampling too. Now, all 3D models have the same interface, output at least 4 (usually 5) levels of feature map at stride 1/4, 1/8, 1/16, 1/32.  <br>\nAs discuss above, we need high resolution (small voxel spacing) for keeping very little details/textures. Standard UNet ultilize and fuse low-level/fine features via skip connection to the same output level. However, UNet with output stride of 16 is not actually U-shape now, hence there're no shorter path to propagate the low-level/fine features (rich texture/localization) to the high-level/coarse output heatmap (rich semantic). Motivated by this, I add necks to fuse low-level features to the coarse output heatmap head, efficiently improve information propagation through the network. Two types of necks were tried:</p>\n<ul>\n<li>Simple 3D FPN Neck: motivated by <a href=\"https://www.kaggle.com/competitions/google-research-identify-contrails-reduce-global-warming/discussion/430491\" target=\"_blank\">this writeup</a> and <a href=\"http://presentations.cocodataset.org/COCO17-Stuff-FAIR.pdf\" target=\"_blank\">Unified FPN architecture</a>. Simply interpolate features at all level to match spatial shape at stride 1/16, concatenate them all then passed to the UNet Decoder block</li>\n<li><a href=\"https://arxiv.org/abs/1803.01534\" target=\"_blank\">3D PAN (Path Aggregation Network)</a>: Instead of just adding top-down path augmentation as in <a href=\"https://arxiv.org/pdf/1612.03144\" target=\"_blank\">FPN</a> to fuse rich semantic features to low-level features at finer scale, we now add another bottom-up path augmentation to fuse rich texture/detail features to the coarser one as well.</li>\n</ul>\n<p>It's clear that Simple FPN is a \"more straight\" way to achieve our goal. Not putting much time on this, but my experiment results also confirm that Simple FPN is usually better than PAN. So all my final models were additionally equipped with this Simple FPN neck, illustrated below.  </p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2F2f2cab849715f0dd2bf2b2a93fe04e8a%2F3d_unet_fpn.jpg?generation=1749212388256232&amp;alt=media\" alt=\"\"></p>\n<h3>2.5D Encoder</h3>\n<p>I'm also working with 2.5D encoder, combined with FPN neck and 3D UNet decoder too. The idea is very simple. First we sample a list of 2D 3-channels image by sliding over the depth (Z) dimension with stride of 2, effectively treat one volume as a batch of 2D images. At each stage of 2D backbone, spatial resolution is downsampled by 2 by default, we also down sample the depth (now treated as batch) by a factor of 2 too by either <code>AvgPool3d((2, 1, 1))</code> or <code>Conv3d(stride=(2, 1, 1))</code>. The first one was better and easier to train since it keep tensor statistics unchanged, better leveraging transfer learning from pretrained 2D backbone. It shows good score if no external dataset was used, significantly reduce overhead at middle/latter stages, being lighweight and helps model effectively \"see\" all 2D slices of a volume. It's okay for 2D convolution-only backbone such as EfficientNet or ConvNeXT which the heavy computation goes in middle or latter stages, but not for backbone which use Transformer in early stage cause of the high resolution and very large \"fake batch size\"</p>\n<p>Also played with somethings like injecting LSTM layers after obtained 2D features, CV may go up but LB just unchanged. Perhap Conv3d in FPN neck and UNet Decoder was better fusing slicewise features, additional mixing is not necessary anymore.</p>\n<h3>Some results</h3>\n<p>I report LB/PB score of single model (single checkpoint). These models are:</p>\n<ul>\n<li>Trained on all data (train + external)</li>\n<li>2xTTA: original + rotate 90 along XY plane</li>\n<li>Threshold is the quantile at 55% over all prediction confident scores.</li>\n</ul>\n<p>I start using external data a little bit late, so just 1 last checkpoint of them was submitted to obtain LB score with very limitted tuning. Each model's runtime is not exceed 3h.</p>\n<table>\n<thead>\n<tr>\n<th><strong>Model</strong></th>\n<th><strong>Params</strong></th>\n<th><strong>Private LB</strong></th>\n<th><strong>Public LB</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>3D RESNEXT50</td>\n<td>49.9M</td>\n<td><strong>86.2</strong></td>\n<td>85.8</td>\n</tr>\n<tr>\n<td>3D DENSENET121</td>\n<td>27.6M</td>\n<td>85.7</td>\n<td><strong>86.0</strong></td>\n</tr>\n<tr>\n<td>X3DM + FPN</td>\n<td>10.4M</td>\n<td>84.6</td>\n<td>84.8</td>\n</tr>\n<tr>\n<td>X3DM + PAN + MIXUP</td>\n<td>6.9M</td>\n<td>84.6</td>\n<td>84.5</td>\n</tr>\n<tr>\n<td>X3DL</td>\n<td>12.8M</td>\n<td>83.9</td>\n<td>85.2</td>\n</tr>\n<tr>\n<td>3D RESNET50</td>\n<td>70.3M</td>\n<td>83.0</td>\n<td>85.4</td>\n</tr>\n<tr>\n<td><em>2.5D CONVNEXT_TINY</em></td>\n<td>40.3M</td>\n<td>82.4</td>\n<td>83.7</td>\n</tr>\n<tr>\n<td><em>2D COAT_LITE_MEDIUM</em></td>\n<td>99.6M</td>\n<td>83.3</td>\n<td>84.8</td>\n</tr>\n<tr>\n<td><em>2D MAXVIT_TINY</em></td>\n<td>86.1M</td>\n<td>82.5</td>\n<td>85.4</td>\n</tr>\n</tbody>\n</table>\n<h1>2D-UNet Approach</h1>\n<p>2D approach was in my plan, but it becomes urgent since I feel that may be many high score public LB may using 2D approaches, especially YOLO. 2 weeks before competition deadline, I started working on 2D modeling and inherrit many details from the 3D one. Training 2D models was much more resource efficient and much faster compared to 3D.</p>\n<h2>Data processing</h2>\n<p>The same target voxel spacing of 16.0 was used too, i.e I resize each 2D slice so that XY spacing is 16A. Of course augmentations could scale this spacing later, but same as 3D approach, sigma of each keypoint is rescaled accordingly so that final sigma is fixed at a real distance of 200A.<br>\nIn inference stage, I load the full volume on RAM then perform LINEAR interpolation to fixed voxel spacing of <code>(Z, Y, X) = (32, 16, 16)</code>, that say sampling along Z (depth) is more sparse. This helps reduce inference cost without too much performance decregation. I use 3 channels image of size <code>(896, 896)</code> as input to 2D-UNet model.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fa69bde60b2bcc2eff12633f2a3e5ea08%2F2d_pipeline.jpg?generation=1749212467165435&amp;alt=media\" alt=\"\"></p>\n<p>In training stage, different from 3D pipeline where I load whole volume to RAM then using MONAI's lazy resampling (using F.grid_sample internally), 2D pipeline just sample a specific value of Z (following truncated normal distribution with scale of 1 sigma) then load 3 nearby images per sample (spacing~32A) from disk, which is pretty efficient. Although 2D approach is more sample efficient to reach a medium score, controlling the positive/negative samples ratio is still important so that model could see more negative slices over all data domain/distribution rather than just the \"motor’s neighbor\" slices. I started experimenting with negative/positive ratio equal to 0.25, latter found that CV improved quite significantly when set it to 0.75, but not enough submissions to confirm that on LB.</p>\n<h2>2D augmentation</h2>\n<p><a href=\"https://github.com/albumentations-team/albumentations\" target=\"_blank\">Albumentations</a> was used to create augmentation pipeline, with ability to handle and keep track of keypoints scaling as well, which is pretty neat.<br>\n</p><br>\n 2D augmentation code <p></p>\n<pre><code>TARGET_SPACING = \nPATCH_SIZE = (, )\n\n ():\n    \n     params\n       data  (data[]) &gt; :\n        kpt = random.choice(data[])\n        \n         (kpt) == \n        x, y = kpt[:]\n        scale = kpt[]\n         [\n            x - margin_xy[] * scale,\n            y - margin_xy[] * scale,\n            x + margin_xy[] * scale,\n            y + margin_xy[] * scale,\n        ]\n    :\n         \n\n\naugment_transform = A.Compose(\n    [\n        \n        A.HorizontalFlip(p=),\n        A.VerticalFlip(p=),\n        \n        A.OneOf(\n            [\n                A.GaussNoise(\n                    var_limit=(, ),\n                    mean=,\n                    per_channel=,\n                    noise_scale_factor=,\n                    p=,\n                ),\n                A.MultiplicativeNoise(\n                    multiplier=(, ),\n                    per_channel=,\n                    elementwise=,\n                    p=,\n                ),\n            ],\n            p=,\n        ),\n        \n        A.OneOf(\n            [\n                \n                A.OneOf(\n                    [\n                        A.Downscale(\n                            scale_range=(, ),\n                            interpolation_pair={\n                                : cv2.INTER_LANCZOS4,\n                                : cv2.INTER_AREA,\n                            },\n                            p=,\n                        ),\n                        A.Downscale(\n                            scale_range=(, ),\n                            interpolation_pair={\n                                : cv2.INTER_LINEAR,\n                                : cv2.INTER_AREA,\n                            },\n                            p=,\n                        ),\n                        A.Downscale(\n                            scale_range=(, ),\n                            interpolation_pair={\n                                : cv2.INTER_LINEAR,\n                                : cv2.INTER_LINEAR,\n                            },\n                            p=,\n                        ),\n                    ],\n                    p=,\n                ),\n                A.ImageCompression(\n                    compression_type=, quality_range=(, ), p=\n                ),\n                A.Posterize(num_bits=(, ), p=),\n            ],\n            p=,\n        ),\n        \n        A.OneOf(\n            [\n                \n                A.Emboss(alpha=(, ), strength=(, ), p=),\n                A.Sharpen(alpha=(, ), lightness=(, ), p=),\n                A.CLAHE(clip_limit=, tile_grid_size=(, ), p=),\n            ],\n            p=,\n        ),\n        \n        A.OneOf(\n            [\n                A.OneOf(\n                    [\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(-, ),\n                            contrast_limit=(-, ),\n                            brightness_by_max=,\n                            p=,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(, ),\n                            contrast_limit=(-, ),\n                            brightness_by_max=,\n                            p=,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(-, ),\n                            contrast_limit=(, ),\n                            brightness_by_max=,\n                            p=,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(, ),\n                            contrast_limit=(, ),\n                            brightness_by_max=,\n                            p=,\n                        ),\n                    ],\n                    p=,\n                ),\n                A.RandomToneCurve(scale=, per_channel=, p=),\n                A.RandomGamma(gamma_limit=(, ), p=),\n            ],\n            p=,\n        ),\n        \n        A.OneOf(\n            [\n                \n                A.Affine(\n                    scale={: (, ), : (, )},\n                    translate_percent=,\n                    rotate=(, ),\n                    shear={: (-, ), : (-, )},\n                    interpolation=cv2.INTER_LINEAR,\n                    cval=,\n                    mode=cv2.BORDER_CONSTANT,\n                    fit_output=,\n                    keep_ratio=,\n                    balanced_scale=,\n                    p=,\n                ),\n                \n                A.Affine(\n                    scale={: (, ), : (, )},\n                    translate_percent=,\n                    rotate=(, ),\n                    shear={: (-, ), : (-, )},\n                    interpolation=cv2.INTER_LINEAR,\n                    cval=,\n                    mode=cv2.BORDER_CONSTANT,\n                    fit_output=,\n                    keep_ratio=,\n                    balanced_scale=,\n                    p=,\n                ),\n                A.Perspective(\n                    scale=(, ),\n                    keep_size=,\n                    pad_mode=cv2.BORDER_CONSTANT,\n                    pad_val=,\n                    fit_output=,\n                    interpolation=cv2.INTER_LINEAR,\n                    p=,\n                ),\n            ],\n            p=,\n        ),\n        \n        AC.CustomRandomSizedBBoxSafeCrop(\n            crop_size=PATCH_SIZE,\n            scale=(, ),  \n            ratio=(, ),  \n            get_bbox_func=partial(\n                _byu_get_safe_bbox,\n                margin_xy=(\n                     / TARGET_SPACING,\n                     / TARGET_SPACING,\n                ),\n            ),\n            retry=,\n            p=,\n        ),\n        A.PadIfNeeded(\n            *PATCH_SIZE,\n            position=,\n            border_mode=cv2.BORDER_CONSTANT,\n            value=,\n            p=,\n        ),\n    ],\n    p=,\n)\n</code></pre>\n<p></p><p></p>\n<h2>2D target heatmap</h2>\n<p>There're many ways to render target heatmap. I do a sweep over 4 kinds of heatmap type with different parameters, and found that the best CV was Gaussian heatmap with peak confident and radius scales smaller when goes far from the Z groudtruth coordinate, same as if we render 3D Gaussian heatmap then take a 2D slice over. <code>adaptive scaling</code> &gt; <code>min=0, max=1</code> &gt;  <code>segment</code> &gt; <code>point</code>, which support more that we need to modeling uncertainty better.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fd53a89634e4065110b61d729a2736fda%2F2d_heatmap_types_compressed.jpg?generation=1749212650075518&amp;alt=media\" alt=\"\"></p>\n<h2>2D modeling</h2>\n<p>The model used for 2D modeling was adapted from <a href=\"https://www.kaggle.com/competitions/google-research-identify-contrails-reduce-global-warming/discussion/430491\" target=\"_blank\">this Google Contrails solution writeup</a>, thank authors very much for public their source code. It contains an image encoder, a simple FPN neck to fuse low-level features to <code>/16</code> feature map, and an UNet Decoder with Pixel Shuffle block.  <br>\nI mainly experiment with different types of 2D encoders including EfficientNet, ConvNeXt, CoaT and MaxViT. No supprising, the Hierachical Convolution-Transformer backbones were perform much better than convolution only, indicated that global context matter and we need a sufficient spatial size for obtaining enough context. In selected final submission, I use 2 2D-UNet models with timm backbones: <code>maxvit_tiny_tf_512.in1k</code> and <code>coat_lite_medium_384.in1k</code>.</p>\n<h1>External dataset &amp; Pseudo label</h1>\n<p>Again, I must thank <a href=\"https://www.kaggle.com/brendanartley\" target=\"_blank\">@brendanartley</a> for <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921\" target=\"_blank\">the external dataset</a>, especially in early stage of the competition, and <a href=\"https://www.kaggle.com/tatamikenn\" target=\"_blank\">@tatamikenn</a> for <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/575028\" target=\"_blank\">his note</a> on wrong-quantized tomograms.</p>\n<p>I started diving into that external dataset to train my final submission a week ago, after ensured that my training/inference pipeline is good enough and bug-free (no?). Without external data, I have a simple ensemble of 3D/2.5D UNet with <code>PB 0.851 and LB 0.852</code>.</p>\n<p><a href=\"https://www.kaggle.com/brendanartley\" target=\"_blank\">@brendanartley</a>'s <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921\" target=\"_blank\">dataset</a> was good, but seem not to be directly usable for my pipeline because of slicing and resizing to a fixed shape and losing voxel spacing information. So I tweak the download script to get my own new version, improve it by pseudo labeling+manual review:</p>\n<ol>\n<li>Keep track the voxel spacing and other metadata as well</li>\n<li>Re-spacing using <code>F.interpolate()</code> to <code>target_spacing=max(16.0, ori_spacing)</code> to save disk space and reduce dataloader overhead in the future. This results in a new <strong>180GB</strong> external dataset.</li>\n<li>Convert his labels to fit my new version. This could loses some precision of Z coordinate.</li>\n<li>Use my trained models to inference on these data with low confident threshold of <code>0.05</code>. Comparing model prediction results with converted GT to identify TP/TN/FP/FN motors.</li>\n<li>For TP, simply trust model prediction results rather than the converted GT coordinates</li>\n<li>Use <a href=\"https://github.com/cvat-ai/cvat\" target=\"_blank\">CVAT</a> to review and label 188 FN and 55 FP. Labeling it is simple and easy as a tagging process, where the labeler (me) just consider whether a sample is wrong or correct, not finding new ones. Almost all FN are correct (?), meaning models are missing so many motors. Labeling these confused cases is hard, but the F2 metric and \"believe in AI models\" punishs me to be bias and may label more to positives than usual, especially one without flagellum. This could cause inconsistances with public/private test since host <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/579540#3217068\" target=\"_blank\">had clarified</a> that “more than 80% of the tomograms containing no flagellum the motor is not marked”</li>\n<li>With same strategy, I also try to correct the labeling errors on the train dataset as well.</li>\n<li>BOOM! Just re-train models on all dataset with previously discovered hyperparameters</li>\n</ol>\n<p>Adding so many external data change the game significantly. Training are more stable and allow for much longer training without overfiting. Though more tunings on new setup are needed, however, the deadline is quickly approaching its final days..</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2F715d399576ca0db1d6ff68d78b522fd1%2Fcvat.png?generation=1749212703333308&amp;alt=media\" alt=\"\"></p>\n<h1>Postprocessing</h1>\n<p>As usual, I'm not use a fixed threshold since it's largely change between submitted models to the LB. Instead, just use quantile thresholding at 55% (55% lowest threshold samples are predicted as negative, the remain 45% ones predicted as positive) which is adaptively changed based on model's confident distribution, so just one LB submission is enough to estimate the best F2 score.</p>\n<p>The postprocessing process:</p>\n<ul>\n<li>I use 5 models combined with TTAs, output 9 different heatmaps</li>\n<li>Not using heatmap averaging, instead, decode each heatmap into list of candidate keypoints using NMS with 0.2 confident threshold.</li>\n<li>Combine all 9 predictions to get final result using a version of 3D Weighted Box Fusion, using L2 distance for clustering and <code>conf_mode='avg'</code></li>\n</ul>\n<p>I didn't conduct many experiments with ensemble methods before, and decided to use WBF on the two last days, just believe that it could survive some very strong peak signal from one single model, thus improve Recall.</p>\n<h1>Source code</h1>\n<ul>\n<li><strong>Training code</strong>: <a href=\"https://github.com/dangnh0611/kaggle_byu\" target=\"_blank\">https://github.com/dangnh0611/kaggle_byu</a></li>\n<li><strong>Inference notebook</strong>:  <a href=\"https://www.kaggle.com/code/dangnh0611/3rd-place-solution-submit\" target=\"_blank\">https://www.kaggle.com/code/dangnh0611/3rd-place-solution-submit</a></li>\n</ul>\n<p>Thanks for your attention !</p>",
  "messages": [
    {
      "id": "3218612",
      "postDate": "06/06/2025 12:31:22",
      "content": "<p>Many thanks to the competition host and Kaggle for another engaging challenge - and big congratulations to all the participants!<br>\nThank you to every one who actively shared insights and notebooks to the community, especially <a href=\"https://www.kaggle.com/brendanartley\" target=\"_blank\">@brendanartley</a> for <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921\" target=\"_blank\">the external dataset</a>.</p>\n<p>As always, I had a great time learning throughout the competition and was fortunate enough to finish in the gold zone. I'm really happy to share a few thoughts here.</p>\n<h1>TLDR;</h1>\n<ul>\n<li>I'm focus on the 3D/2.5D/2D UNet approaches. The final submission PB 86.6 was an ensemble of 5 models: <code>3D Resnext50 (2xTTA)</code>, <code>3D Densenet121 (2xTTA)</code>, <code>3D X3D-M (2xTTA)</code>, <code>2D MaxViT (2xTTA)</code> and <code>2D CoaT (1xTTA)</code></li>\n<li>3D model use patch size of <code>(224, 448, 448)</code> while 2D use <code>(3, 896, 896)</code> patch size to incorporate enough global context.</li>\n<li>Gaussian heatmap with <code>stride=16</code> and <code>sigma=200A</code> for uncertainty modeling</li>\n<li>Simple FPN neck to fuse low-level features into stride-16-feature map</li>\n<li>All final selected models are trained on combine of train dataset + external dataset without local validation</li>\n<li>Heavy augmentations allowed to train for more epochs and be more robust to domain/distribution change.</li>\n<li>Pseudo labeling on train+external dataset, with manual review</li>\n<li>Resample all tomograms to 16A voxel spacing + Sliding window patches inference</li>\n<li>Simple BCE loss works best</li>\n<li>Weighted Box Fusion (WBF) to ensemble multiple model/TTA ’s predictions</li>\n</ul>\n<p><strong>What not work</strong></p>\n<ul>\n<li>Other losses: MSE, L1, Weighted BCE, Focal, Tversky, Combine multiple losses</li>\n</ul>\n<h1>Table of Contents</h1>\n<ul>\n<li><a href=\"#validation-strategy\">Validation strategy</a></li>\n<li><a href=\"#3d-unet-approach\">3D-UNet Approach</a><ul>\n<li><a href=\"#data-processing\">Data processing</a></li>\n<li><a href=\"#3d-augmentation\">3D augmentation</a></li>\n<li><a href=\"#3d-target-heatmap\">3D target heatmap</a></li>\n<li><a href=\"#3d-modeling\">3D modeling</a></li>\n<li><a href=\"#3d-encoder\">3D Encoder</a></li>\n<li><a href=\"#25d-encoder\">2.5D Encoder</a></li>\n<li><a href=\"#some-results\">Some results</a></li></ul></li>\n<li><a href=\"#2d-unet-approach\">2D-UNet Approach</a><ul>\n<li><a href=\"#data-processing-1\">Data processing</a></li>\n<li><a href=\"#2d-augmentation\">2D augmentation</a></li>\n<li><a href=\"#2d-target-heatmap\">2D target heatmap</a></li>\n<li><a href=\"#2d-modeling\">2D modeling</a></li></ul></li>\n<li><a href=\"#external-dataset--pseudo-label\">External dataset &amp; Pseudo label</a></li>\n<li><a href=\"#postprocessing\">Postprocessing</a></li>\n<li><a href=\"#source-code\">Source code</a></li>\n</ul>\n<hr>\n<h1>Validation strategy</h1>\n<p>A simple 4-StratifiedKfold by number of motors was conducted on train dataset. Validation samples are augmented by Rotate90+Flip (16 posible combinations) to increase the number of samples, making validation metrics more stable and trustworthy to track.</p>\n<p>Experiment progress was tracked using mainly F2 score and additionally Partial Average Precision, cutoff at a specific Recall, e.g &gt; 0.9. The second is not a binary metric so more stable and less sentitive to threshold, strong correlate with F2. Single-fold local CV was the main factor to select models and hyperparameters.</p>\n<p><strong>Train on all data, validate on LB:</strong> However, when my pipeline are bug-free and going better (when CV &gt; 0.96 and LB &gt; 0.8), CV is just saturated and not correlate to LB well. So at the later stage, CV is just used to guide a reasonable value of hyperparameter (mainly learning rate). In last week, all models were trained on all dataset without local validation and are submitted to LB.</p>\n<h1>3D-UNet Approach</h1>\n<h2>Data processing</h2>\n<p>I resample tomogram to a fixed voxel spacing of 16.0, use sliding windows (patching) technique, split tomogram to <code>224x448x448</code> (possibly overlap) patches as input to models to obtain <strong>16x</strong> downscaled prediction heatmap of shape <code>14x28x28</code>.  <br>\nThe labeling coordinate is a little bit noisy/uncertain, also the tolerance radius is high, so using high heatmap resolution seem to be redundant, significantly increase the computation. <code>Radius of 1000A</code> == <code>62.5 voxels in input volume</code> == <code>4 voxels in prediction heatmap</code>. I've experimented with prediction heatmap of <code>8x</code> and <code>32x</code> downscaled heatmap and found that <code>8x</code> ~ <code>16x</code> &gt;&gt; <code>32x</code>. To the end, I use <code>16x</code> downscaled heatmap for both 3D and 2D models.</p>\n<p><strong>Optimal voxel spacing is ~16.0</strong>  <br>\nFor this task, resampling to a suitable voxel spacing is important. Using small voxel spacing keep the very small details but computation intensive due to high resolution, in contrast large voxel spacing could erase out all the textures needed for detection. Experiment results confirm that very small detail/textures are matter:</p>\n<ul>\n<li>Tried various target voxel spacing 12, 16, 20, 24, 32, 64; found that CV degrade significantly when spacing &gt; 32. LB also degrade much faster right after spacing &gt; 20. I found 16.0 is the best balance value, matching with the typical <a href=\"https://github.com/MIC-DKFZ/nnUNet/issues/2701\" target=\"_blank\">median spacing</a> default practice of nnUnet (median spacing is 15.6 for the train dataset).</li>\n<li>Resampling interpolation mode (LINEAR, AREA, NEAREST) also could significantly affect performance. All tomograms are also noisy (Gaussian noise?) so resize/interpolation could erase thing in an unexpected manner.</li>\n</ul>\n<p><strong>The mean original voxel spacing in LB/PB is 13.1 ???</strong>  <br>\nHaha, just my stupid guess work and there're no way I could confirm. I trained model with <code>target_spacing=16.0</code> and submit to LB with different of assumtions about the original spacing. <code>13.1</code> brings the highest LB. Given that test samples have higher resolution (<code>~400x1400x1050</code> in average) and 44.7% of train samples have <code>voxel_spacing=13.1</code>, <strong>I safely assume that the average test voxel spacing is ~13.1, and resample to 16.0 for all experiments</strong></p>\n<blockquote>\n  <p>I tried to quickly train a voxel spacing prediction model on the last day, but failed, metric is about <code>MAE=5.1</code>, can't predict any useful thing :D</p>\n</blockquote>\n<p>Each sample is dominated by negative voxels, so I try to control the number of positive/negative patches ratio in the training progress. For 3D approach, the positive/negative samples ratio is 20/1.</p>\n<h2>3D augmentation</h2>\n<p>For each epoch, all positive samples are ensured to be augmented with all 16 transformations composed of Rotate90+Flip, go after a \"heavy\" MONAI augmentation pipeline. Keypoints passed to MONAI dictionary transform as well so that we get the Affine-transformed keypoints to render the heatmap with larger stride (16 in this case). I really like MONAI's <a href=\"https://docs.monai.io/en/stable/lazy_resampling.html\" target=\"_blank\">Lazy Resampling</a>, which make augmentation and dataloader become fast and efficient.<br>\nVarious MONAI 3D augmentations were used:</p>\n<ul>\n<li>Spatial: <code>RandZoom</code>, <code>RandAffined</code></li>\n<li>Intensity: <code>RandShiftIntensityd</code>, <code>RandStdShiftIntensityd</code>, <code>RandScaleIntensityFixedMeand</code>, <code>RandScaleIntensityd</code>, <code>RandAdjustContrastd</code>, <code>RandHistogramShiftd</code></li>\n<li>Reduce quality: <code>RandSimulateLowResolutiond</code></li>\n<li>Dropout: <code>RandCoarseDropoutKeepKeypoints</code></li>\n</ul>\n<p>Mixup and Cutmix were tried but bring no clear improvement so be ignored in latter experiments.</p>\n<p></p><br>\nPseudo code in Albumentations style<p></p>\n<pre><code> monai  transforms  T\n monai_custom  CT\n\nT.Compose(\n    [\n        \n        T.Spacingd(target_spacing=, p=),\n        CT.CustomCropBySlicesd(),\n        \n        T.RandZoomd(min_zoom=(, , ), max_zoom=(, , ), p=),\n        T.OneOf(\n            [\n                \n                T.RandAffined(\n                    rotate_range=((, ), (, ), (, )),\n                    scale_range=((-, ), (-, ), (-, )),\n                    p=,\n                ),\n                \n                T.RandAffined(\n                    rotate_range=((-, ), (-, ), (, )),\n                    shear_range=((-, ), (-, ), (-, )),\n                    scale_range=((-, ), (-, ), (-, )),\n                    p=,\n                ),\n            ],\n            p=,\n        ),\n        \n        T.OneOf(\n            [\n                T.Compose(\n                    [\n                        \n                        T.OneOf(\n                            [\n                                T.RandShiftIntensityd(offset=(-, ), p=),\n                                T.RandStdShiftIntensityd(factors=(-, ), p=),\n                            ],\n                            p=,\n                        ),\n                        \n                        T.OneOf(\n                            [\n                                \n                                T.RandScaleIntensityFixedMeand(factors=(-, ), p=),\n                                \n                                T.RandScaleIntensityFixedMeand(factors=(, ), p=),\n                            ],\n                            p=,\n                        ),\n                    ],\n                    p=,\n                ),\n                T.RandScaleIntensityd(factors=(-, ), channel_wise=, p=),\n                T.RandAdjustContrastd(gamma=(, ), p=),\n                T.RandHistogramShiftd(num_control_points=(, ), p=),\n            ],\n            p=,\n        ),\n        \n        \n        CT.ApplyTransformToNormalDistributionsd(keys=[]),\n        \n        T.RandSimulateLowResolutiond(\n            zoom_range=(, ), downsample_mode=, upsample_mode=, p=\n        ),\n        CT.RandCoarseDropoutWithKeypointsd(\n            keypoints_key=, holes=, spatial_size=(, , ),\n            dropout_holes=, fill_value=(, ), max_holes=,\n            max_spatial_size=(, , ), prob=\n        )\n    ]\n)\n</code></pre>\n<p></p><p></p>\n<h2>3D target heatmap</h2>\n<p>For a task where real distance/scale could be measured, e.g voxel spacing is known, I prefer a fixed sigma measured in real-life distance. Some advances in bottom-up human pose estimation have introduced techniques to adaptively rescale each keypoint's heatmap. Intuitively, these methods adjust the heatmap’s sigma values based on the size of each person, assigning larger/smaller sigmas (higher/lower uncertainty) to larger/smaller ones (relative to their pixel count/box area).</p>\n<p>I use a sigma value of 200A (0.2 times the radius). Note that going through Spatial augmentations like Spacing, Affine or Zoom, the voxel spacing is changed so the value of sigma used in rendering the heatmap is not a fixed number of voxels, but rather <code>0.2 * 1000 / cur_voxel_spacing</code>.  <br>\nA custom implementation of <a href=\"https://docs.monai.io/en/stable/transforms.html#applytransformtopoints\" target=\"_blank\">ApplyTransformToPoints</a> was implemented to apply Affine Transform to a Normal distribution, result in another Normal distribution to render on the target heatmap volume. Hence, instead of a ball-like shape, each keypoint looks like a rotated ellipsoid.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fd2d3ba10849048534fcc91ff265ac800%2F3d_heatmap.png?generation=1749212341383311&amp;alt=media\" alt=\"\"></p>\n<p>Does this make sense? Maybe not that significant. It's just an assumption that the likelihood of a motor <strong>strictly</strong> follows a 3D Normal distribution in the original tomogram, which is not likely for this task. Later, I decided to use a ball-like-shape Gaussian heatmap, with diagonal covariance matrix and single sigma for all 3 dimensions ZYX to make life easier for my model (just need to predict the mean and scale, intuitively).</p>\n<h2>3D modeling</h2>\n<p>3D-UNet with various 3D/2.5D Encoder was tried and be used in final submision. Taking input as a 3D volume of shape <code>(Z, Y, X) = (224, 448, 448)</code>, all model predict the output heatmap of shape <code>(Z/16, Y/16, X/16) = (14, 28, 28)</code>.  <br>\n3D-UNet Decoder borrowed from <a href=\"https://github.com/ZFTurbo/segmentation_models_pytorch_3d\" target=\"_blank\">segmentation_models_pytorch_3d</a> was tweak to output multiscale heatmaps for ultilizing deep supervision. Since for this task, finest heatmap has stride 16 right after the coarsest one of 32 (not too deep), deep supervision seem to be redundant experimentally, later I just supervise the 1/16 heatmap only.</p>\n<h3>3D Encoder</h3>\n<p>I mainly tweak and use models from either <a href=\"https://github.com/ZFTurbo/timm_3d\" target=\"_blank\">timm3d</a> or <a href=\"https://github.com/facebookresearch/SlowFast\" target=\"_blank\">SlowFast</a>. By default, video models like X3D and I3D in SlowFast did not use temporal pooling, keeping the temporal dim unchanged to keep as much as temporal resolution as posible. For this task, this is unnecessary, I make change so that depth (temporal in video term) is progressively downsampled by 2 at each stage, synchronized with spatial downsampling too. Now, all 3D models have the same interface, output at least 4 (usually 5) levels of feature map at stride 1/4, 1/8, 1/16, 1/32.  <br>\nAs discuss above, we need high resolution (small voxel spacing) for keeping very little details/textures. Standard UNet ultilize and fuse low-level/fine features via skip connection to the same output level. However, UNet with output stride of 16 is not actually U-shape now, hence there're no shorter path to propagate the low-level/fine features (rich texture/localization) to the high-level/coarse output heatmap (rich semantic). Motivated by this, I add necks to fuse low-level features to the coarse output heatmap head, efficiently improve information propagation through the network. Two types of necks were tried:</p>\n<ul>\n<li>Simple 3D FPN Neck: motivated by <a href=\"https://www.kaggle.com/competitions/google-research-identify-contrails-reduce-global-warming/discussion/430491\" target=\"_blank\">this writeup</a> and <a href=\"http://presentations.cocodataset.org/COCO17-Stuff-FAIR.pdf\" target=\"_blank\">Unified FPN architecture</a>. Simply interpolate features at all level to match spatial shape at stride 1/16, concatenate them all then passed to the UNet Decoder block</li>\n<li><a href=\"https://arxiv.org/abs/1803.01534\" target=\"_blank\">3D PAN (Path Aggregation Network)</a>: Instead of just adding top-down path augmentation as in <a href=\"https://arxiv.org/pdf/1612.03144\" target=\"_blank\">FPN</a> to fuse rich semantic features to low-level features at finer scale, we now add another bottom-up path augmentation to fuse rich texture/detail features to the coarser one as well.</li>\n</ul>\n<p>It's clear that Simple FPN is a \"more straight\" way to achieve our goal. Not putting much time on this, but my experiment results also confirm that Simple FPN is usually better than PAN. So all my final models were additionally equipped with this Simple FPN neck, illustrated below.  </p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2F2f2cab849715f0dd2bf2b2a93fe04e8a%2F3d_unet_fpn.jpg?generation=1749212388256232&amp;alt=media\" alt=\"\"></p>\n<h3>2.5D Encoder</h3>\n<p>I'm also working with 2.5D encoder, combined with FPN neck and 3D UNet decoder too. The idea is very simple. First we sample a list of 2D 3-channels image by sliding over the depth (Z) dimension with stride of 2, effectively treat one volume as a batch of 2D images. At each stage of 2D backbone, spatial resolution is downsampled by 2 by default, we also down sample the depth (now treated as batch) by a factor of 2 too by either <code>AvgPool3d((2, 1, 1))</code> or <code>Conv3d(stride=(2, 1, 1))</code>. The first one was better and easier to train since it keep tensor statistics unchanged, better leveraging transfer learning from pretrained 2D backbone. It shows good score if no external dataset was used, significantly reduce overhead at middle/latter stages, being lighweight and helps model effectively \"see\" all 2D slices of a volume. It's okay for 2D convolution-only backbone such as EfficientNet or ConvNeXT which the heavy computation goes in middle or latter stages, but not for backbone which use Transformer in early stage cause of the high resolution and very large \"fake batch size\"</p>\n<p>Also played with somethings like injecting LSTM layers after obtained 2D features, CV may go up but LB just unchanged. Perhap Conv3d in FPN neck and UNet Decoder was better fusing slicewise features, additional mixing is not necessary anymore.</p>\n<h3>Some results</h3>\n<p>I report LB/PB score of single model (single checkpoint). These models are:</p>\n<ul>\n<li>Trained on all data (train + external)</li>\n<li>2xTTA: original + rotate 90 along XY plane</li>\n<li>Threshold is the quantile at 55% over all prediction confident scores.</li>\n</ul>\n<p>I start using external data a little bit late, so just 1 last checkpoint of them was submitted to obtain LB score with very limitted tuning. Each model's runtime is not exceed 3h.</p>\n<table>\n<thead>\n<tr>\n<th><strong>Model</strong></th>\n<th><strong>Params</strong></th>\n<th><strong>Private LB</strong></th>\n<th><strong>Public LB</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>3D RESNEXT50</td>\n<td>49.9M</td>\n<td><strong>86.2</strong></td>\n<td>85.8</td>\n</tr>\n<tr>\n<td>3D DENSENET121</td>\n<td>27.6M</td>\n<td>85.7</td>\n<td><strong>86.0</strong></td>\n</tr>\n<tr>\n<td>X3DM + FPN</td>\n<td>10.4M</td>\n<td>84.6</td>\n<td>84.8</td>\n</tr>\n<tr>\n<td>X3DM + PAN + MIXUP</td>\n<td>6.9M</td>\n<td>84.6</td>\n<td>84.5</td>\n</tr>\n<tr>\n<td>X3DL</td>\n<td>12.8M</td>\n<td>83.9</td>\n<td>85.2</td>\n</tr>\n<tr>\n<td>3D RESNET50</td>\n<td>70.3M</td>\n<td>83.0</td>\n<td>85.4</td>\n</tr>\n<tr>\n<td><em>2.5D CONVNEXT_TINY</em></td>\n<td>40.3M</td>\n<td>82.4</td>\n<td>83.7</td>\n</tr>\n<tr>\n<td><em>2D COAT_LITE_MEDIUM</em></td>\n<td>99.6M</td>\n<td>83.3</td>\n<td>84.8</td>\n</tr>\n<tr>\n<td><em>2D MAXVIT_TINY</em></td>\n<td>86.1M</td>\n<td>82.5</td>\n<td>85.4</td>\n</tr>\n</tbody>\n</table>\n<h1>2D-UNet Approach</h1>\n<p>2D approach was in my plan, but it becomes urgent since I feel that may be many high score public LB may using 2D approaches, especially YOLO. 2 weeks before competition deadline, I started working on 2D modeling and inherrit many details from the 3D one. Training 2D models was much more resource efficient and much faster compared to 3D.</p>\n<h2>Data processing</h2>\n<p>The same target voxel spacing of 16.0 was used too, i.e I resize each 2D slice so that XY spacing is 16A. Of course augmentations could scale this spacing later, but same as 3D approach, sigma of each keypoint is rescaled accordingly so that final sigma is fixed at a real distance of 200A.<br>\nIn inference stage, I load the full volume on RAM then perform LINEAR interpolation to fixed voxel spacing of <code>(Z, Y, X) = (32, 16, 16)</code>, that say sampling along Z (depth) is more sparse. This helps reduce inference cost without too much performance decregation. I use 3 channels image of size <code>(896, 896)</code> as input to 2D-UNet model.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fa69bde60b2bcc2eff12633f2a3e5ea08%2F2d_pipeline.jpg?generation=1749212467165435&amp;alt=media\" alt=\"\"></p>\n<p>In training stage, different from 3D pipeline where I load whole volume to RAM then using MONAI's lazy resampling (using F.grid_sample internally), 2D pipeline just sample a specific value of Z (following truncated normal distribution with scale of 1 sigma) then load 3 nearby images per sample (spacing~32A) from disk, which is pretty efficient. Although 2D approach is more sample efficient to reach a medium score, controlling the positive/negative samples ratio is still important so that model could see more negative slices over all data domain/distribution rather than just the \"motor’s neighbor\" slices. I started experimenting with negative/positive ratio equal to 0.25, latter found that CV improved quite significantly when set it to 0.75, but not enough submissions to confirm that on LB.</p>\n<h2>2D augmentation</h2>\n<p><a href=\"https://github.com/albumentations-team/albumentations\" target=\"_blank\">Albumentations</a> was used to create augmentation pipeline, with ability to handle and keep track of keypoints scaling as well, which is pretty neat.<br>\n</p><br>\n 2D augmentation code <p></p>\n<pre><code>TARGET_SPACING = \nPATCH_SIZE = (, )\n\n ():\n    \n     params\n       data  (data[]) &gt; :\n        kpt = random.choice(data[])\n        \n         (kpt) == \n        x, y = kpt[:]\n        scale = kpt[]\n         [\n            x - margin_xy[] * scale,\n            y - margin_xy[] * scale,\n            x + margin_xy[] * scale,\n            y + margin_xy[] * scale,\n        ]\n    :\n         \n\n\naugment_transform = A.Compose(\n    [\n        \n        A.HorizontalFlip(p=),\n        A.VerticalFlip(p=),\n        \n        A.OneOf(\n            [\n                A.GaussNoise(\n                    var_limit=(, ),\n                    mean=,\n                    per_channel=,\n                    noise_scale_factor=,\n                    p=,\n                ),\n                A.MultiplicativeNoise(\n                    multiplier=(, ),\n                    per_channel=,\n                    elementwise=,\n                    p=,\n                ),\n            ],\n            p=,\n        ),\n        \n        A.OneOf(\n            [\n                \n                A.OneOf(\n                    [\n                        A.Downscale(\n                            scale_range=(, ),\n                            interpolation_pair={\n                                : cv2.INTER_LANCZOS4,\n                                : cv2.INTER_AREA,\n                            },\n                            p=,\n                        ),\n                        A.Downscale(\n                            scale_range=(, ),\n                            interpolation_pair={\n                                : cv2.INTER_LINEAR,\n                                : cv2.INTER_AREA,\n                            },\n                            p=,\n                        ),\n                        A.Downscale(\n                            scale_range=(, ),\n                            interpolation_pair={\n                                : cv2.INTER_LINEAR,\n                                : cv2.INTER_LINEAR,\n                            },\n                            p=,\n                        ),\n                    ],\n                    p=,\n                ),\n                A.ImageCompression(\n                    compression_type=, quality_range=(, ), p=\n                ),\n                A.Posterize(num_bits=(, ), p=),\n            ],\n            p=,\n        ),\n        \n        A.OneOf(\n            [\n                \n                A.Emboss(alpha=(, ), strength=(, ), p=),\n                A.Sharpen(alpha=(, ), lightness=(, ), p=),\n                A.CLAHE(clip_limit=, tile_grid_size=(, ), p=),\n            ],\n            p=,\n        ),\n        \n        A.OneOf(\n            [\n                A.OneOf(\n                    [\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(-, ),\n                            contrast_limit=(-, ),\n                            brightness_by_max=,\n                            p=,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(, ),\n                            contrast_limit=(-, ),\n                            brightness_by_max=,\n                            p=,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(-, ),\n                            contrast_limit=(, ),\n                            brightness_by_max=,\n                            p=,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(, ),\n                            contrast_limit=(, ),\n                            brightness_by_max=,\n                            p=,\n                        ),\n                    ],\n                    p=,\n                ),\n                A.RandomToneCurve(scale=, per_channel=, p=),\n                A.RandomGamma(gamma_limit=(, ), p=),\n            ],\n            p=,\n        ),\n        \n        A.OneOf(\n            [\n                \n                A.Affine(\n                    scale={: (, ), : (, )},\n                    translate_percent=,\n                    rotate=(, ),\n                    shear={: (-, ), : (-, )},\n                    interpolation=cv2.INTER_LINEAR,\n                    cval=,\n                    mode=cv2.BORDER_CONSTANT,\n                    fit_output=,\n                    keep_ratio=,\n                    balanced_scale=,\n                    p=,\n                ),\n                \n                A.Affine(\n                    scale={: (, ), : (, )},\n                    translate_percent=,\n                    rotate=(, ),\n                    shear={: (-, ), : (-, )},\n                    interpolation=cv2.INTER_LINEAR,\n                    cval=,\n                    mode=cv2.BORDER_CONSTANT,\n                    fit_output=,\n                    keep_ratio=,\n                    balanced_scale=,\n                    p=,\n                ),\n                A.Perspective(\n                    scale=(, ),\n                    keep_size=,\n                    pad_mode=cv2.BORDER_CONSTANT,\n                    pad_val=,\n                    fit_output=,\n                    interpolation=cv2.INTER_LINEAR,\n                    p=,\n                ),\n            ],\n            p=,\n        ),\n        \n        AC.CustomRandomSizedBBoxSafeCrop(\n            crop_size=PATCH_SIZE,\n            scale=(, ),  \n            ratio=(, ),  \n            get_bbox_func=partial(\n                _byu_get_safe_bbox,\n                margin_xy=(\n                     / TARGET_SPACING,\n                     / TARGET_SPACING,\n                ),\n            ),\n            retry=,\n            p=,\n        ),\n        A.PadIfNeeded(\n            *PATCH_SIZE,\n            position=,\n            border_mode=cv2.BORDER_CONSTANT,\n            value=,\n            p=,\n        ),\n    ],\n    p=,\n)\n</code></pre>\n<p></p><p></p>\n<h2>2D target heatmap</h2>\n<p>There're many ways to render target heatmap. I do a sweep over 4 kinds of heatmap type with different parameters, and found that the best CV was Gaussian heatmap with peak confident and radius scales smaller when goes far from the Z groudtruth coordinate, same as if we render 3D Gaussian heatmap then take a 2D slice over. <code>adaptive scaling</code> &gt; <code>min=0, max=1</code> &gt;  <code>segment</code> &gt; <code>point</code>, which support more that we need to modeling uncertainty better.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fd53a89634e4065110b61d729a2736fda%2F2d_heatmap_types_compressed.jpg?generation=1749212650075518&amp;alt=media\" alt=\"\"></p>\n<h2>2D modeling</h2>\n<p>The model used for 2D modeling was adapted from <a href=\"https://www.kaggle.com/competitions/google-research-identify-contrails-reduce-global-warming/discussion/430491\" target=\"_blank\">this Google Contrails solution writeup</a>, thank authors very much for public their source code. It contains an image encoder, a simple FPN neck to fuse low-level features to <code>/16</code> feature map, and an UNet Decoder with Pixel Shuffle block.  <br>\nI mainly experiment with different types of 2D encoders including EfficientNet, ConvNeXt, CoaT and MaxViT. No supprising, the Hierachical Convolution-Transformer backbones were perform much better than convolution only, indicated that global context matter and we need a sufficient spatial size for obtaining enough context. In selected final submission, I use 2 2D-UNet models with timm backbones: <code>maxvit_tiny_tf_512.in1k</code> and <code>coat_lite_medium_384.in1k</code>.</p>\n<h1>External dataset &amp; Pseudo label</h1>\n<p>Again, I must thank <a href=\"https://www.kaggle.com/brendanartley\" target=\"_blank\">@brendanartley</a> for <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921\" target=\"_blank\">the external dataset</a>, especially in early stage of the competition, and <a href=\"https://www.kaggle.com/tatamikenn\" target=\"_blank\">@tatamikenn</a> for <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/575028\" target=\"_blank\">his note</a> on wrong-quantized tomograms.</p>\n<p>I started diving into that external dataset to train my final submission a week ago, after ensured that my training/inference pipeline is good enough and bug-free (no?). Without external data, I have a simple ensemble of 3D/2.5D UNet with <code>PB 0.851 and LB 0.852</code>.</p>\n<p><a href=\"https://www.kaggle.com/brendanartley\" target=\"_blank\">@brendanartley</a>'s <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921\" target=\"_blank\">dataset</a> was good, but seem not to be directly usable for my pipeline because of slicing and resizing to a fixed shape and losing voxel spacing information. So I tweak the download script to get my own new version, improve it by pseudo labeling+manual review:</p>\n<ol>\n<li>Keep track the voxel spacing and other metadata as well</li>\n<li>Re-spacing using <code>F.interpolate()</code> to <code>target_spacing=max(16.0, ori_spacing)</code> to save disk space and reduce dataloader overhead in the future. This results in a new <strong>180GB</strong> external dataset.</li>\n<li>Convert his labels to fit my new version. This could loses some precision of Z coordinate.</li>\n<li>Use my trained models to inference on these data with low confident threshold of <code>0.05</code>. Comparing model prediction results with converted GT to identify TP/TN/FP/FN motors.</li>\n<li>For TP, simply trust model prediction results rather than the converted GT coordinates</li>\n<li>Use <a href=\"https://github.com/cvat-ai/cvat\" target=\"_blank\">CVAT</a> to review and label 188 FN and 55 FP. Labeling it is simple and easy as a tagging process, where the labeler (me) just consider whether a sample is wrong or correct, not finding new ones. Almost all FN are correct (?), meaning models are missing so many motors. Labeling these confused cases is hard, but the F2 metric and \"believe in AI models\" punishs me to be bias and may label more to positives than usual, especially one without flagellum. This could cause inconsistances with public/private test since host <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/579540#3217068\" target=\"_blank\">had clarified</a> that “more than 80% of the tomograms containing no flagellum the motor is not marked”</li>\n<li>With same strategy, I also try to correct the labeling errors on the train dataset as well.</li>\n<li>BOOM! Just re-train models on all dataset with previously discovered hyperparameters</li>\n</ol>\n<p>Adding so many external data change the game significantly. Training are more stable and allow for much longer training without overfiting. Though more tunings on new setup are needed, however, the deadline is quickly approaching its final days..</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2F715d399576ca0db1d6ff68d78b522fd1%2Fcvat.png?generation=1749212703333308&amp;alt=media\" alt=\"\"></p>\n<h1>Postprocessing</h1>\n<p>As usual, I'm not use a fixed threshold since it's largely change between submitted models to the LB. Instead, just use quantile thresholding at 55% (55% lowest threshold samples are predicted as negative, the remain 45% ones predicted as positive) which is adaptively changed based on model's confident distribution, so just one LB submission is enough to estimate the best F2 score.</p>\n<p>The postprocessing process:</p>\n<ul>\n<li>I use 5 models combined with TTAs, output 9 different heatmaps</li>\n<li>Not using heatmap averaging, instead, decode each heatmap into list of candidate keypoints using NMS with 0.2 confident threshold.</li>\n<li>Combine all 9 predictions to get final result using a version of 3D Weighted Box Fusion, using L2 distance for clustering and <code>conf_mode='avg'</code></li>\n</ul>\n<p>I didn't conduct many experiments with ensemble methods before, and decided to use WBF on the two last days, just believe that it could survive some very strong peak signal from one single model, thus improve Recall.</p>\n<h1>Source code</h1>\n<ul>\n<li><strong>Training code</strong>: <a href=\"https://github.com/dangnh0611/kaggle_byu\" target=\"_blank\">https://github.com/dangnh0611/kaggle_byu</a></li>\n<li><strong>Inference notebook</strong>:  <a href=\"https://www.kaggle.com/code/dangnh0611/3rd-place-solution-submit\" target=\"_blank\">https://www.kaggle.com/code/dangnh0611/3rd-place-solution-submit</a></li>\n</ul>\n<p>Thanks for your attention !</p>",
      "rawMarkdown": "Many thanks to the competition host and Kaggle for another engaging challenge - and big congratulations to all the participants!\nThank you to every one who actively shared insights and notebooks to the community, especially @brendanartley for [the external dataset](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921).\n\nAs always, I had a great time learning throughout the competition and was fortunate enough to finish in the gold zone. I'm really happy to share a few thoughts here.\n  \n# TLDR;\n- I'm focus on the 3D/2.5D/2D UNet approaches. The final submission PB 86.6 was an ensemble of 5 models: `3D Resnext50 (2xTTA)`, `3D Densenet121 (2xTTA)`, `3D X3D-M (2xTTA)`, `2D MaxViT (2xTTA)` and `2D CoaT (1xTTA)`\n- 3D model use patch size of `(224, 448, 448)` while 2D use `(3, 896, 896)` patch size to incorporate enough global context.\n- Gaussian heatmap with `stride=16` and `sigma=200A` for uncertainty modeling\n- Simple FPN neck to fuse low-level features into stride-16-feature map\n- All final selected models are trained on combine of train dataset + external dataset without local validation\n- Heavy augmentations allowed to train for more epochs and be more robust to domain/distribution change.\n- Pseudo labeling on train+external dataset, with manual review\n- Resample all tomograms to 16A voxel spacing + Sliding window patches inference\n- Simple BCE loss works best\n- Weighted Box Fusion (WBF) to ensemble multiple model/TTA ’s predictions\n\n\n**What not work**\n- Other losses: MSE, L1, Weighted BCE, Focal, Tversky, Combine multiple losses\n\n\n\n\n# Table of Contents\n\n\n- [Validation strategy](#validation-strategy)\n- [3D-UNet Approach](#3d-unet-approach)\n  - [Data processing](#data-processing)\n  - [3D augmentation](#3d-augmentation)\n  - [3D target heatmap](#3d-target-heatmap)\n  - [3D modeling](#3d-modeling)\n    - [3D Encoder](#3d-encoder)\n    - [2.5D Encoder](#25d-encoder)\n    - [Some results](#some-results)\n- [2D-UNet Approach](#2d-unet-approach)\n  - [Data processing](#data-processing-1)\n  - [2D augmentation](#2d-augmentation)\n  - [2D target heatmap](#2d-target-heatmap)\n  - [2D modeling](#2d-modeling)\n- [External dataset & Pseudo label](#external-dataset--pseudo-label)\n- [Postprocessing](#postprocessing)\n- [Source code](#source-code)\n\n\n---\n\n# Validation strategy\nA simple 4-StratifiedKfold by number of motors was conducted on train dataset. Validation samples are augmented by Rotate90+Flip (16 posible combinations) to increase the number of samples, making validation metrics more stable and trustworthy to track.\n\nExperiment progress was tracked using mainly F2 score and additionally Partial Average Precision, cutoff at a specific Recall, e.g > 0.9. The second is not a binary metric so more stable and less sentitive to threshold, strong correlate with F2. Single-fold local CV was the main factor to select models and hyperparameters.\n\n**Train on all data, validate on LB:** However, when my pipeline are bug-free and going better (when CV > 0.96 and LB > 0.8), CV is just saturated and not correlate to LB well. So at the later stage, CV is just used to guide a reasonable value of hyperparameter (mainly learning rate). In last week, all models were trained on all dataset without local validation and are submitted to LB.\n\n\n# 3D-UNet Approach\n\n\n## Data processing\n\nI resample tomogram to a fixed voxel spacing of 16.0, use sliding windows (patching) technique, split tomogram to `224x448x448` (possibly overlap) patches as input to models to obtain **16x** downscaled prediction heatmap of shape `14x28x28`.  \nThe labeling coordinate is a little bit noisy/uncertain, also the tolerance radius is high, so using high heatmap resolution seem to be redundant, significantly increase the computation. `Radius of 1000A` == `62.5 voxels in input volume` == `4 voxels in prediction heatmap`. I've experimented with prediction heatmap of `8x` and `32x` downscaled heatmap and found that `8x` ~ `16x` >> `32x`. To the end, I use `16x` downscaled heatmap for both 3D and 2D models.\n\n**Optimal voxel spacing is ~16.0**  \nFor this task, resampling to a suitable voxel spacing is important. Using small voxel spacing keep the very small details but computation intensive due to high resolution, in contrast large voxel spacing could erase out all the textures needed for detection. Experiment results confirm that very small detail/textures are matter:\n\n- Tried various target voxel spacing 12, 16, 20, 24, 32, 64; found that CV degrade significantly when spacing > 32. LB also degrade much faster right after spacing > 20. I found 16.0 is the best balance value, matching with the typical [median spacing](https://github.com/MIC-DKFZ/nnUNet/issues/2701) default practice of nnUnet (median spacing is 15.6 for the train dataset).\n- Resampling interpolation mode (LINEAR, AREA, NEAREST) also could significantly affect performance. All tomograms are also noisy (Gaussian noise?) so resize/interpolation could erase thing in an unexpected manner.\n\n**The mean original voxel spacing in LB/PB is 13.1 ???**  \nHaha, just my stupid guess work and there're no way I could confirm. I trained model with `target_spacing=16.0` and submit to LB with different of assumtions about the original spacing. `13.1` brings the highest LB. Given that test samples have higher resolution (`~400x1400x1050` in average) and 44.7% of train samples have `voxel_spacing=13.1`, **I safely assume that the average test voxel spacing is ~13.1, and resample to 16.0 for all experiments**\n> I tried to quickly train a voxel spacing prediction model on the last day, but failed, metric is about `MAE=5.1`, can't predict any useful thing :D\n\nEach sample is dominated by negative voxels, so I try to control the number of positive/negative patches ratio in the training progress. For 3D approach, the positive/negative samples ratio is 20/1.\n\n\n## 3D augmentation\n\nFor each epoch, all positive samples are ensured to be augmented with all 16 transformations composed of Rotate90+Flip, go after a \"heavy\" MONAI augmentation pipeline. Keypoints passed to MONAI dictionary transform as well so that we get the Affine-transformed keypoints to render the heatmap with larger stride (16 in this case). I really like MONAI's [Lazy Resampling](https://docs.monai.io/en/stable/lazy_resampling.html), which make augmentation and dataloader become fast and efficient.\nVarious MONAI 3D augmentations were used:\n- Spatial: `RandZoom`, `RandAffined`\n- Intensity: `RandShiftIntensityd`, `RandStdShiftIntensityd`, `RandScaleIntensityFixedMeand`, `RandScaleIntensityd`, `RandAdjustContrastd`, `RandHistogramShiftd`\n- Reduce quality: `RandSimulateLowResolutiond`\n- Dropout: `RandCoarseDropoutKeepKeypoints`\n\nMixup and Cutmix were tried but bring no clear improvement so be ignored in latter experiments.\n\n<details>\n<summary>Pseudo code in Albumentations style</summary>\n\n```python\nfrom monai import transforms as T\nimport monai_custom as CT\n\nT.Compose(\n    [\n        # =========START OF LAZY RESAMPLING=========\n        T.Spacingd(target_spacing=16.0, p=1.0),\n        CT.CustomCropBySlicesd(),\n        # SPATIAL TRANSFORM\n        T.RandZoomd(min_zoom=(0.6, 0.6, 0.6), max_zoom=(1.2, 1.2, 1.2), p=0.4),\n        T.OneOf(\n            [\n                # Affine 1: slight, focus on XY dims\n                T.RandAffined(\n                    rotate_range=((0, 0), (0, 0), (0, 360)),\n                    scale_range=((-0.3, 0.3), (-0.3, 0.3), (-0.3, 0.3)),\n                    p=0.67,\n                ),\n                # Affine2: heavier, focus on all XYZ dims\n                T.RandAffined(\n                    rotate_range=((-15, 15), (-15, 15), (0, 360)),\n                    shear_range=((-0.2, 0.2), (-0.2, 0.2), (-0.2, 0.2)),\n                    scale_range=((-0.3, 0.3), (-0.3, 0.3), (-0.3, 0.3)),\n                    p=0.33,\n                ),\n            ],\n            p=0.75,\n        ),\n        # INTENSITY TRANSFORM\n        T.OneOf(\n            [\n                T.Compose(\n                    [\n                        # mean shift\n                        T.OneOf(\n                            [\n                                T.RandShiftIntensityd(offset=(-40, 80), p=0.5),\n                                T.RandStdShiftIntensityd(factors=(-0.7, 1.2), p=0.5),\n                            ],\n                            p=0.3,\n                        ),\n                        # std/contrast scale (multiplicative)\n                        T.OneOf(\n                            [\n                                # decrease std/contrast -> harder sample -> higher prob\n                                T.RandScaleIntensityFixedMeand(factors=(-0.6, 0), p=0.7),\n                                # increase std/contrast -> easier sample -> lower prob\n                                T.RandScaleIntensityFixedMeand(factors=(0, 0.8), p=0.3),\n                            ],\n                            p=0.3,\n                        ),\n                    ],\n                    p=0.4,\n                ),\n                T.RandScaleIntensityd(factors=(-0.7, 0.7), channel_wise=True, p=0.2),\n                T.RandAdjustContrastd(gamma=(0.25, 1.75), p=0.3),\n                T.RandHistogramShiftd(num_control_points=(10, 20), p=0.1),\n            ],\n            p=0.5,\n        ),\n        # =========END OF LAZY RESAMPLING=========\n        # apply Affine Transform to keypoints (normal distributions) as well\n        CT.ApplyTransformToNormalDistributionsd(keys=[\"keypoints\"]),\n        # with varius interpolation mode pairs, simplified for readability\n        T.RandSimulateLowResolutiond(\n            zoom_range=(0.6, 0.9), downsample_mode=\"*\", upsample_mode=\"*\", p=0.2\n        ),\n        CT.RandCoarseDropoutWithKeypointsd(\n            keypoints_key=\"keypoints\", holes=3, spatial_size=(30, 30, 30),\n            dropout_holes=True, fill_value=(0, 255), max_holes=8,\n            max_spatial_size=(125, 125, 125), prob=0.1\n        )\n    ]\n)\n```\n</details>\n\n\n\n\n\n## 3D target heatmap\nFor a task where real distance/scale could be measured, e.g voxel spacing is known, I prefer a fixed sigma measured in real-life distance. Some advances in bottom-up human pose estimation have introduced techniques to adaptively rescale each keypoint's heatmap. Intuitively, these methods adjust the heatmap’s sigma values based on the size of each person, assigning larger/smaller sigmas (higher/lower uncertainty) to larger/smaller ones (relative to their pixel count/box area).\n\nI use a sigma value of 200A (0.2 times the radius). Note that going through Spatial augmentations like Spacing, Affine or Zoom, the voxel spacing is changed so the value of sigma used in rendering the heatmap is not a fixed number of voxels, but rather `0.2 * 1000 / cur_voxel_spacing`.  \nA custom implementation of [ApplyTransformToPoints](https://docs.monai.io/en/stable/transforms.html#applytransformtopoints) was implemented to apply Affine Transform to a Normal distribution, result in another Normal distribution to render on the target heatmap volume. Hence, instead of a ball-like shape, each keypoint looks like a rotated ellipsoid.\n  \n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fd2d3ba10849048534fcc91ff265ac800%2F3d_heatmap.png?generation=1749212341383311&alt=media)\n\nDoes this make sense? Maybe not that significant. It's just an assumption that the likelihood of a motor **strictly** follows a 3D Normal distribution in the original tomogram, which is not likely for this task. Later, I decided to use a ball-like-shape Gaussian heatmap, with diagonal covariance matrix and single sigma for all 3 dimensions ZYX to make life easier for my model (just need to predict the mean and scale, intuitively).\n\n## 3D modeling\n3D-UNet with various 3D/2.5D Encoder was tried and be used in final submision. Taking input as a 3D volume of shape `(Z, Y, X) = (224, 448, 448)`, all model predict the output heatmap of shape `(Z/16, Y/16, X/16) = (14, 28, 28)`.  \n3D-UNet Decoder borrowed from [segmentation_models_pytorch_3d](https://github.com/ZFTurbo/segmentation_models_pytorch_3d) was tweak to output multiscale heatmaps for ultilizing deep supervision. Since for this task, finest heatmap has stride 16 right after the coarsest one of 32 (not too deep), deep supervision seem to be redundant experimentally, later I just supervise the 1/16 heatmap only.\n### 3D Encoder\nI mainly tweak and use models from either [timm3d](https://github.com/ZFTurbo/timm_3d) or [SlowFast](https://github.com/facebookresearch/SlowFast). By default, video models like X3D and I3D in SlowFast did not use temporal pooling, keeping the temporal dim unchanged to keep as much as temporal resolution as posible. For this task, this is unnecessary, I make change so that depth (temporal in video term) is progressively downsampled by 2 at each stage, synchronized with spatial downsampling too. Now, all 3D models have the same interface, output at least 4 (usually 5) levels of feature map at stride 1/4, 1/8, 1/16, 1/32.  \nAs discuss above, we need high resolution (small voxel spacing) for keeping very little details/textures. Standard UNet ultilize and fuse low-level/fine features via skip connection to the same output level. However, UNet with output stride of 16 is not actually U-shape now, hence there're no shorter path to propagate the low-level/fine features (rich texture/localization) to the high-level/coarse output heatmap (rich semantic). Motivated by this, I add necks to fuse low-level features to the coarse output heatmap head, efficiently improve information propagation through the network. Two types of necks were tried:\n- Simple 3D FPN Neck: motivated by [this writeup](https://www.kaggle.com/competitions/google-research-identify-contrails-reduce-global-warming/discussion/430491) and [Unified FPN architecture](http://presentations.cocodataset.org/COCO17-Stuff-FAIR.pdf). Simply interpolate features at all level to match spatial shape at stride 1/16, concatenate them all then passed to the UNet Decoder block\n- [3D PAN (Path Aggregation Network)](https://arxiv.org/abs/1803.01534): Instead of just adding top-down path augmentation as in [FPN](https://arxiv.org/pdf/1612.03144) to fuse rich semantic features to low-level features at finer scale, we now add another bottom-up path augmentation to fuse rich texture/detail features to the coarser one as well.\n\nIt's clear that Simple FPN is a \"more straight\" way to achieve our goal. Not putting much time on this, but my experiment results also confirm that Simple FPN is usually better than PAN. So all my final models were additionally equipped with this Simple FPN neck, illustrated below.  \n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2F2f2cab849715f0dd2bf2b2a93fe04e8a%2F3d_unet_fpn.jpg?generation=1749212388256232&alt=media)\n\n\n### 2.5D Encoder\nI'm also working with 2.5D encoder, combined with FPN neck and 3D UNet decoder too. The idea is very simple. First we sample a list of 2D 3-channels image by sliding over the depth (Z) dimension with stride of 2, effectively treat one volume as a batch of 2D images. At each stage of 2D backbone, spatial resolution is downsampled by 2 by default, we also down sample the depth (now treated as batch) by a factor of 2 too by either `AvgPool3d((2, 1, 1))` or `Conv3d(stride=(2, 1, 1))`. The first one was better and easier to train since it keep tensor statistics unchanged, better leveraging transfer learning from pretrained 2D backbone. It shows good score if no external dataset was used, significantly reduce overhead at middle/latter stages, being lighweight and helps model effectively \"see\" all 2D slices of a volume. It's okay for 2D convolution-only backbone such as EfficientNet or ConvNeXT which the heavy computation goes in middle or latter stages, but not for backbone which use Transformer in early stage cause of the high resolution and very large \"fake batch size\"\n\nAlso played with somethings like injecting LSTM layers after obtained 2D features, CV may go up but LB just unchanged. Perhap Conv3d in FPN neck and UNet Decoder was better fusing slicewise features, additional mixing is not necessary anymore.\n\n\n### Some results\nI report LB/PB score of single model (single checkpoint). These models are:\n- Trained on all data (train + external)\n- 2xTTA: original + rotate 90 along XY plane\n- Threshold is the quantile at 55% over all prediction confident scores.\n\nI start using external data a little bit late, so just 1 last checkpoint of them was submitted to obtain LB score with very limitted tuning. Each model's runtime is not exceed 3h.\n\n\n|       **Model**       | **Params** | **Private LB** | **Public LB** |\n|:---------------------:|:----------:|:--------------:|:-------------:|\n|      3D RESNEXT50     |    49.9M   |    **86.2**    |      85.8     |\n|     3D DENSENET121    |    27.6M   |      85.7      |    **86.0**   |\n|       X3DM + FPN      |    10.4M   |      84.6      |      84.8     |\n|   X3DM + PAN + MIXUP  |    6.9M    |      84.6      |      84.5     |\n|          X3DL         |    12.8M   |      83.9      |      85.2     |\n|      3D RESNET50      |    70.3M   |      83.0      |      85.4     |\n|  _2.5D CONVNEXT_TINY_ |    40.3M   |      82.4      |      83.7     |\n| _2D COAT_LITE_MEDIUM_ |    99.6M   |      83.3      |      84.8     |\n|    _2D MAXVIT_TINY_   |    86.1M   |      82.5      |      85.4     |\n\n\n\n\n# 2D-UNet Approach\n2D approach was in my plan, but it becomes urgent since I feel that may be many high score public LB may using 2D approaches, especially YOLO. 2 weeks before competition deadline, I started working on 2D modeling and inherrit many details from the 3D one. Training 2D models was much more resource efficient and much faster compared to 3D.\n\n## Data processing\nThe same target voxel spacing of 16.0 was used too, i.e I resize each 2D slice so that XY spacing is 16A. Of course augmentations could scale this spacing later, but same as 3D approach, sigma of each keypoint is rescaled accordingly so that final sigma is fixed at a real distance of 200A.\nIn inference stage, I load the full volume on RAM then perform LINEAR interpolation to fixed voxel spacing of `(Z, Y, X) = (32, 16, 16)`, that say sampling along Z (depth) is more sparse. This helps reduce inference cost without too much performance decregation. I use 3 channels image of size `(896, 896)` as input to 2D-UNet model.\n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fa69bde60b2bcc2eff12633f2a3e5ea08%2F2d_pipeline.jpg?generation=1749212467165435&alt=media)\n\n\nIn training stage, different from 3D pipeline where I load whole volume to RAM then using MONAI's lazy resampling (using F.grid_sample internally), 2D pipeline just sample a specific value of Z (following truncated normal distribution with scale of 1 sigma) then load 3 nearby images per sample (spacing~32A) from disk, which is pretty efficient. Although 2D approach is more sample efficient to reach a medium score, controlling the positive/negative samples ratio is still important so that model could see more negative slices over all data domain/distribution rather than just the \"motor’s neighbor\" slices. I started experimenting with negative/positive ratio equal to 0.25, latter found that CV improved quite significantly when set it to 0.75, but not enough submissions to confirm that on LB.\n\n## 2D augmentation\n[Albumentations](https://github.com/albumentations-team/albumentations) was used to create augmentation pipeline, with ability to handle and keep track of keypoints scaling as well, which is pretty neat.\n<details>\n<summary> 2D augmentation code </summary>\n\n```python\nTARGET_SPACING = 16.0\nPATCH_SIZE = (896, 896)\n\ndef _byu_get_safe_bbox(params, data, margin_xy=(100, 100)):\n    \"\"\"Get safe bbox to crop which contain at least 1 motor.\"\"\"\n    del params\n    if \"keypoints\" in data and len(data[\"keypoints\"]) > 0:\n        kpt = random.choice(data[\"keypoints\"])\n        # x, y, angle, scale\n        assert len(kpt) == 9\n        x, y = kpt[:2]\n        scale = kpt[3]\n        return [\n            x - margin_xy[0] * scale,\n            y - margin_xy[1] * scale,\n            x + margin_xy[0] * scale,\n            y + margin_xy[1] * scale,\n        ]\n    else:\n        return None\n\n\naugment_transform = A.Compose(\n    [\n        # FLIP\n        A.HorizontalFlip(p=0.5),\n        A.VerticalFlip(p=0.5),\n        # NOISE\n        A.OneOf(\n            [\n                A.GaussNoise(\n                    var_limit=(60, 120),\n                    mean=0,\n                    per_channel=True,\n                    noise_scale_factor=0.5,\n                    p=0.6,\n                ),\n                A.MultiplicativeNoise(\n                    multiplier=(0.6, 1.4),\n                    per_channel=True,\n                    elementwise=True,\n                    p=0.4,\n                ),\n            ],\n            p=0.1,\n        ),\n        # REDUCE QUALITY\n        A.OneOf(\n            [\n                # jitter on float32, implement AdaptiveDownscale based on current resolution\n                A.OneOf(\n                    [\n                        A.Downscale(\n                            scale_range=(0.5, 0.9),\n                            interpolation_pair={\n                                \"upscale\": cv2.INTER_LANCZOS4,\n                                \"downscale\": cv2.INTER_AREA,\n                            },\n                            p=0.1,\n                        ),\n                        A.Downscale(\n                            scale_range=(0.5, 0.9),\n                            interpolation_pair={\n                                \"upscale\": cv2.INTER_LINEAR,\n                                \"downscale\": cv2.INTER_AREA,\n                            },\n                            p=0.1,\n                        ),\n                        A.Downscale(\n                            scale_range=(0.5, 0.9),\n                            interpolation_pair={\n                                \"upscale\": cv2.INTER_LINEAR,\n                                \"downscale\": cv2.INTER_LINEAR,\n                            },\n                            p=0.8,\n                        ),\n                    ],\n                    p=0.6,\n                ),\n                A.ImageCompression(\n                    compression_type=\"jpeg\", quality_range=(20, 80), p=0.3\n                ),\n                A.Posterize(num_bits=(4, 6), p=0.1),\n            ],\n            p=0.25,\n        ),\n        # TEXTURE\n        A.OneOf(\n            [\n                # wrong on float32 img\n                A.Emboss(alpha=(0.3, 0.6), strength=(0.2, 0.8), p=0.4),\n                A.Sharpen(alpha=(0.1, 0.3), lightness=(0.0, 0.4), p=0.5),\n                A.CLAHE(clip_limit=4.0, tile_grid_size=(16, 16), p=0.1),\n            ],\n            p=0.1,\n        ),\n        # BRIGHTNESS & CONTRAST\n        A.OneOf(\n            [\n                A.OneOf(\n                    [\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(-0.3, 0.0),\n                            contrast_limit=(-0.2, 0.0),\n                            brightness_by_max=False,\n                            p=0.4,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(0.0, 0.4),\n                            contrast_limit=(-0.5, 0.0),\n                            brightness_by_max=False,\n                            p=0.4,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(-0.3, 0.0),\n                            contrast_limit=(0.0, 0.5),\n                            brightness_by_max=False,\n                            p=0.1,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(0.0, 0.3),\n                            contrast_limit=(0.0, 0.5),\n                            brightness_by_max=False,\n                            p=0.1,\n                        ),\n                    ],\n                    p=0.4,\n                ),\n                A.RandomToneCurve(scale=0.3, per_channel=True, p=0.4),\n                A.RandomGamma(gamma_limit=(60, 150), p=0.2),\n            ],\n            p=0.5,\n        ),\n        # GEOMETRIC\n        A.OneOf(\n            [\n                # strong rotate\n                A.Affine(\n                    scale={\"x\": (0.6, 1.2), \"y\": (0.6, 1.2)},\n                    translate_percent=None,\n                    rotate=(0, 360),\n                    shear={\"x\": (-5, 5), \"y\": (-5, 5)},\n                    interpolation=cv2.INTER_LINEAR,\n                    cval=128,\n                    mode=cv2.BORDER_CONSTANT,\n                    fit_output=True,\n                    keep_ratio=False,\n                    balanced_scale=False,\n                    p=0.4,\n                ),\n                # strong shear\n                A.Affine(\n                    scale={\"x\": (0.6, 1.2), \"y\": (0.6, 1.2)},\n                    translate_percent=None,\n                    rotate=(0, 360),\n                    shear={\"x\": (-20, 20), \"y\": (-20, 20)},\n                    interpolation=cv2.INTER_LINEAR,\n                    cval=128,\n                    mode=cv2.BORDER_CONSTANT,\n                    fit_output=True,\n                    keep_ratio=False,\n                    balanced_scale=False,\n                    p=0.3,\n                ),\n                A.Perspective(\n                    scale=(0.05, 0.12),\n                    keep_size=False,\n                    pad_mode=cv2.BORDER_CONSTANT,\n                    pad_val=128,\n                    fit_output=True,\n                    interpolation=cv2.INTER_LINEAR,\n                    p=0.3,\n                ),\n            ],\n            p=0.8,\n        ),\n        # crop go after geometric to prevent too much information loss\n        AC.CustomRandomSizedBBoxSafeCrop(\n            crop_size=PATCH_SIZE,\n            scale=(0.25, 1.0),  # unused\n            ratio=(0.25, 1.5),  # unused\n            get_bbox_func=partial(\n                _byu_get_safe_bbox,\n                margin_xy=(\n                    1000.0 / TARGET_SPACING,\n                    1000.0 / TARGET_SPACING,\n                ),\n            ),\n            retry=10,\n            p=1.0,\n        ),\n        A.PadIfNeeded(\n            *PATCH_SIZE,\n            position=\"top_left\",\n            border_mode=cv2.BORDER_CONSTANT,\n            value=128,\n            p=1.0,\n        ),\n    ],\n    p=1.0,\n)\n```\n\n</details>\n\n\n## 2D target heatmap\nThere're many ways to render target heatmap. I do a sweep over 4 kinds of heatmap type with different parameters, and found that the best CV was Gaussian heatmap with peak confident and radius scales smaller when goes far from the Z groudtruth coordinate, same as if we render 3D Gaussian heatmap then take a 2D slice over. `adaptive scaling` > `min=0, max=1` >  `segment` > `point`, which support more that we need to modeling uncertainty better.\n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fd53a89634e4065110b61d729a2736fda%2F2d_heatmap_types_compressed.jpg?generation=1749212650075518&alt=media)\n\n\n## 2D modeling\nThe model used for 2D modeling was adapted from [this Google Contrails solution writeup](https://www.kaggle.com/competitions/google-research-identify-contrails-reduce-global-warming/discussion/430491), thank authors very much for public their source code. It contains an image encoder, a simple FPN neck to fuse low-level features to `/16` feature map, and an UNet Decoder with Pixel Shuffle block.  \nI mainly experiment with different types of 2D encoders including EfficientNet, ConvNeXt, CoaT and MaxViT. No supprising, the Hierachical Convolution-Transformer backbones were perform much better than convolution only, indicated that global context matter and we need a sufficient spatial size for obtaining enough context. In selected final submission, I use 2 2D-UNet models with timm backbones: `maxvit_tiny_tf_512.in1k` and `coat_lite_medium_384.in1k`.\n\n# External dataset & Pseudo label\nAgain, I must thank @brendanartley for [the external dataset](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921), especially in early stage of the competition, and @tatamikenn for [his note](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/575028) on wrong-quantized tomograms.\n\nI started diving into that external dataset to train my final submission a week ago, after ensured that my training/inference pipeline is good enough and bug-free (no?). Without external data, I have a simple ensemble of 3D/2.5D UNet with `PB 0.851 and LB 0.852`.\n\n@brendanartley's [dataset](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921) was good, but seem not to be directly usable for my pipeline because of slicing and resizing to a fixed shape and losing voxel spacing information. So I tweak the download script to get my own new version, improve it by pseudo labeling+manual review:\n1. Keep track the voxel spacing and other metadata as well\n2. Re-spacing using `F.interpolate()` to `target_spacing=max(16.0, ori_spacing)` to save disk space and reduce dataloader overhead in the future. This results in a new **180GB** external dataset.\n3. Convert his labels to fit my new version. This could loses some precision of Z coordinate.\n4. Use my trained models to inference on these data with low confident threshold of `0.05`. Comparing model prediction results with converted GT to identify TP/TN/FP/FN motors.\n5. For TP, simply trust model prediction results rather than the converted GT coordinates\n6. Use [CVAT](https://github.com/cvat-ai/cvat) to review and label 188 FN and 55 FP. Labeling it is simple and easy as a tagging process, where the labeler (me) just consider whether a sample is wrong or correct, not finding new ones. Almost all FN are correct (?), meaning models are missing so many motors. Labeling these confused cases is hard, but the F2 metric and \"believe in AI models\" punishs me to be bias and may label more to positives than usual, especially one without flagellum. This could cause inconsistances with public/private test since host [had clarified](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/579540#3217068) that “more than 80% of the tomograms containing no flagellum the motor is not marked”\n7. With same strategy, I also try to correct the labeling errors on the train dataset as well.\n8. BOOM! Just re-train models on all dataset with previously discovered hyperparameters\n\nAdding so many external data change the game significantly. Training are more stable and allow for much longer training without overfiting. Though more tunings on new setup are needed, however, the deadline is quickly approaching its final days..\n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2F715d399576ca0db1d6ff68d78b522fd1%2Fcvat.png?generation=1749212703333308&alt=media)\n\n\n# Postprocessing\n\nAs usual, I'm not use a fixed threshold since it's largely change between submitted models to the LB. Instead, just use quantile thresholding at 55% (55% lowest threshold samples are predicted as negative, the remain 45% ones predicted as positive) which is adaptively changed based on model's confident distribution, so just one LB submission is enough to estimate the best F2 score.\n\nThe postprocessing process:\n- I use 5 models combined with TTAs, output 9 different heatmaps\n- Not using heatmap averaging, instead, decode each heatmap into list of candidate keypoints using NMS with 0.2 confident threshold.\n- Combine all 9 predictions to get final result using a version of 3D Weighted Box Fusion, using L2 distance for clustering and `conf_mode='avg'`\n\nI didn't conduct many experiments with ensemble methods before, and decided to use WBF on the two last days, just believe that it could survive some very strong peak signal from one single model, thus improve Recall.\n\n\n# Source code\n- **Training code**: https://github.com/dangnh0611/kaggle_byu\n- **Inference notebook**:  https://www.kaggle.com/code/dangnh0611/3rd-place-solution-submit\n\nThanks for your attention !",
      "votes": null
    },
    {
      "id": "3219336",
      "postDate": "06/07/2025 14:14:36",
      "content": "<p>congrats, thanks for your sharing, can't image so many works behind.</p>",
      "rawMarkdown": "congrats, thanks for your sharing, can't image so many works behind.",
      "votes": null
    },
    {
      "id": "3223247",
      "postDate": "06/13/2025 04:16:15",
      "content": "<p>Congratulations and thank you for sharing details of your work. I believe success is even better when achieved alone. I look forward to the publication of your notebooks to learn more about medical applications.</p>\n<p>All the best!</p>",
      "rawMarkdown": "Congratulations and thank you for sharing details of your work. I believe success is even better when achieved alone. I look forward to the publication of your notebooks to learn more about medical applications.\n\nAll the best!",
      "votes": null
    },
    {
      "id": "3240519",
      "postDate": "07/03/2025 22:41:16",
      "content": "<p><a href=\"https://www.kaggle.com/dangnh0611\" target=\"_blank\">@dangnh0611</a> I am attempting to recreate your solution and am unable to setup the environment. I decomposed the pip install commands of the dockerfile into its separate components and the one that fails is pip install mamba-ssm</p>\n<h2>Boils down to this: ERROR: ERROR: Failed to build installable wheels for some pyproject.toml based projects (mamba-ssm)</h2>",
      "rawMarkdown": "dangnh0611 I am attempting to recreate your solution and am unable to setup the environment. I decomposed the pip install commands of the dockerfile into its separate components and the one that fails is pip install mamba-ssm\n\nBoils down to this: ERROR: ERROR: Failed to build installable wheels for some pyproject.toml based projects (mamba-ssm)\n------",
      "votes": null
    },
    {
      "id": "3240538",
      "postDate": "07/03/2025 23:41:46",
      "content": "<p>It’s  strange, did you use the same base image pytorch/pytorch:2.6.0-cuda12.6-cudnn9-devel ?<br>\nAnyway, you can skip installing mamba-ssm caused my solution did not use it. Sorry for the confusion</p>",
      "rawMarkdown": "It’s  strange, did you use the same base image pytorch/pytorch:2.6.0-cuda12.6-cudnn9-devel ?\nAnyway, you can skip installing mamba-ssm caused my solution did not use it. Sorry for the confusion",
      "votes": null
    },
    {
      "id": "3240540",
      "postDate": "07/03/2025 23:55:37",
      "content": "<p>This is one of the best posts I've read. Great write up, good graphics!</p>",
      "rawMarkdown": "This is one of the best posts I've read. Great write up, good graphics!",
      "votes": null
    },
    {
      "id": "3245116",
      "postDate": "07/09/2025 00:14:03",
      "content": "<p>I ended up getting the docker environment working after a while. However, when running the script to download extra data, the processing workers freeze up. I am going to try and train your model without the extra data until I can find a workaround</p>",
      "rawMarkdown": "I ended up getting the docker environment working after a while. However, when running the script to download extra data, the processing workers freeze up. I am going to try and train your model without the extra data until I can find a workaround",
      "votes": null
    },
    {
      "id": "3245250",
      "postDate": "07/09/2025 05:50:17",
      "content": "<p>The download script runs fine on my machine, but I haven’t tested it in other environments, so there may be issues I’m not yet aware of. <br>\nIn case of failure, you can modify the script a little bit to download continously, skipping the processed ones.<br>\nNote that some tomograms can take a long time to process due to their high resolution. On my setup, a single tomogram can take up to 20 minutes, however there're not so many ones like that.<br>\nIf you run into any problems, feel free to DM me directly on a chat platform. I’m happy to help, just email me at <a href=\"mailto:dangnh0611@gmail.com\">dangnh0611@gmail.com</a> to coordinate a DM.<br>\nThanks for your patience.</p>",
      "rawMarkdown": "The download script runs fine on my machine, but I haven’t tested it in other environments, so there may be issues I’m not yet aware of. \nIn case of failure, you can modify the script a little bit to download continously, skipping the processed ones.\nNote that some tomograms can take a long time to process due to their high resolution. On my setup, a single tomogram can take up to 20 minutes, however there're not so many ones like that.\nIf you run into any problems, feel free to DM me directly on a chat platform. I’m happy to help, just email me at dangnh0611@gmail.com to coordinate a DM.\nThanks for your patience.",
      "votes": null
    }
  ],
  "comments": [
    {
      "id": 3219336,
      "author_name": "lhlk83358",
      "author_url": "",
      "post_date": "06/07/2025 14:14:36",
      "content": "<p>congrats, thanks for your sharing, can't image so many works behind.</p>",
      "votes": null,
      "replies": []
    },
    {
      "id": 3223247,
      "author_name": "guillermoperezg",
      "author_url": "",
      "post_date": "06/13/2025 04:16:15",
      "content": "<p>Congratulations and thank you for sharing details of your work. I believe success is even better when achieved alone. I look forward to the publication of your notebooks to learn more about medical applications.</p>\n<p>All the best!</p>",
      "votes": null,
      "replies": []
    },
    {
      "id": 3240519,
      "author_name": "andrewjdarley",
      "author_url": "",
      "post_date": "07/03/2025 22:41:16",
      "content": "<p><a href=\"https://www.kaggle.com/dangnh0611\" target=\"_blank\">@dangnh0611</a> I am attempting to recreate your solution and am unable to setup the environment. I decomposed the pip install commands of the dockerfile into its separate components and the one that fails is pip install mamba-ssm</p>\n<h2>Boils down to this: ERROR: ERROR: Failed to build installable wheels for some pyproject.toml based projects (mamba-ssm)</h2>",
      "votes": null,
      "replies": [
        {
          "id": 3240538,
          "author_name": "dangnh0611",
          "author_url": "",
          "post_date": "07/03/2025 23:41:46",
          "content": "<p>It’s  strange, did you use the same base image pytorch/pytorch:2.6.0-cuda12.6-cudnn9-devel ?<br>\nAnyway, you can skip installing mamba-ssm caused my solution did not use it. Sorry for the confusion</p>",
          "votes": null,
          "replies": [
            {
              "id": 3245116,
              "author_name": "andrewjdarley",
              "author_url": "",
              "post_date": "07/09/2025 00:14:03",
              "content": "<p>I ended up getting the docker environment working after a while. However, when running the script to download extra data, the processing workers freeze up. I am going to try and train your model without the extra data until I can find a workaround</p>",
              "votes": null,
              "replies": [
                {
                  "id": 3245250,
                  "author_name": "dangnh0611",
                  "author_url": "",
                  "post_date": "07/09/2025 05:50:17",
                  "content": "<p>The download script runs fine on my machine, but I haven’t tested it in other environments, so there may be issues I’m not yet aware of. <br>\nIn case of failure, you can modify the script a little bit to download continously, skipping the processed ones.<br>\nNote that some tomograms can take a long time to process due to their high resolution. On my setup, a single tomogram can take up to 20 minutes, however there're not so many ones like that.<br>\nIf you run into any problems, feel free to DM me directly on a chat platform. I’m happy to help, just email me at <a href=\"mailto:dangnh0611@gmail.com\">dangnh0611@gmail.com</a> to coordinate a DM.<br>\nThanks for your patience.</p>",
                  "votes": null,
                  "replies": []
                }
              ]
            }
          ]
        }
      ]
    },
    {
      "id": 3240540,
      "author_name": "taylorsamarel",
      "author_url": "",
      "post_date": "07/03/2025 23:55:37",
      "content": "<p>This is one of the best posts I've read. Great write up, good graphics!</p>",
      "votes": null,
      "replies": []
    }
  ],
  "raw_markdown_by_id": {
    "3218612": "Many thanks to the competition host and Kaggle for another engaging challenge - and big congratulations to all the participants!\nThank you to every one who actively shared insights and notebooks to the community, especially @brendanartley for [the external dataset](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921).\n\nAs always, I had a great time learning throughout the competition and was fortunate enough to finish in the gold zone. I'm really happy to share a few thoughts here.\n  \n# TLDR;\n- I'm focus on the 3D/2.5D/2D UNet approaches. The final submission PB 86.6 was an ensemble of 5 models: `3D Resnext50 (2xTTA)`, `3D Densenet121 (2xTTA)`, `3D X3D-M (2xTTA)`, `2D MaxViT (2xTTA)` and `2D CoaT (1xTTA)`\n- 3D model use patch size of `(224, 448, 448)` while 2D use `(3, 896, 896)` patch size to incorporate enough global context.\n- Gaussian heatmap with `stride=16` and `sigma=200A` for uncertainty modeling\n- Simple FPN neck to fuse low-level features into stride-16-feature map\n- All final selected models are trained on combine of train dataset + external dataset without local validation\n- Heavy augmentations allowed to train for more epochs and be more robust to domain/distribution change.\n- Pseudo labeling on train+external dataset, with manual review\n- Resample all tomograms to 16A voxel spacing + Sliding window patches inference\n- Simple BCE loss works best\n- Weighted Box Fusion (WBF) to ensemble multiple model/TTA ’s predictions\n\n\n**What not work**\n- Other losses: MSE, L1, Weighted BCE, Focal, Tversky, Combine multiple losses\n\n\n\n\n# Table of Contents\n\n\n- [Validation strategy](#validation-strategy)\n- [3D-UNet Approach](#3d-unet-approach)\n  - [Data processing](#data-processing)\n  - [3D augmentation](#3d-augmentation)\n  - [3D target heatmap](#3d-target-heatmap)\n  - [3D modeling](#3d-modeling)\n    - [3D Encoder](#3d-encoder)\n    - [2.5D Encoder](#25d-encoder)\n    - [Some results](#some-results)\n- [2D-UNet Approach](#2d-unet-approach)\n  - [Data processing](#data-processing-1)\n  - [2D augmentation](#2d-augmentation)\n  - [2D target heatmap](#2d-target-heatmap)\n  - [2D modeling](#2d-modeling)\n- [External dataset & Pseudo label](#external-dataset--pseudo-label)\n- [Postprocessing](#postprocessing)\n- [Source code](#source-code)\n\n\n---\n\n# Validation strategy\nA simple 4-StratifiedKfold by number of motors was conducted on train dataset. Validation samples are augmented by Rotate90+Flip (16 posible combinations) to increase the number of samples, making validation metrics more stable and trustworthy to track.\n\nExperiment progress was tracked using mainly F2 score and additionally Partial Average Precision, cutoff at a specific Recall, e.g > 0.9. The second is not a binary metric so more stable and less sentitive to threshold, strong correlate with F2. Single-fold local CV was the main factor to select models and hyperparameters.\n\n**Train on all data, validate on LB:** However, when my pipeline are bug-free and going better (when CV > 0.96 and LB > 0.8), CV is just saturated and not correlate to LB well. So at the later stage, CV is just used to guide a reasonable value of hyperparameter (mainly learning rate). In last week, all models were trained on all dataset without local validation and are submitted to LB.\n\n\n# 3D-UNet Approach\n\n\n## Data processing\n\nI resample tomogram to a fixed voxel spacing of 16.0, use sliding windows (patching) technique, split tomogram to `224x448x448` (possibly overlap) patches as input to models to obtain **16x** downscaled prediction heatmap of shape `14x28x28`.  \nThe labeling coordinate is a little bit noisy/uncertain, also the tolerance radius is high, so using high heatmap resolution seem to be redundant, significantly increase the computation. `Radius of 1000A` == `62.5 voxels in input volume` == `4 voxels in prediction heatmap`. I've experimented with prediction heatmap of `8x` and `32x` downscaled heatmap and found that `8x` ~ `16x` >> `32x`. To the end, I use `16x` downscaled heatmap for both 3D and 2D models.\n\n**Optimal voxel spacing is ~16.0**  \nFor this task, resampling to a suitable voxel spacing is important. Using small voxel spacing keep the very small details but computation intensive due to high resolution, in contrast large voxel spacing could erase out all the textures needed for detection. Experiment results confirm that very small detail/textures are matter:\n\n- Tried various target voxel spacing 12, 16, 20, 24, 32, 64; found that CV degrade significantly when spacing > 32. LB also degrade much faster right after spacing > 20. I found 16.0 is the best balance value, matching with the typical [median spacing](https://github.com/MIC-DKFZ/nnUNet/issues/2701) default practice of nnUnet (median spacing is 15.6 for the train dataset).\n- Resampling interpolation mode (LINEAR, AREA, NEAREST) also could significantly affect performance. All tomograms are also noisy (Gaussian noise?) so resize/interpolation could erase thing in an unexpected manner.\n\n**The mean original voxel spacing in LB/PB is 13.1 ???**  \nHaha, just my stupid guess work and there're no way I could confirm. I trained model with `target_spacing=16.0` and submit to LB with different of assumtions about the original spacing. `13.1` brings the highest LB. Given that test samples have higher resolution (`~400x1400x1050` in average) and 44.7% of train samples have `voxel_spacing=13.1`, **I safely assume that the average test voxel spacing is ~13.1, and resample to 16.0 for all experiments**\n> I tried to quickly train a voxel spacing prediction model on the last day, but failed, metric is about `MAE=5.1`, can't predict any useful thing :D\n\nEach sample is dominated by negative voxels, so I try to control the number of positive/negative patches ratio in the training progress. For 3D approach, the positive/negative samples ratio is 20/1.\n\n\n## 3D augmentation\n\nFor each epoch, all positive samples are ensured to be augmented with all 16 transformations composed of Rotate90+Flip, go after a \"heavy\" MONAI augmentation pipeline. Keypoints passed to MONAI dictionary transform as well so that we get the Affine-transformed keypoints to render the heatmap with larger stride (16 in this case). I really like MONAI's [Lazy Resampling](https://docs.monai.io/en/stable/lazy_resampling.html), which make augmentation and dataloader become fast and efficient.\nVarious MONAI 3D augmentations were used:\n- Spatial: `RandZoom`, `RandAffined`\n- Intensity: `RandShiftIntensityd`, `RandStdShiftIntensityd`, `RandScaleIntensityFixedMeand`, `RandScaleIntensityd`, `RandAdjustContrastd`, `RandHistogramShiftd`\n- Reduce quality: `RandSimulateLowResolutiond`\n- Dropout: `RandCoarseDropoutKeepKeypoints`\n\nMixup and Cutmix were tried but bring no clear improvement so be ignored in latter experiments.\n\n<details>\n<summary>Pseudo code in Albumentations style</summary>\n\n```python\nfrom monai import transforms as T\nimport monai_custom as CT\n\nT.Compose(\n    [\n        # =========START OF LAZY RESAMPLING=========\n        T.Spacingd(target_spacing=16.0, p=1.0),\n        CT.CustomCropBySlicesd(),\n        # SPATIAL TRANSFORM\n        T.RandZoomd(min_zoom=(0.6, 0.6, 0.6), max_zoom=(1.2, 1.2, 1.2), p=0.4),\n        T.OneOf(\n            [\n                # Affine 1: slight, focus on XY dims\n                T.RandAffined(\n                    rotate_range=((0, 0), (0, 0), (0, 360)),\n                    scale_range=((-0.3, 0.3), (-0.3, 0.3), (-0.3, 0.3)),\n                    p=0.67,\n                ),\n                # Affine2: heavier, focus on all XYZ dims\n                T.RandAffined(\n                    rotate_range=((-15, 15), (-15, 15), (0, 360)),\n                    shear_range=((-0.2, 0.2), (-0.2, 0.2), (-0.2, 0.2)),\n                    scale_range=((-0.3, 0.3), (-0.3, 0.3), (-0.3, 0.3)),\n                    p=0.33,\n                ),\n            ],\n            p=0.75,\n        ),\n        # INTENSITY TRANSFORM\n        T.OneOf(\n            [\n                T.Compose(\n                    [\n                        # mean shift\n                        T.OneOf(\n                            [\n                                T.RandShiftIntensityd(offset=(-40, 80), p=0.5),\n                                T.RandStdShiftIntensityd(factors=(-0.7, 1.2), p=0.5),\n                            ],\n                            p=0.3,\n                        ),\n                        # std/contrast scale (multiplicative)\n                        T.OneOf(\n                            [\n                                # decrease std/contrast -> harder sample -> higher prob\n                                T.RandScaleIntensityFixedMeand(factors=(-0.6, 0), p=0.7),\n                                # increase std/contrast -> easier sample -> lower prob\n                                T.RandScaleIntensityFixedMeand(factors=(0, 0.8), p=0.3),\n                            ],\n                            p=0.3,\n                        ),\n                    ],\n                    p=0.4,\n                ),\n                T.RandScaleIntensityd(factors=(-0.7, 0.7), channel_wise=True, p=0.2),\n                T.RandAdjustContrastd(gamma=(0.25, 1.75), p=0.3),\n                T.RandHistogramShiftd(num_control_points=(10, 20), p=0.1),\n            ],\n            p=0.5,\n        ),\n        # =========END OF LAZY RESAMPLING=========\n        # apply Affine Transform to keypoints (normal distributions) as well\n        CT.ApplyTransformToNormalDistributionsd(keys=[\"keypoints\"]),\n        # with varius interpolation mode pairs, simplified for readability\n        T.RandSimulateLowResolutiond(\n            zoom_range=(0.6, 0.9), downsample_mode=\"*\", upsample_mode=\"*\", p=0.2\n        ),\n        CT.RandCoarseDropoutWithKeypointsd(\n            keypoints_key=\"keypoints\", holes=3, spatial_size=(30, 30, 30),\n            dropout_holes=True, fill_value=(0, 255), max_holes=8,\n            max_spatial_size=(125, 125, 125), prob=0.1\n        )\n    ]\n)\n```\n</details>\n\n\n\n\n\n## 3D target heatmap\nFor a task where real distance/scale could be measured, e.g voxel spacing is known, I prefer a fixed sigma measured in real-life distance. Some advances in bottom-up human pose estimation have introduced techniques to adaptively rescale each keypoint's heatmap. Intuitively, these methods adjust the heatmap’s sigma values based on the size of each person, assigning larger/smaller sigmas (higher/lower uncertainty) to larger/smaller ones (relative to their pixel count/box area).\n\nI use a sigma value of 200A (0.2 times the radius). Note that going through Spatial augmentations like Spacing, Affine or Zoom, the voxel spacing is changed so the value of sigma used in rendering the heatmap is not a fixed number of voxels, but rather `0.2 * 1000 / cur_voxel_spacing`.  \nA custom implementation of [ApplyTransformToPoints](https://docs.monai.io/en/stable/transforms.html#applytransformtopoints) was implemented to apply Affine Transform to a Normal distribution, result in another Normal distribution to render on the target heatmap volume. Hence, instead of a ball-like shape, each keypoint looks like a rotated ellipsoid.\n  \n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fd2d3ba10849048534fcc91ff265ac800%2F3d_heatmap.png?generation=1749212341383311&alt=media)\n\nDoes this make sense? Maybe not that significant. It's just an assumption that the likelihood of a motor **strictly** follows a 3D Normal distribution in the original tomogram, which is not likely for this task. Later, I decided to use a ball-like-shape Gaussian heatmap, with diagonal covariance matrix and single sigma for all 3 dimensions ZYX to make life easier for my model (just need to predict the mean and scale, intuitively).\n\n## 3D modeling\n3D-UNet with various 3D/2.5D Encoder was tried and be used in final submision. Taking input as a 3D volume of shape `(Z, Y, X) = (224, 448, 448)`, all model predict the output heatmap of shape `(Z/16, Y/16, X/16) = (14, 28, 28)`.  \n3D-UNet Decoder borrowed from [segmentation_models_pytorch_3d](https://github.com/ZFTurbo/segmentation_models_pytorch_3d) was tweak to output multiscale heatmaps for ultilizing deep supervision. Since for this task, finest heatmap has stride 16 right after the coarsest one of 32 (not too deep), deep supervision seem to be redundant experimentally, later I just supervise the 1/16 heatmap only.\n### 3D Encoder\nI mainly tweak and use models from either [timm3d](https://github.com/ZFTurbo/timm_3d) or [SlowFast](https://github.com/facebookresearch/SlowFast). By default, video models like X3D and I3D in SlowFast did not use temporal pooling, keeping the temporal dim unchanged to keep as much as temporal resolution as posible. For this task, this is unnecessary, I make change so that depth (temporal in video term) is progressively downsampled by 2 at each stage, synchronized with spatial downsampling too. Now, all 3D models have the same interface, output at least 4 (usually 5) levels of feature map at stride 1/4, 1/8, 1/16, 1/32.  \nAs discuss above, we need high resolution (small voxel spacing) for keeping very little details/textures. Standard UNet ultilize and fuse low-level/fine features via skip connection to the same output level. However, UNet with output stride of 16 is not actually U-shape now, hence there're no shorter path to propagate the low-level/fine features (rich texture/localization) to the high-level/coarse output heatmap (rich semantic). Motivated by this, I add necks to fuse low-level features to the coarse output heatmap head, efficiently improve information propagation through the network. Two types of necks were tried:\n- Simple 3D FPN Neck: motivated by [this writeup](https://www.kaggle.com/competitions/google-research-identify-contrails-reduce-global-warming/discussion/430491) and [Unified FPN architecture](http://presentations.cocodataset.org/COCO17-Stuff-FAIR.pdf). Simply interpolate features at all level to match spatial shape at stride 1/16, concatenate them all then passed to the UNet Decoder block\n- [3D PAN (Path Aggregation Network)](https://arxiv.org/abs/1803.01534): Instead of just adding top-down path augmentation as in [FPN](https://arxiv.org/pdf/1612.03144) to fuse rich semantic features to low-level features at finer scale, we now add another bottom-up path augmentation to fuse rich texture/detail features to the coarser one as well.\n\nIt's clear that Simple FPN is a \"more straight\" way to achieve our goal. Not putting much time on this, but my experiment results also confirm that Simple FPN is usually better than PAN. So all my final models were additionally equipped with this Simple FPN neck, illustrated below.  \n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2F2f2cab849715f0dd2bf2b2a93fe04e8a%2F3d_unet_fpn.jpg?generation=1749212388256232&alt=media)\n\n\n### 2.5D Encoder\nI'm also working with 2.5D encoder, combined with FPN neck and 3D UNet decoder too. The idea is very simple. First we sample a list of 2D 3-channels image by sliding over the depth (Z) dimension with stride of 2, effectively treat one volume as a batch of 2D images. At each stage of 2D backbone, spatial resolution is downsampled by 2 by default, we also down sample the depth (now treated as batch) by a factor of 2 too by either `AvgPool3d((2, 1, 1))` or `Conv3d(stride=(2, 1, 1))`. The first one was better and easier to train since it keep tensor statistics unchanged, better leveraging transfer learning from pretrained 2D backbone. It shows good score if no external dataset was used, significantly reduce overhead at middle/latter stages, being lighweight and helps model effectively \"see\" all 2D slices of a volume. It's okay for 2D convolution-only backbone such as EfficientNet or ConvNeXT which the heavy computation goes in middle or latter stages, but not for backbone which use Transformer in early stage cause of the high resolution and very large \"fake batch size\"\n\nAlso played with somethings like injecting LSTM layers after obtained 2D features, CV may go up but LB just unchanged. Perhap Conv3d in FPN neck and UNet Decoder was better fusing slicewise features, additional mixing is not necessary anymore.\n\n\n### Some results\nI report LB/PB score of single model (single checkpoint). These models are:\n- Trained on all data (train + external)\n- 2xTTA: original + rotate 90 along XY plane\n- Threshold is the quantile at 55% over all prediction confident scores.\n\nI start using external data a little bit late, so just 1 last checkpoint of them was submitted to obtain LB score with very limitted tuning. Each model's runtime is not exceed 3h.\n\n\n|       **Model**       | **Params** | **Private LB** | **Public LB** |\n|:---------------------:|:----------:|:--------------:|:-------------:|\n|      3D RESNEXT50     |    49.9M   |    **86.2**    |      85.8     |\n|     3D DENSENET121    |    27.6M   |      85.7      |    **86.0**   |\n|       X3DM + FPN      |    10.4M   |      84.6      |      84.8     |\n|   X3DM + PAN + MIXUP  |    6.9M    |      84.6      |      84.5     |\n|          X3DL         |    12.8M   |      83.9      |      85.2     |\n|      3D RESNET50      |    70.3M   |      83.0      |      85.4     |\n|  _2.5D CONVNEXT_TINY_ |    40.3M   |      82.4      |      83.7     |\n| _2D COAT_LITE_MEDIUM_ |    99.6M   |      83.3      |      84.8     |\n|    _2D MAXVIT_TINY_   |    86.1M   |      82.5      |      85.4     |\n\n\n\n\n# 2D-UNet Approach\n2D approach was in my plan, but it becomes urgent since I feel that may be many high score public LB may using 2D approaches, especially YOLO. 2 weeks before competition deadline, I started working on 2D modeling and inherrit many details from the 3D one. Training 2D models was much more resource efficient and much faster compared to 3D.\n\n## Data processing\nThe same target voxel spacing of 16.0 was used too, i.e I resize each 2D slice so that XY spacing is 16A. Of course augmentations could scale this spacing later, but same as 3D approach, sigma of each keypoint is rescaled accordingly so that final sigma is fixed at a real distance of 200A.\nIn inference stage, I load the full volume on RAM then perform LINEAR interpolation to fixed voxel spacing of `(Z, Y, X) = (32, 16, 16)`, that say sampling along Z (depth) is more sparse. This helps reduce inference cost without too much performance decregation. I use 3 channels image of size `(896, 896)` as input to 2D-UNet model.\n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fa69bde60b2bcc2eff12633f2a3e5ea08%2F2d_pipeline.jpg?generation=1749212467165435&alt=media)\n\n\nIn training stage, different from 3D pipeline where I load whole volume to RAM then using MONAI's lazy resampling (using F.grid_sample internally), 2D pipeline just sample a specific value of Z (following truncated normal distribution with scale of 1 sigma) then load 3 nearby images per sample (spacing~32A) from disk, which is pretty efficient. Although 2D approach is more sample efficient to reach a medium score, controlling the positive/negative samples ratio is still important so that model could see more negative slices over all data domain/distribution rather than just the \"motor’s neighbor\" slices. I started experimenting with negative/positive ratio equal to 0.25, latter found that CV improved quite significantly when set it to 0.75, but not enough submissions to confirm that on LB.\n\n## 2D augmentation\n[Albumentations](https://github.com/albumentations-team/albumentations) was used to create augmentation pipeline, with ability to handle and keep track of keypoints scaling as well, which is pretty neat.\n<details>\n<summary> 2D augmentation code </summary>\n\n```python\nTARGET_SPACING = 16.0\nPATCH_SIZE = (896, 896)\n\ndef _byu_get_safe_bbox(params, data, margin_xy=(100, 100)):\n    \"\"\"Get safe bbox to crop which contain at least 1 motor.\"\"\"\n    del params\n    if \"keypoints\" in data and len(data[\"keypoints\"]) > 0:\n        kpt = random.choice(data[\"keypoints\"])\n        # x, y, angle, scale\n        assert len(kpt) == 9\n        x, y = kpt[:2]\n        scale = kpt[3]\n        return [\n            x - margin_xy[0] * scale,\n            y - margin_xy[1] * scale,\n            x + margin_xy[0] * scale,\n            y + margin_xy[1] * scale,\n        ]\n    else:\n        return None\n\n\naugment_transform = A.Compose(\n    [\n        # FLIP\n        A.HorizontalFlip(p=0.5),\n        A.VerticalFlip(p=0.5),\n        # NOISE\n        A.OneOf(\n            [\n                A.GaussNoise(\n                    var_limit=(60, 120),\n                    mean=0,\n                    per_channel=True,\n                    noise_scale_factor=0.5,\n                    p=0.6,\n                ),\n                A.MultiplicativeNoise(\n                    multiplier=(0.6, 1.4),\n                    per_channel=True,\n                    elementwise=True,\n                    p=0.4,\n                ),\n            ],\n            p=0.1,\n        ),\n        # REDUCE QUALITY\n        A.OneOf(\n            [\n                # jitter on float32, implement AdaptiveDownscale based on current resolution\n                A.OneOf(\n                    [\n                        A.Downscale(\n                            scale_range=(0.5, 0.9),\n                            interpolation_pair={\n                                \"upscale\": cv2.INTER_LANCZOS4,\n                                \"downscale\": cv2.INTER_AREA,\n                            },\n                            p=0.1,\n                        ),\n                        A.Downscale(\n                            scale_range=(0.5, 0.9),\n                            interpolation_pair={\n                                \"upscale\": cv2.INTER_LINEAR,\n                                \"downscale\": cv2.INTER_AREA,\n                            },\n                            p=0.1,\n                        ),\n                        A.Downscale(\n                            scale_range=(0.5, 0.9),\n                            interpolation_pair={\n                                \"upscale\": cv2.INTER_LINEAR,\n                                \"downscale\": cv2.INTER_LINEAR,\n                            },\n                            p=0.8,\n                        ),\n                    ],\n                    p=0.6,\n                ),\n                A.ImageCompression(\n                    compression_type=\"jpeg\", quality_range=(20, 80), p=0.3\n                ),\n                A.Posterize(num_bits=(4, 6), p=0.1),\n            ],\n            p=0.25,\n        ),\n        # TEXTURE\n        A.OneOf(\n            [\n                # wrong on float32 img\n                A.Emboss(alpha=(0.3, 0.6), strength=(0.2, 0.8), p=0.4),\n                A.Sharpen(alpha=(0.1, 0.3), lightness=(0.0, 0.4), p=0.5),\n                A.CLAHE(clip_limit=4.0, tile_grid_size=(16, 16), p=0.1),\n            ],\n            p=0.1,\n        ),\n        # BRIGHTNESS & CONTRAST\n        A.OneOf(\n            [\n                A.OneOf(\n                    [\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(-0.3, 0.0),\n                            contrast_limit=(-0.2, 0.0),\n                            brightness_by_max=False,\n                            p=0.4,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(0.0, 0.4),\n                            contrast_limit=(-0.5, 0.0),\n                            brightness_by_max=False,\n                            p=0.4,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(-0.3, 0.0),\n                            contrast_limit=(0.0, 0.5),\n                            brightness_by_max=False,\n                            p=0.1,\n                        ),\n                        A.RandomBrightnessContrast(\n                            brightness_limit=(0.0, 0.3),\n                            contrast_limit=(0.0, 0.5),\n                            brightness_by_max=False,\n                            p=0.1,\n                        ),\n                    ],\n                    p=0.4,\n                ),\n                A.RandomToneCurve(scale=0.3, per_channel=True, p=0.4),\n                A.RandomGamma(gamma_limit=(60, 150), p=0.2),\n            ],\n            p=0.5,\n        ),\n        # GEOMETRIC\n        A.OneOf(\n            [\n                # strong rotate\n                A.Affine(\n                    scale={\"x\": (0.6, 1.2), \"y\": (0.6, 1.2)},\n                    translate_percent=None,\n                    rotate=(0, 360),\n                    shear={\"x\": (-5, 5), \"y\": (-5, 5)},\n                    interpolation=cv2.INTER_LINEAR,\n                    cval=128,\n                    mode=cv2.BORDER_CONSTANT,\n                    fit_output=True,\n                    keep_ratio=False,\n                    balanced_scale=False,\n                    p=0.4,\n                ),\n                # strong shear\n                A.Affine(\n                    scale={\"x\": (0.6, 1.2), \"y\": (0.6, 1.2)},\n                    translate_percent=None,\n                    rotate=(0, 360),\n                    shear={\"x\": (-20, 20), \"y\": (-20, 20)},\n                    interpolation=cv2.INTER_LINEAR,\n                    cval=128,\n                    mode=cv2.BORDER_CONSTANT,\n                    fit_output=True,\n                    keep_ratio=False,\n                    balanced_scale=False,\n                    p=0.3,\n                ),\n                A.Perspective(\n                    scale=(0.05, 0.12),\n                    keep_size=False,\n                    pad_mode=cv2.BORDER_CONSTANT,\n                    pad_val=128,\n                    fit_output=True,\n                    interpolation=cv2.INTER_LINEAR,\n                    p=0.3,\n                ),\n            ],\n            p=0.8,\n        ),\n        # crop go after geometric to prevent too much information loss\n        AC.CustomRandomSizedBBoxSafeCrop(\n            crop_size=PATCH_SIZE,\n            scale=(0.25, 1.0),  # unused\n            ratio=(0.25, 1.5),  # unused\n            get_bbox_func=partial(\n                _byu_get_safe_bbox,\n                margin_xy=(\n                    1000.0 / TARGET_SPACING,\n                    1000.0 / TARGET_SPACING,\n                ),\n            ),\n            retry=10,\n            p=1.0,\n        ),\n        A.PadIfNeeded(\n            *PATCH_SIZE,\n            position=\"top_left\",\n            border_mode=cv2.BORDER_CONSTANT,\n            value=128,\n            p=1.0,\n        ),\n    ],\n    p=1.0,\n)\n```\n\n</details>\n\n\n## 2D target heatmap\nThere're many ways to render target heatmap. I do a sweep over 4 kinds of heatmap type with different parameters, and found that the best CV was Gaussian heatmap with peak confident and radius scales smaller when goes far from the Z groudtruth coordinate, same as if we render 3D Gaussian heatmap then take a 2D slice over. `adaptive scaling` > `min=0, max=1` >  `segment` > `point`, which support more that we need to modeling uncertainty better.\n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2Fd53a89634e4065110b61d729a2736fda%2F2d_heatmap_types_compressed.jpg?generation=1749212650075518&alt=media)\n\n\n## 2D modeling\nThe model used for 2D modeling was adapted from [this Google Contrails solution writeup](https://www.kaggle.com/competitions/google-research-identify-contrails-reduce-global-warming/discussion/430491), thank authors very much for public their source code. It contains an image encoder, a simple FPN neck to fuse low-level features to `/16` feature map, and an UNet Decoder with Pixel Shuffle block.  \nI mainly experiment with different types of 2D encoders including EfficientNet, ConvNeXt, CoaT and MaxViT. No supprising, the Hierachical Convolution-Transformer backbones were perform much better than convolution only, indicated that global context matter and we need a sufficient spatial size for obtaining enough context. In selected final submission, I use 2 2D-UNet models with timm backbones: `maxvit_tiny_tf_512.in1k` and `coat_lite_medium_384.in1k`.\n\n# External dataset & Pseudo label\nAgain, I must thank @brendanartley for [the external dataset](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921), especially in early stage of the competition, and @tatamikenn for [his note](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/575028) on wrong-quantized tomograms.\n\nI started diving into that external dataset to train my final submission a week ago, after ensured that my training/inference pipeline is good enough and bug-free (no?). Without external data, I have a simple ensemble of 3D/2.5D UNet with `PB 0.851 and LB 0.852`.\n\n@brendanartley's [dataset](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/569921) was good, but seem not to be directly usable for my pipeline because of slicing and resizing to a fixed shape and losing voxel spacing information. So I tweak the download script to get my own new version, improve it by pseudo labeling+manual review:\n1. Keep track the voxel spacing and other metadata as well\n2. Re-spacing using `F.interpolate()` to `target_spacing=max(16.0, ori_spacing)` to save disk space and reduce dataloader overhead in the future. This results in a new **180GB** external dataset.\n3. Convert his labels to fit my new version. This could loses some precision of Z coordinate.\n4. Use my trained models to inference on these data with low confident threshold of `0.05`. Comparing model prediction results with converted GT to identify TP/TN/FP/FN motors.\n5. For TP, simply trust model prediction results rather than the converted GT coordinates\n6. Use [CVAT](https://github.com/cvat-ai/cvat) to review and label 188 FN and 55 FP. Labeling it is simple and easy as a tagging process, where the labeler (me) just consider whether a sample is wrong or correct, not finding new ones. Almost all FN are correct (?), meaning models are missing so many motors. Labeling these confused cases is hard, but the F2 metric and \"believe in AI models\" punishs me to be bias and may label more to positives than usual, especially one without flagellum. This could cause inconsistances with public/private test since host [had clarified](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/579540#3217068) that “more than 80% of the tomograms containing no flagellum the motor is not marked”\n7. With same strategy, I also try to correct the labeling errors on the train dataset as well.\n8. BOOM! Just re-train models on all dataset with previously discovered hyperparameters\n\nAdding so many external data change the game significantly. Training are more stable and allow for much longer training without overfiting. Though more tunings on new setup are needed, however, the deadline is quickly approaching its final days..\n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F10254700%2F715d399576ca0db1d6ff68d78b522fd1%2Fcvat.png?generation=1749212703333308&alt=media)\n\n\n# Postprocessing\n\nAs usual, I'm not use a fixed threshold since it's largely change between submitted models to the LB. Instead, just use quantile thresholding at 55% (55% lowest threshold samples are predicted as negative, the remain 45% ones predicted as positive) which is adaptively changed based on model's confident distribution, so just one LB submission is enough to estimate the best F2 score.\n\nThe postprocessing process:\n- I use 5 models combined with TTAs, output 9 different heatmaps\n- Not using heatmap averaging, instead, decode each heatmap into list of candidate keypoints using NMS with 0.2 confident threshold.\n- Combine all 9 predictions to get final result using a version of 3D Weighted Box Fusion, using L2 distance for clustering and `conf_mode='avg'`\n\nI didn't conduct many experiments with ensemble methods before, and decided to use WBF on the two last days, just believe that it could survive some very strong peak signal from one single model, thus improve Recall.\n\n\n# Source code\n- **Training code**: https://github.com/dangnh0611/kaggle_byu\n- **Inference notebook**:  https://www.kaggle.com/code/dangnh0611/3rd-place-solution-submit\n\nThanks for your attention !",
    "3219336": "congrats, thanks for your sharing, can't image so many works behind.",
    "3223247": "Congratulations and thank you for sharing details of your work. I believe success is even better when achieved alone. I look forward to the publication of your notebooks to learn more about medical applications.\n\nAll the best!",
    "3240519": "dangnh0611 I am attempting to recreate your solution and am unable to setup the environment. I decomposed the pip install commands of the dockerfile into its separate components and the one that fails is pip install mamba-ssm\n\nBoils down to this: ERROR: ERROR: Failed to build installable wheels for some pyproject.toml based projects (mamba-ssm)\n------",
    "3240538": "It’s  strange, did you use the same base image pytorch/pytorch:2.6.0-cuda12.6-cudnn9-devel ?\nAnyway, you can skip installing mamba-ssm caused my solution did not use it. Sorry for the confusion",
    "3240540": "This is one of the best posts I've read. Great write up, good graphics!",
    "3245116": "I ended up getting the docker environment working after a while. However, when running the script to download extra data, the processing workers freeze up. I am going to try and train your model without the extra data until I can find a workaround",
    "3245250": "The download script runs fine on my machine, but I haven’t tested it in other environments, so there may be issues I’m not yet aware of. \nIn case of failure, you can modify the script a little bit to download continously, skipping the processed ones.\nNote that some tomograms can take a long time to process due to their high resolution. On my setup, a single tomogram can take up to 20 minutes, however there're not so many ones like that.\nIf you run into any problems, feel free to DM me directly on a chat platform. I’m happy to help, just email me at dangnh0611@gmail.com to coordinate a DM.\nThanks for your patience."
  },
  "source": "meta"
}