{
  "id": 583228,
  "title": "4-th place solution (Detection Part)",
  "url": "/competitions/byu-locating-bacterial-flagellar-motors-2025/discussion/583228",
  "author_name": "",
  "post_date": "2025-06-05T14:26:43.297367100Z",
  "votes": 28,
  "comment_count": 5,
  "views": 0,
  "content": "<pre><code>Throughout the competition there were numerous missile strikes, bombings, and other acts of war that have taken the lives of many innocent people in Ukraine. \nRockets from russia hit within few kilometers from my home in Odesa. Each day Kaggle users from Ukraine facing the chance of not waking up. Just keep in this mind while you read this solution writeup.\n\nI would like to thank the Armed Forces of Ukraine, the Security Service of Ukraine, Defence Intelligence of Ukraine, and the State Emergency Service of Ukraine for providing safety and security to participate in this great competition, complete this work, and help science, technology, and business not to stop but to move forward.\n</code></pre>\n<p>This writeup describes the detection &amp; blending part of our solution for the <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025\" target=\"_blank\">BYU - Locating Bacterial Flagellar Motors 2025</a> competition.<br>\nSome details are omitted as my solution is heavily based upon other 3D detection challenge <a href=\"https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046\" target=\"_blank\">CryoET challenge solution</a>, but I will try to cover the most important parts of the solution.</p>\n<p>I'd like to thank my teammate <a href=\"https://www.kaggle.com/christofhenkel\" target=\"_blank\">@christofhenkel</a> for his great performance and collaboration during the competition. It's a pleasure to work with you, Christof!<br>\nI will not make any spoilers here, but Christof's solution is very out of the box and unique, so I highly recommend you to check out his solution writeup as well:<br>\n<code>&lt;PLACEHOLDER FOR CHRISTOF'S SOLUTION WRITEUP&gt;</code>.</p>\n<h1>TLDR</h1>\n<p>The detection part of the solution is a hybrid 2.5D (2D encoder / 3D decoder) detection model. There were 4 checkpoints (4 folds) in the final ensemble.<br>\nHaving an input volume of shape <code>[D, H, W]</code> the model first reduces the depth of the input volume by a factor of 4 using <code>nn.Conv3d</code> with stride 4 (<code>[D//4, H, W]</code>).<br>\nThen we pass the reduced feature maps through a 2D encoder (<code>maxxvit_rmlp_small_rw_256.sw_in1k</code>) and take the last feature map (<code>[D//4, H//32, W//32]</code>).<br>\nNext we pass it via 3D-CNN decoder to reduce output feature map even more to <code>[D//32, H//32, W//32]</code>. Finally, we pass this feature map to the object detection head which predicts logits and offsets map. <br>\nThe model is trained with a custom loss function that mimics PP-Yolo loss function with a few modifications for 3D detection.<br>\nWe accelerate model inference with NVidia TensorRT to achieve more than 200% speedup compared to eager PyTorch runtime and leverage two T4 GPUs to run predictions in parallel. Total runtime for detection part was ~5 hours.</p>\n<h2>Introduction</h2>\n<p>I started initially with a 3D SegResNet detection model (taken from our <a href=\"https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046\" target=\"_blank\">CryoET challenge solution</a>) solution. <br>\nThe only change I did was to use output stride of 4 instead of 2. <br>\nThis approach was ok, without external data I was able to achieve 0.831 on the public LB. <br>\nHowever, I was not satisfied with the results and wanted to try something different.</p>\n<p>Next, I implemented a hybrid 2.5D (2D encoder / 3D decoder) model also with output stride of 4. <br>\nThis model used a 2D encoder (<code>maxxvit_rmlp_small_rw_256.sw_in1k</code>) and a 3D decoder to reduce the depth of the input volume by a factor of 4.<br>\nAt this point I also added <a href=\"https://www.kaggle.com/datasets/brendanartley/cryoet-flagellar-motors-dataset\" target=\"_blank\">external data</a> by <a href=\"https://www.kaggle.com/brendanartley\" target=\"_blank\">@brendanartley</a> (Kudos for sharing it with the community!). <br>\nThis boosted the public LB score to 0.846. At this point I dropped the idea of fully 3D-CNN and focused on 2.5D models as they we much faster at train and inference.</p>\n<h2>Modeling approach</h2>\n<p>My final approach uses hybrid 3D-2D-3D architecture with a 2D encoder in the middle. <br>\nThe model itself trained for a 3D object detection task, with a training objective matching <a href=\"https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046\" target=\"_blank\">CryoET challenge solution</a>.<br>\nIn the nutshell, model predicts a class map of shape <code>[B, C, D/32, H/32, W/32]</code> and an offsets map of shape <code>[B, 3, D/32, H/32, W/32]</code> where <code>B</code> is the batch size, <code>C</code> is the number of classes (1 in our case), and <code>D</code>, <code>H</code>, <code>W</code> are the depth, height, and width of the input volume respectively.<br>\nInitial 3D convolution with stride 4 reduces the depth of the input volume by a factor of 4, which allows us to use a 2D encoder to extract features from the input volume.</p>\n<h3>Stem</h3>\n<p>The stem of the model is a 3D convolution with kernel size 3x3x3 and stride 4. It serves a purpose of reducing the depth of the input volume and<br>\nextract initial representations from the input data. The stem is followed by an instance normalization layer to normalize the feature maps. <br>\nUse of large kernel size and 8 output channels prevent information loss and extracts meaningful features from the input volume.</p>\n<pre><code>.initial_conv = nn.Sequential(\n    nn.Conv3d(\n        in_channels=,\n        out_channels=,\n        kernel_size=(, , ),\n        stride=(, , ),\n        padding=(, , ),\n        bias=,\n    ),\n    nn.InstanceNorm3d(),\n)\n</code></pre>\n<h3>2D Encoder</h3>\n<p>For the 2D encoder, I used <code>maxxvit_rmlp_small_rw_256.sw_in1k</code> from the <a href=\"https://github.com/huggingface/pytorch-image-models\" target=\"_blank\">timm library</a>.<br>\nThe choice of this specific encoder was motivated by presence of ViT blocks in the architecture, which are known to be effective in capturing long-range dependencies in the data.<br>\nMy assumption was that is it important for the task of detecting bacterial flagellar motors, as these structures are small but exist at the specific locations with regard to the bacterial cell body.<br>\nAnd having a transformer-based architecture in the middle of the model would help to capture these dependencies. </p>\n<pre><code>.backbone = timm.create_model(\n    model_name=,\n    pretrained=,\n    features_only=,\n    in_chans=,\n)\n\n ():\n    \n    \n    x = .initial_conv(x)  \n    B, C, D, H, W = x.shape\n    x = einops.rearrange(x, )\n    features = .backbone(x)\n    features_3d = einops.rearrange(features[-], , d=D)\n     features_3d\n</code></pre>\n<h4>3D Decoder</h4>\n<p>After obtaining the feature maps from the 2D encoder, I pass them through a 3D decoder to reduce the depth of the feature maps by a factor of 8 to obtain the final feature maps of shape <code>[B, C, D/32, H/32, W/32]</code>.<br>\nIn terms of architecture, the decoder consists two <code>Conv3d + Normalization + Activation</code> where first Conv3d layer has stride of 2 in depth dimension.</p>\n<p>The goal of the decoder is to incorporate the spatial information from the 2D encoder into a consistent 3D representation that can be used for object detection.</p>\n<pre><code>in_channels = backbone_channels[-]\nout_channels = \n\n.neck = nn.Sequential()\n out_channels  config.decoder_channels:\n    .neck.append(\n        nn.Sequential(\n            nn.Conv3d(in_channels, out_channels, kernel_size=, bias=),\n            nn.Conv3d(\n                out_channels,\n                out_channels,\n                kernel_size=(, , ),\n                padding=,\n                stride=(, , ),\n                bias=,\n                groups=config.decoder_num_groups,\n            ),\n            get_norm_layer(config.decoder_norm_type, out_channels, config.decoder_num_groups),\n            get_activation(config.decoder_activation, inplace=),\n            nn.Dropout3d(config.decoder_dropout),\n            nn.Conv3d(\n                out_channels, out_channels, kernel_size=(, , ), padding=, bias=, groups=config.decoder_num_groups\n            ),\n            get_norm_layer(config.decoder_norm_type, out_channels, config.decoder_num_groups),\n            get_activation(config.decoder_activation, inplace=),\n        )\n    )\n    in_channels = out_channels\n</code></pre>\n<h4>3D Object Detection Head</h4>\n<p>The final part of the model is the 3D object detection head that predicts class logits and offsets map. </p>\n<pre><code> (nn.Module):\n     ():\n        ().__init__()\n\n         () -&gt; nn.Sequential:\n             nn.Sequential(\n                nn.Conv3d(in_ch, out_ch, kernel_size=head_kernel_size, padding=head_kernel_size // ),\n                get_activation(activation, inplace=),\n                get_norm_layer(norm_type, out_ch, num_groups),\n                nn.Conv3d(out_ch, out_ch, kernel_size=head_kernel_size, padding=head_kernel_size // ),\n                get_activation(activation, inplace=),\n                get_norm_layer(norm_type, out_ch, num_groups),\n            )\n\n        .stride = stride\n\n        .cls_stem = make_conv_block(in_channels, intermediate_channels)\n        .cls_head = nn.Conv3d(intermediate_channels, num_classes, kernel_size=, padding=)\n\n        .offset_stem = make_conv_block(in_channels, offset_intermediate_channels)\n        .offset_head = nn.Conv3d(offset_intermediate_channels, , kernel_size=, padding=)\n\n        torch.nn.init.zeros_(.offset_head.weight)\n        torch.nn.init.constant_(.offset_head.bias, )\n\n\n     ():\n        logits = .cls_head(.cls_stem(features))\n        offsets = .offset_head(.offset_stem(features)).tanh() * stride\n         logits, offsets\n</code></pre>\n<h2>Training</h2>\n<p>I used 4-fold stratified group split. Stratification was done by Voxel size while external data was additionally groupped by dataset id to prevent data leakage.<br>\nFor validation I used only tomos with 0 or 1 motor instances.</p>\n<p>Training epoch used fixed number of random crops (4) per study and fixed number of random crops around each motor instance (8). <br>\nFor data augmentations I used:</p>\n<ul>\n<li>Random flips along X, Y, Z axes.</li>\n<li>Random rotations along Z-axis (+- 180 degrees)</li>\n<li>Random brightness, contrast &amp; gamma alterations</li>\n<li>Slight rotations along X and Y axis (+-10) </li>\n<li>Heavy scale jitter to cover all resolution in 8-20A range (With mode around 13A)</li>\n<li>Additional anisotropic scale jitter +-10% along each axis</li>\n<li>Mixup with 0.5 probability (I found that in this specific competition mixup improved training speed a lot - with mixup I was able to achieve same accuracy within 25 epochs as without mixup it required at least 50 epochs of training).</li>\n</ul>\n<p>During training, I used 0.5x input scale resolution and my input volumes were of size 128x256x256px.</p>\n<h2>Validation</h2>\n<p>For validation, I used sliding window approach with the same window size and 0.5x overlap between tiles. <br>\nDuring validation individual tiles accumulated to final classmap and offsets map and F2 score was computed on the final maps. <br>\nAfter each epoch, I computed  thresholds that maximizes F2 score on the validation set. <br>\nI saved top-5 models for training experiment which I later averaged which almost always increased the F2 score.</p>\n<p>As many users noticed, the local validation was overly optimistic and did not reflect the true performance of the model on the public LB. <br>\nAt first, I thought it was due to the fact that LB contains tomos in higher resolution that released training data.<br>\nHowever, after adding external data to the training set, I found that the local validation score is still much higher than the public LB score.</p>\n<p>Only two days before the end of the competition I found that the reason for this discrepancy is that the public/private LB contains approximately 50/50 positive/negative studies, while the local validation set contains much more positive studies.<br>\nAfter splitting each tomo volume in half ([D,H,W] -&gt; [D,H, W/2:], [D,H,W/2:]) and computing predictions for each half separately, I was able to achieve a much more realistic local validation score that is closer to the public LB score:</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F1504864%2F6759d72f963e467b1c48ab296e6954ce%2Fv16_val_halves_oof_curves_0.45x_128x256_56x72.png?generation=1749133450173405&amp;alt=media\" alt=\"\"></p>\n<p>It was too late to change the training strategy, but I was able to use this knowledge to select the best blending method of our final ensemble.</p>\n<h2>Blenidng</h2>\n<p>Christof's and mine solutions are very different in nature and how raw model's predictions looked like.<br>\nThe only way to take our prediction CSVs and blend them somehow. </p>\n<p>The validation on halves helped us find the best blending method which I called \"Winner takes all with double Otsu\".</p>\n<p>I will explain the Winner takes all approach first and then explain the double Otsu part.</p>\n<h3>Winner takes all</h3>\n<ol>\n<li>Compute normalized ranks for each solution's scores.</li>\n<li>For each prediction, take the solution with the highest rank and use its coordinates and <em>normalized rank instead of raw score</em>.</li>\n</ol>\n<h3>Double Otsu</h3>\n<p>\"Winner takes all with double Otsu\" method extends the Winner takes all approach with an additional step supress negative predictions.</p>\n<ol>\n<li>We take the raw scores from both solutions and compute Otsu threshold for each solution separately. </li>\n<li>We binarize scores array of each prediction using the computed threshold. This gives us two binary masks.</li>\n<li>We use these masks to find the most possible negative predictions (where both masks are 0). If both models agrees (in terms of binary masks) that the prediction is negative, we assign it a score of 0 and set the coordinates to -1.</li>\n<li>Remaining predictions are processed using the Winner takes all approach.</li>\n</ol>\n<pre><code>| Method                         | Score (percentile)   |\n|:-------------------------------|:---------------------|\n| EK Predictions                 | 0.9115 (54.2%)       |\n| CH Predictions                 | 0.9413 (54.7%)       |\n| ---                            | ---                  |\n| Winner Takes All (Rank-based)  | 0.9408 (55.7%)       |\n| Double Otsu Blend              | 0.9421 (54.2%)       |\n</code></pre>\n<p>As you can see, the double Otsu blend increased the score a little bit compared to the Winner takes all approach.</p>\n<p>Other methods like simple averaging, rank-averaging, weighted coordinate blending, re-weighting based in IoU and others did not work well for this competition.</p>\n<p>That what we used for the final submission.</p>\n<h2>Pitfalls</h2>\n<p>Usually, training at <code>bfloat16</code> precision is my default go-to option. Unfortunately not always it works as intended especially when you have to do inference in <code>float16</code> (Due to the fact ONNXRuntime does not support <code>bfloat16</code> inference). Just a days before the competition end I realized my <code>bf16</code> checkpoints are completely unusable at fp16 precision and fp32 is too slow. </p>\n<p>So I had to retrain my models from scratch in fp16-mixed precision (Hello <code>torch.amp</code>!). Imagine my surprise when I've got much higher accuracy of the final models. Not only models ended up more accurate, the training dynamics improved and converted to a best score twice as fast.</p>\n<p>A <code>bfloat16</code> offers great dynamic range, but it comes at a cost of some precision loss which can be crucial. Important not to forget about it :)</p>",
  "messages": [
    {
      "id": "3217922",
      "postDate": "06/05/2025 14:26:43",
      "content": "<pre><code>Throughout the competition there were numerous missile strikes, bombings, and other acts of war that have taken the lives of many innocent people in Ukraine. \nRockets from russia hit within few kilometers from my home in Odesa. Each day Kaggle users from Ukraine facing the chance of not waking up. Just keep in this mind while you read this solution writeup.\n\nI would like to thank the Armed Forces of Ukraine, the Security Service of Ukraine, Defence Intelligence of Ukraine, and the State Emergency Service of Ukraine for providing safety and security to participate in this great competition, complete this work, and help science, technology, and business not to stop but to move forward.\n</code></pre>\n<p>This writeup describes the detection &amp; blending part of our solution for the <a href=\"https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025\" target=\"_blank\">BYU - Locating Bacterial Flagellar Motors 2025</a> competition.<br>\nSome details are omitted as my solution is heavily based upon other 3D detection challenge <a href=\"https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046\" target=\"_blank\">CryoET challenge solution</a>, but I will try to cover the most important parts of the solution.</p>\n<p>I'd like to thank my teammate <a href=\"https://www.kaggle.com/christofhenkel\" target=\"_blank\">@christofhenkel</a> for his great performance and collaboration during the competition. It's a pleasure to work with you, Christof!<br>\nI will not make any spoilers here, but Christof's solution is very out of the box and unique, so I highly recommend you to check out his solution writeup as well:<br>\n<code>&lt;PLACEHOLDER FOR CHRISTOF'S SOLUTION WRITEUP&gt;</code>.</p>\n<h1>TLDR</h1>\n<p>The detection part of the solution is a hybrid 2.5D (2D encoder / 3D decoder) detection model. There were 4 checkpoints (4 folds) in the final ensemble.<br>\nHaving an input volume of shape <code>[D, H, W]</code> the model first reduces the depth of the input volume by a factor of 4 using <code>nn.Conv3d</code> with stride 4 (<code>[D//4, H, W]</code>).<br>\nThen we pass the reduced feature maps through a 2D encoder (<code>maxxvit_rmlp_small_rw_256.sw_in1k</code>) and take the last feature map (<code>[D//4, H//32, W//32]</code>).<br>\nNext we pass it via 3D-CNN decoder to reduce output feature map even more to <code>[D//32, H//32, W//32]</code>. Finally, we pass this feature map to the object detection head which predicts logits and offsets map. <br>\nThe model is trained with a custom loss function that mimics PP-Yolo loss function with a few modifications for 3D detection.<br>\nWe accelerate model inference with NVidia TensorRT to achieve more than 200% speedup compared to eager PyTorch runtime and leverage two T4 GPUs to run predictions in parallel. Total runtime for detection part was ~5 hours.</p>\n<h2>Introduction</h2>\n<p>I started initially with a 3D SegResNet detection model (taken from our <a href=\"https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046\" target=\"_blank\">CryoET challenge solution</a>) solution. <br>\nThe only change I did was to use output stride of 4 instead of 2. <br>\nThis approach was ok, without external data I was able to achieve 0.831 on the public LB. <br>\nHowever, I was not satisfied with the results and wanted to try something different.</p>\n<p>Next, I implemented a hybrid 2.5D (2D encoder / 3D decoder) model also with output stride of 4. <br>\nThis model used a 2D encoder (<code>maxxvit_rmlp_small_rw_256.sw_in1k</code>) and a 3D decoder to reduce the depth of the input volume by a factor of 4.<br>\nAt this point I also added <a href=\"https://www.kaggle.com/datasets/brendanartley/cryoet-flagellar-motors-dataset\" target=\"_blank\">external data</a> by <a href=\"https://www.kaggle.com/brendanartley\" target=\"_blank\">@brendanartley</a> (Kudos for sharing it with the community!). <br>\nThis boosted the public LB score to 0.846. At this point I dropped the idea of fully 3D-CNN and focused on 2.5D models as they we much faster at train and inference.</p>\n<h2>Modeling approach</h2>\n<p>My final approach uses hybrid 3D-2D-3D architecture with a 2D encoder in the middle. <br>\nThe model itself trained for a 3D object detection task, with a training objective matching <a href=\"https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046\" target=\"_blank\">CryoET challenge solution</a>.<br>\nIn the nutshell, model predicts a class map of shape <code>[B, C, D/32, H/32, W/32]</code> and an offsets map of shape <code>[B, 3, D/32, H/32, W/32]</code> where <code>B</code> is the batch size, <code>C</code> is the number of classes (1 in our case), and <code>D</code>, <code>H</code>, <code>W</code> are the depth, height, and width of the input volume respectively.<br>\nInitial 3D convolution with stride 4 reduces the depth of the input volume by a factor of 4, which allows us to use a 2D encoder to extract features from the input volume.</p>\n<h3>Stem</h3>\n<p>The stem of the model is a 3D convolution with kernel size 3x3x3 and stride 4. It serves a purpose of reducing the depth of the input volume and<br>\nextract initial representations from the input data. The stem is followed by an instance normalization layer to normalize the feature maps. <br>\nUse of large kernel size and 8 output channels prevent information loss and extracts meaningful features from the input volume.</p>\n<pre><code>.initial_conv = nn.Sequential(\n    nn.Conv3d(\n        in_channels=,\n        out_channels=,\n        kernel_size=(, , ),\n        stride=(, , ),\n        padding=(, , ),\n        bias=,\n    ),\n    nn.InstanceNorm3d(),\n)\n</code></pre>\n<h3>2D Encoder</h3>\n<p>For the 2D encoder, I used <code>maxxvit_rmlp_small_rw_256.sw_in1k</code> from the <a href=\"https://github.com/huggingface/pytorch-image-models\" target=\"_blank\">timm library</a>.<br>\nThe choice of this specific encoder was motivated by presence of ViT blocks in the architecture, which are known to be effective in capturing long-range dependencies in the data.<br>\nMy assumption was that is it important for the task of detecting bacterial flagellar motors, as these structures are small but exist at the specific locations with regard to the bacterial cell body.<br>\nAnd having a transformer-based architecture in the middle of the model would help to capture these dependencies. </p>\n<pre><code>.backbone = timm.create_model(\n    model_name=,\n    pretrained=,\n    features_only=,\n    in_chans=,\n)\n\n ():\n    \n    \n    x = .initial_conv(x)  \n    B, C, D, H, W = x.shape\n    x = einops.rearrange(x, )\n    features = .backbone(x)\n    features_3d = einops.rearrange(features[-], , d=D)\n     features_3d\n</code></pre>\n<h4>3D Decoder</h4>\n<p>After obtaining the feature maps from the 2D encoder, I pass them through a 3D decoder to reduce the depth of the feature maps by a factor of 8 to obtain the final feature maps of shape <code>[B, C, D/32, H/32, W/32]</code>.<br>\nIn terms of architecture, the decoder consists two <code>Conv3d + Normalization + Activation</code> where first Conv3d layer has stride of 2 in depth dimension.</p>\n<p>The goal of the decoder is to incorporate the spatial information from the 2D encoder into a consistent 3D representation that can be used for object detection.</p>\n<pre><code>in_channels = backbone_channels[-]\nout_channels = \n\n.neck = nn.Sequential()\n out_channels  config.decoder_channels:\n    .neck.append(\n        nn.Sequential(\n            nn.Conv3d(in_channels, out_channels, kernel_size=, bias=),\n            nn.Conv3d(\n                out_channels,\n                out_channels,\n                kernel_size=(, , ),\n                padding=,\n                stride=(, , ),\n                bias=,\n                groups=config.decoder_num_groups,\n            ),\n            get_norm_layer(config.decoder_norm_type, out_channels, config.decoder_num_groups),\n            get_activation(config.decoder_activation, inplace=),\n            nn.Dropout3d(config.decoder_dropout),\n            nn.Conv3d(\n                out_channels, out_channels, kernel_size=(, , ), padding=, bias=, groups=config.decoder_num_groups\n            ),\n            get_norm_layer(config.decoder_norm_type, out_channels, config.decoder_num_groups),\n            get_activation(config.decoder_activation, inplace=),\n        )\n    )\n    in_channels = out_channels\n</code></pre>\n<h4>3D Object Detection Head</h4>\n<p>The final part of the model is the 3D object detection head that predicts class logits and offsets map. </p>\n<pre><code> (nn.Module):\n     ():\n        ().__init__()\n\n         () -&gt; nn.Sequential:\n             nn.Sequential(\n                nn.Conv3d(in_ch, out_ch, kernel_size=head_kernel_size, padding=head_kernel_size // ),\n                get_activation(activation, inplace=),\n                get_norm_layer(norm_type, out_ch, num_groups),\n                nn.Conv3d(out_ch, out_ch, kernel_size=head_kernel_size, padding=head_kernel_size // ),\n                get_activation(activation, inplace=),\n                get_norm_layer(norm_type, out_ch, num_groups),\n            )\n\n        .stride = stride\n\n        .cls_stem = make_conv_block(in_channels, intermediate_channels)\n        .cls_head = nn.Conv3d(intermediate_channels, num_classes, kernel_size=, padding=)\n\n        .offset_stem = make_conv_block(in_channels, offset_intermediate_channels)\n        .offset_head = nn.Conv3d(offset_intermediate_channels, , kernel_size=, padding=)\n\n        torch.nn.init.zeros_(.offset_head.weight)\n        torch.nn.init.constant_(.offset_head.bias, )\n\n\n     ():\n        logits = .cls_head(.cls_stem(features))\n        offsets = .offset_head(.offset_stem(features)).tanh() * stride\n         logits, offsets\n</code></pre>\n<h2>Training</h2>\n<p>I used 4-fold stratified group split. Stratification was done by Voxel size while external data was additionally groupped by dataset id to prevent data leakage.<br>\nFor validation I used only tomos with 0 or 1 motor instances.</p>\n<p>Training epoch used fixed number of random crops (4) per study and fixed number of random crops around each motor instance (8). <br>\nFor data augmentations I used:</p>\n<ul>\n<li>Random flips along X, Y, Z axes.</li>\n<li>Random rotations along Z-axis (+- 180 degrees)</li>\n<li>Random brightness, contrast &amp; gamma alterations</li>\n<li>Slight rotations along X and Y axis (+-10) </li>\n<li>Heavy scale jitter to cover all resolution in 8-20A range (With mode around 13A)</li>\n<li>Additional anisotropic scale jitter +-10% along each axis</li>\n<li>Mixup with 0.5 probability (I found that in this specific competition mixup improved training speed a lot - with mixup I was able to achieve same accuracy within 25 epochs as without mixup it required at least 50 epochs of training).</li>\n</ul>\n<p>During training, I used 0.5x input scale resolution and my input volumes were of size 128x256x256px.</p>\n<h2>Validation</h2>\n<p>For validation, I used sliding window approach with the same window size and 0.5x overlap between tiles. <br>\nDuring validation individual tiles accumulated to final classmap and offsets map and F2 score was computed on the final maps. <br>\nAfter each epoch, I computed  thresholds that maximizes F2 score on the validation set. <br>\nI saved top-5 models for training experiment which I later averaged which almost always increased the F2 score.</p>\n<p>As many users noticed, the local validation was overly optimistic and did not reflect the true performance of the model on the public LB. <br>\nAt first, I thought it was due to the fact that LB contains tomos in higher resolution that released training data.<br>\nHowever, after adding external data to the training set, I found that the local validation score is still much higher than the public LB score.</p>\n<p>Only two days before the end of the competition I found that the reason for this discrepancy is that the public/private LB contains approximately 50/50 positive/negative studies, while the local validation set contains much more positive studies.<br>\nAfter splitting each tomo volume in half ([D,H,W] -&gt; [D,H, W/2:], [D,H,W/2:]) and computing predictions for each half separately, I was able to achieve a much more realistic local validation score that is closer to the public LB score:</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F1504864%2F6759d72f963e467b1c48ab296e6954ce%2Fv16_val_halves_oof_curves_0.45x_128x256_56x72.png?generation=1749133450173405&amp;alt=media\" alt=\"\"></p>\n<p>It was too late to change the training strategy, but I was able to use this knowledge to select the best blending method of our final ensemble.</p>\n<h2>Blenidng</h2>\n<p>Christof's and mine solutions are very different in nature and how raw model's predictions looked like.<br>\nThe only way to take our prediction CSVs and blend them somehow. </p>\n<p>The validation on halves helped us find the best blending method which I called \"Winner takes all with double Otsu\".</p>\n<p>I will explain the Winner takes all approach first and then explain the double Otsu part.</p>\n<h3>Winner takes all</h3>\n<ol>\n<li>Compute normalized ranks for each solution's scores.</li>\n<li>For each prediction, take the solution with the highest rank and use its coordinates and <em>normalized rank instead of raw score</em>.</li>\n</ol>\n<h3>Double Otsu</h3>\n<p>\"Winner takes all with double Otsu\" method extends the Winner takes all approach with an additional step supress negative predictions.</p>\n<ol>\n<li>We take the raw scores from both solutions and compute Otsu threshold for each solution separately. </li>\n<li>We binarize scores array of each prediction using the computed threshold. This gives us two binary masks.</li>\n<li>We use these masks to find the most possible negative predictions (where both masks are 0). If both models agrees (in terms of binary masks) that the prediction is negative, we assign it a score of 0 and set the coordinates to -1.</li>\n<li>Remaining predictions are processed using the Winner takes all approach.</li>\n</ol>\n<pre><code>| Method                         | Score (percentile)   |\n|:-------------------------------|:---------------------|\n| EK Predictions                 | 0.9115 (54.2%)       |\n| CH Predictions                 | 0.9413 (54.7%)       |\n| ---                            | ---                  |\n| Winner Takes All (Rank-based)  | 0.9408 (55.7%)       |\n| Double Otsu Blend              | 0.9421 (54.2%)       |\n</code></pre>\n<p>As you can see, the double Otsu blend increased the score a little bit compared to the Winner takes all approach.</p>\n<p>Other methods like simple averaging, rank-averaging, weighted coordinate blending, re-weighting based in IoU and others did not work well for this competition.</p>\n<p>That what we used for the final submission.</p>\n<h2>Pitfalls</h2>\n<p>Usually, training at <code>bfloat16</code> precision is my default go-to option. Unfortunately not always it works as intended especially when you have to do inference in <code>float16</code> (Due to the fact ONNXRuntime does not support <code>bfloat16</code> inference). Just a days before the competition end I realized my <code>bf16</code> checkpoints are completely unusable at fp16 precision and fp32 is too slow. </p>\n<p>So I had to retrain my models from scratch in fp16-mixed precision (Hello <code>torch.amp</code>!). Imagine my surprise when I've got much higher accuracy of the final models. Not only models ended up more accurate, the training dynamics improved and converted to a best score twice as fast.</p>\n<p>A <code>bfloat16</code> offers great dynamic range, but it comes at a cost of some precision loss which can be crucial. Important not to forget about it :)</p>",
      "rawMarkdown": "```markdown\nThroughout the competition there were numerous missile strikes, bombings, and other acts of war that have taken the lives of many innocent people in Ukraine. \nRockets from russia hit within few kilometers from my home in Odesa. Each day Kaggle users from Ukraine facing the chance of not waking up. Just keep in this mind while you read this solution writeup.\n\nI would like to thank the Armed Forces of Ukraine, the Security Service of Ukraine, Defence Intelligence of Ukraine, and the State Emergency Service of Ukraine for providing safety and security to participate in this great competition, complete this work, and help science, technology, and business not to stop but to move forward.\n```\n\nThis writeup describes the detection & blending part of our solution for the [BYU - Locating Bacterial Flagellar Motors 2025](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025) competition.\nSome details are omitted as my solution is heavily based upon other 3D detection challenge [CryoET challenge solution](https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046), but I will try to cover the most important parts of the solution.\n\nI'd like to thank my teammate @christofhenkel for his great performance and collaboration during the competition. It's a pleasure to work with you, Christof!\nI will not make any spoilers here, but Christof's solution is very out of the box and unique, so I highly recommend you to check out his solution writeup as well:\n`<PLACEHOLDER FOR CHRISTOF'S SOLUTION WRITEUP>`.\n\n# TLDR\nThe detection part of the solution is a hybrid 2.5D (2D encoder / 3D decoder) detection model. There were 4 checkpoints (4 folds) in the final ensemble.\nHaving an input volume of shape `[D, H, W]` the model first reduces the depth of the input volume by a factor of 4 using `nn.Conv3d` with stride 4 (`[D//4, H, W]`).\nThen we pass the reduced feature maps through a 2D encoder (`maxxvit_rmlp_small_rw_256.sw_in1k`) and take the last feature map (`[D//4, H//32, W//32]`).\nNext we pass it via 3D-CNN decoder to reduce output feature map even more to `[D//32, H//32, W//32]`. Finally, we pass this feature map to the object detection head which predicts logits and offsets map. \nThe model is trained with a custom loss function that mimics PP-Yolo loss function with a few modifications for 3D detection.\nWe accelerate model inference with NVidia TensorRT to achieve more than 200% speedup compared to eager PyTorch runtime and leverage two T4 GPUs to run predictions in parallel. Total runtime for detection part was ~5 hours.\n\n## Introduction\n\nI started initially with a 3D SegResNet detection model (taken from our [CryoET challenge solution](https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046)) solution. \nThe only change I did was to use output stride of 4 instead of 2. \nThis approach was ok, without external data I was able to achieve 0.831 on the public LB. \nHowever, I was not satisfied with the results and wanted to try something different.\n\nNext, I implemented a hybrid 2.5D (2D encoder / 3D decoder) model also with output stride of 4. \nThis model used a 2D encoder (`maxxvit_rmlp_small_rw_256.sw_in1k`) and a 3D decoder to reduce the depth of the input volume by a factor of 4.\nAt this point I also added [external data](https://www.kaggle.com/datasets/brendanartley/cryoet-flagellar-motors-dataset) by @brendanartley (Kudos for sharing it with the community!). \nThis boosted the public LB score to 0.846. At this point I dropped the idea of fully 3D-CNN and focused on 2.5D models as they we much faster at train and inference.\n\n## Modeling approach \n\nMy final approach uses hybrid 3D-2D-3D architecture with a 2D encoder in the middle. \nThe model itself trained for a 3D object detection task, with a training objective matching [CryoET challenge solution](https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046).\nIn the nutshell, model predicts a class map of shape `[B, C, D/32, H/32, W/32]` and an offsets map of shape `[B, 3, D/32, H/32, W/32]` where `B` is the batch size, `C` is the number of classes (1 in our case), and `D`, `H`, `W` are the depth, height, and width of the input volume respectively.\nInitial 3D convolution with stride 4 reduces the depth of the input volume by a factor of 4, which allows us to use a 2D encoder to extract features from the input volume.\n\n### Stem\n\nThe stem of the model is a 3D convolution with kernel size 3x3x3 and stride 4. It serves a purpose of reducing the depth of the input volume and\nextract initial representations from the input data. The stem is followed by an instance normalization layer to normalize the feature maps. \nUse of large kernel size and 8 output channels prevent information loss and extracts meaningful features from the input volume.\n\n```python\nself.initial_conv = nn.Sequential(\n    nn.Conv3d(\n        in_channels=1,\n        out_channels=8,\n        kernel_size=(7, 3, 3),\n        stride=(4, 1, 1),\n        padding=(3, 1, 1),\n        bias=False,\n    ),\n    nn.InstanceNorm3d(8),\n)\n```\n\n### 2D Encoder\n\nFor the 2D encoder, I used `maxxvit_rmlp_small_rw_256.sw_in1k` from the [timm library](https://github.com/huggingface/pytorch-image-models).\nThe choice of this specific encoder was motivated by presence of ViT blocks in the architecture, which are known to be effective in capturing long-range dependencies in the data.\nMy assumption was that is it important for the task of detecting bacterial flagellar motors, as these structures are small but exist at the specific locations with regard to the bacterial cell body.\nAnd having a transformer-based architecture in the middle of the model would help to capture these dependencies. \n\n```python\nself.backbone = timm.create_model(\n    model_name=\"maxxvit_rmlp_small_rw_256.sw_in1k\",\n    pretrained=True,\n    features_only=True,\n    in_chans=8,\n)\n\ndef forward_25d_backbone(self, x: torch.Tensor):\n    \"\"\"Process 3D input through 2D backbone.\n\n    Args:\n        x: Input tensor of shape (B, C, D, H, W)\n\n    Returns:\n        List of feature maps, each of shape (B, C_i, D // self.initial_conv_stride, H_i, W_i)\n        where D_i = D // d_stride for all feature maps\n    \"\"\"\n    # Apply initial 3D convolution to reduce D dimension\n    x = self.initial_conv(x)  # Now D dimension is reduced by d_stride\n    B, C, D, H, W = x.shape\n    x = einops.rearrange(x, \"b c d h w -> (b d) c h w\")\n    features = self.backbone(x)\n    features_3d = einops.rearrange(features[-1], \"(b d) c h w -> b c d h w\", d=D)\n    return features_3d\n```\n\n#### 3D Decoder\n\nAfter obtaining the feature maps from the 2D encoder, I pass them through a 3D decoder to reduce the depth of the feature maps by a factor of 8 to obtain the final feature maps of shape `[B, C, D/32, H/32, W/32]`.\nIn terms of architecture, the decoder consists two `Conv3d + Normalization + Activation` where first Conv3d layer has stride of 2 in depth dimension.\n\nThe goal of the decoder is to incorporate the spatial information from the 2D encoder into a consistent 3D representation that can be used for object detection.\n\n```python\nin_channels = backbone_channels[-1]\nout_channels = None\n\nself.neck = nn.Sequential()\nfor out_channels in config.decoder_channels:\n    self.neck.append(\n        nn.Sequential(\n            nn.Conv3d(in_channels, out_channels, kernel_size=1, bias=False),\n            nn.Conv3d(\n                out_channels,\n                out_channels,\n                kernel_size=(3, 3, 3),\n                padding=1,\n                stride=(2, 1, 1),\n                bias=False,\n                groups=config.decoder_num_groups,\n            ),\n            get_norm_layer(config.decoder_norm_type, out_channels, config.decoder_num_groups),\n            get_activation(config.decoder_activation, inplace=True),\n            nn.Dropout3d(config.decoder_dropout),\n            nn.Conv3d(\n                out_channels, out_channels, kernel_size=(3, 3, 3), padding=1, bias=False, groups=config.decoder_num_groups\n            ),\n            get_norm_layer(config.decoder_norm_type, out_channels, config.decoder_num_groups),\n            get_activation(config.decoder_activation, inplace=True),\n        )\n    )\n    in_channels = out_channels\n```\n\n#### 3D Object Detection Head\n\nThe final part of the model is the 3D object detection head that predicts class logits and offsets map. \n\n```python\n\nclass ObjectDetectionHead(nn.Module):\n    def __init__(\n        self,\n        in_channels: int,\n        num_classes: int,\n        stride: int,\n        head_kernel_size: int = 3,\n        intermediate_channels: int = 64,\n        offset_intermediate_channels: int = 32,\n        norm_type: Literal[\"instance\", \"batch\", \"group\"] = \"instance\",\n        num_groups: int = 32,\n        activation: str = \"silu\",\n    ):\n        super().__init__()\n\n        def make_conv_block(in_ch: int, out_ch: int) -> nn.Sequential:\n            return nn.Sequential(\n                nn.Conv3d(in_ch, out_ch, kernel_size=head_kernel_size, padding=head_kernel_size // 2),\n                get_activation(activation, inplace=True),\n                get_norm_layer(norm_type, out_ch, num_groups),\n                nn.Conv3d(out_ch, out_ch, kernel_size=head_kernel_size, padding=head_kernel_size // 2),\n                get_activation(activation, inplace=True),\n                get_norm_layer(norm_type, out_ch, num_groups),\n            )\n\n        self.stride = stride\n\n        self.cls_stem = make_conv_block(in_channels, intermediate_channels)\n        self.cls_head = nn.Conv3d(intermediate_channels, num_classes, kernel_size=1, padding=0)\n\n        self.offset_stem = make_conv_block(in_channels, offset_intermediate_channels)\n        self.offset_head = nn.Conv3d(offset_intermediate_channels, 3, kernel_size=1, padding=0)\n\n        torch.nn.init.zeros_(self.offset_head.weight)\n        torch.nn.init.constant_(self.offset_head.bias, 0)\n\n\n    def forward(self, features):\n        logits = self.cls_head(self.cls_stem(features))\n        offsets = self.offset_head(self.offset_stem(features)).tanh() * stride\n        return logits, offsets\n```\n\n\n\n## Training\n\nI used 4-fold stratified group split. Stratification was done by Voxel size while external data was additionally groupped by dataset id to prevent data leakage.\nFor validation I used only tomos with 0 or 1 motor instances.\n\nTraining epoch used fixed number of random crops (4) per study and fixed number of random crops around each motor instance (8). \nFor data augmentations I used:\n* Random flips along X, Y, Z axes.\n* Random rotations along Z-axis (+- 180 degrees)\n* Random brightness, contrast & gamma alterations\n* Slight rotations along X and Y axis (+-10) \n* Heavy scale jitter to cover all resolution in 8-20A range (With mode around 13A)\n* Additional anisotropic scale jitter +-10% along each axis\n* Mixup with 0.5 probability (I found that in this specific competition mixup improved training speed a lot - with mixup I was able to achieve same accuracy within 25 epochs as without mixup it required at least 50 epochs of training).\n\nDuring training, I used 0.5x input scale resolution and my input volumes were of size 128x256x256px.\n\n## Validation\n\nFor validation, I used sliding window approach with the same window size and 0.5x overlap between tiles. \nDuring validation individual tiles accumulated to final classmap and offsets map and F2 score was computed on the final maps. \nAfter each epoch, I computed  thresholds that maximizes F2 score on the validation set. \nI saved top-5 models for training experiment which I later averaged which almost always increased the F2 score.\n\nAs many users noticed, the local validation was overly optimistic and did not reflect the true performance of the model on the public LB. \nAt first, I thought it was due to the fact that LB contains tomos in higher resolution that released training data.\nHowever, after adding external data to the training set, I found that the local validation score is still much higher than the public LB score.\n\nOnly two days before the end of the competition I found that the reason for this discrepancy is that the public/private LB contains approximately 50/50 positive/negative studies, while the local validation set contains much more positive studies.\nAfter splitting each tomo volume in half ([D,H,W] -> [D,H, W/2:], [D,H,W/2:]) and computing predictions for each half separately, I was able to achieve a much more realistic local validation score that is closer to the public LB score:\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F1504864%2F6759d72f963e467b1c48ab296e6954ce%2Fv16_val_halves_oof_curves_0.45x_128x256_56x72.png?generation=1749133450173405&alt=media)\n\nIt was too late to change the training strategy, but I was able to use this knowledge to select the best blending method of our final ensemble.\n\n## Blenidng\n\nChristof's and mine solutions are very different in nature and how raw model's predictions looked like.\nThe only way to take our prediction CSVs and blend them somehow. \n\nThe validation on halves helped us find the best blending method which I called \"Winner takes all with double Otsu\".\n\nI will explain the Winner takes all approach first and then explain the double Otsu part.\n\n### Winner takes all\n\n1. Compute normalized ranks for each solution's scores.\n2. For each prediction, take the solution with the highest rank and use its coordinates and *normalized rank instead of raw score*.\n\n### Double Otsu\n\n\"Winner takes all with double Otsu\" method extends the Winner takes all approach with an additional step supress negative predictions.\n\n1. We take the raw scores from both solutions and compute Otsu threshold for each solution separately. \n2. We binarize scores array of each prediction using the computed threshold. This gives us two binary masks.\n3. We use these masks to find the most possible negative predictions (where both masks are 0). If both models agrees (in terms of binary masks) that the prediction is negative, we assign it a score of 0 and set the coordinates to -1.\n4. Remaining predictions are processed using the Winner takes all approach.\n\n```markdown\n| Method                         | Score (percentile)   |\n|:-------------------------------|:---------------------|\n| EK Predictions                 | 0.9115 (54.2%)       |\n| CH Predictions                 | 0.9413 (54.7%)       |\n| ---                            | ---                  |\n| Winner Takes All (Rank-based)  | 0.9408 (55.7%)       |\n| Double Otsu Blend              | 0.9421 (54.2%)       |\n```\n\nAs you can see, the double Otsu blend increased the score a little bit compared to the Winner takes all approach.\n\nOther methods like simple averaging, rank-averaging, weighted coordinate blending, re-weighting based in IoU and others did not work well for this competition.\n\nThat what we used for the final submission.\n\n## Pitfalls\n\nUsually, training at `bfloat16` precision is my default go-to option. Unfortunately not always it works as intended especially when you have to do inference in `float16` (Due to the fact ONNXRuntime does not support `bfloat16` inference). Just a days before the competition end I realized my `bf16` checkpoints are completely unusable at fp16 precision and fp32 is too slow. \n\nSo I had to retrain my models from scratch in fp16-mixed precision (Hello `torch.amp`!). Imagine my surprise when I've got much higher accuracy of the final models. Not only models ended up more accurate, the training dynamics improved and converted to a best score twice as fast.\n\nA `bfloat16` offers great dynamic range, but it comes at a cost of some precision loss which can be crucial. Important not to forget about it :)",
      "votes": null
    },
    {
      "id": "3218350",
      "postDate": "06/06/2025 05:24:43",
      "content": "<p><a href=\"https://www.kaggle.com/bloodaxe\" target=\"_blank\">@bloodaxe</a> Congratulations on the 4th place!<br>\nSplitting the validation tomograms in half was a really clever idea.</p>\n<p>By the way, as mentioned in the public discussions, the positive tomograms seem to contain false negatives (i.e., unlabeled motors).<br>\nDid you apply any data correction or adjustments to address this?</p>",
      "rawMarkdown": "bloodaxe Congratulations on the 4th place!\nSplitting the validation tomograms in half was a really clever idea.\n\nBy the way, as mentioned in the public discussions, the positive tomograms seem to contain false negatives (i.e., unlabeled motors).\nDid you apply any data correction or adjustments to address this?",
      "votes": null
    },
    {
      "id": "3218508",
      "postDate": "06/06/2025 09:30:03",
      "content": "<p>Yes, I saw the mentions of mistakes (both FP and FN) in the tomo. But I never got enough time to reannotate / correct those mistakes.<br>\nIn fact I ever wrote a script to some semi-automatic active learning, but as it often happens on kaggle - you have to give up some things in order to win a fight against time :) Instead I relied on extra data in hope that it will dilute those mistakes enough 🤷‍♂️ </p>",
      "rawMarkdown": "Yes, I saw the mentions of mistakes (both FP and FN) in the tomo. But I never got enough time to reannotate / correct those mistakes.\nIn fact I ever wrote a script to some semi-automatic active learning, but as it often happens on kaggle - you have to give up some things in order to win a fight against time :) Instead I relied on extra data in hope that it will dilute those mistakes enough 🤷‍♂️",
      "votes": null
    },
    {
      "id": "3218733",
      "postDate": "06/06/2025 16:25:01",
      "content": "<p>Congrats, and nice write-up <a href=\"https://www.kaggle.com/bloodaxe\" target=\"_blank\">@bloodaxe</a>! The \"splitting each tomo volume in half\" validation strategy is a very clever idea.</p>\n<p>What is anisotropic scale jitter? </p>",
      "rawMarkdown": "Congrats, and nice write-up @bloodaxe! The \"splitting each tomo volume in half\" validation strategy is a very clever idea.\n\nWhat is anisotropic scale jitter?",
      "votes": null
    },
    {
      "id": "3219309",
      "postDate": "06/07/2025 13:13:31",
      "content": "<p>Anisotropic scale resizes input volume at different scale factors along each axis. This is purely extra data augmentation to prevent overfitting and make the model more robust to “squished bacteria” (if this is a thing). I use +-10% of along each scale.</p>\n<p>This extend the isotropic scale jitter (+-75%, but capped to ensure effective resolution stays in 8-20A/px). And technically all scale and rotation operations are making a final 4x4 transformation matrix that encodes translation, rotation and scale transformations which represents random rotation, cropping and scale. </p>\n<p>By having only one volume resamplibg op I preserve the most of the signal and also save the time as volume resample is somewhat costly operation.</p>",
      "rawMarkdown": "Anisotropic scale resizes input volume at different scale factors along each axis. This is purely extra data augmentation to prevent overfitting and make the model more robust to “squished bacteria” (if this is a thing). I use +-10% of along each scale.\n\nThis extend the isotropic scale jitter (+-75%, but capped to ensure effective resolution stays in 8-20A/px). And technically all scale and rotation operations are making a final 4x4 transformation matrix that encodes translation, rotation and scale transformations which represents random rotation, cropping and scale. \n\nBy having only one volume resamplibg op I preserve the most of the signal and also save the time as volume resample is somewhat costly operation.",
      "votes": null
    },
    {
      "id": "3219989",
      "postDate": "06/08/2025 15:49:47",
      "content": "<p><a href=\"https://www.kaggle.com/bloodaxe\" target=\"_blank\">@bloodaxe</a> Congrats! Pray for Ukraine 🙏 </p>",
      "rawMarkdown": "bloodaxe Congrats! Pray for Ukraine 🙏",
      "votes": null
    }
  ],
  "comments": [
    {
      "id": 3218350,
      "author_name": "tatamikenn",
      "author_url": "",
      "post_date": "06/06/2025 05:24:43",
      "content": "<p><a href=\"https://www.kaggle.com/bloodaxe\" target=\"_blank\">@bloodaxe</a> Congratulations on the 4th place!<br>\nSplitting the validation tomograms in half was a really clever idea.</p>\n<p>By the way, as mentioned in the public discussions, the positive tomograms seem to contain false negatives (i.e., unlabeled motors).<br>\nDid you apply any data correction or adjustments to address this?</p>",
      "votes": null,
      "replies": [
        {
          "id": 3218508,
          "author_name": "bloodaxe",
          "author_url": "",
          "post_date": "06/06/2025 09:30:03",
          "content": "<p>Yes, I saw the mentions of mistakes (both FP and FN) in the tomo. But I never got enough time to reannotate / correct those mistakes.<br>\nIn fact I ever wrote a script to some semi-automatic active learning, but as it often happens on kaggle - you have to give up some things in order to win a fight against time :) Instead I relied on extra data in hope that it will dilute those mistakes enough 🤷‍♂️ </p>",
          "votes": null,
          "replies": []
        }
      ]
    },
    {
      "id": 3218733,
      "author_name": "brendanartley",
      "author_url": "",
      "post_date": "06/06/2025 16:25:01",
      "content": "<p>Congrats, and nice write-up <a href=\"https://www.kaggle.com/bloodaxe\" target=\"_blank\">@bloodaxe</a>! The \"splitting each tomo volume in half\" validation strategy is a very clever idea.</p>\n<p>What is anisotropic scale jitter? </p>",
      "votes": null,
      "replies": [
        {
          "id": 3219309,
          "author_name": "bloodaxe",
          "author_url": "",
          "post_date": "06/07/2025 13:13:31",
          "content": "<p>Anisotropic scale resizes input volume at different scale factors along each axis. This is purely extra data augmentation to prevent overfitting and make the model more robust to “squished bacteria” (if this is a thing). I use +-10% of along each scale.</p>\n<p>This extend the isotropic scale jitter (+-75%, but capped to ensure effective resolution stays in 8-20A/px). And technically all scale and rotation operations are making a final 4x4 transformation matrix that encodes translation, rotation and scale transformations which represents random rotation, cropping and scale. </p>\n<p>By having only one volume resamplibg op I preserve the most of the signal and also save the time as volume resample is somewhat costly operation.</p>",
          "votes": null,
          "replies": []
        }
      ]
    },
    {
      "id": 3219989,
      "author_name": "tom99763",
      "author_url": "",
      "post_date": "06/08/2025 15:49:47",
      "content": "<p><a href=\"https://www.kaggle.com/bloodaxe\" target=\"_blank\">@bloodaxe</a> Congrats! Pray for Ukraine 🙏 </p>",
      "votes": null,
      "replies": []
    }
  ],
  "raw_markdown_by_id": {
    "3217922": "```markdown\nThroughout the competition there were numerous missile strikes, bombings, and other acts of war that have taken the lives of many innocent people in Ukraine. \nRockets from russia hit within few kilometers from my home in Odesa. Each day Kaggle users from Ukraine facing the chance of not waking up. Just keep in this mind while you read this solution writeup.\n\nI would like to thank the Armed Forces of Ukraine, the Security Service of Ukraine, Defence Intelligence of Ukraine, and the State Emergency Service of Ukraine for providing safety and security to participate in this great competition, complete this work, and help science, technology, and business not to stop but to move forward.\n```\n\nThis writeup describes the detection & blending part of our solution for the [BYU - Locating Bacterial Flagellar Motors 2025](https://www.kaggle.com/competitions/byu-locating-bacterial-flagellar-motors-2025) competition.\nSome details are omitted as my solution is heavily based upon other 3D detection challenge [CryoET challenge solution](https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046), but I will try to cover the most important parts of the solution.\n\nI'd like to thank my teammate @christofhenkel for his great performance and collaboration during the competition. It's a pleasure to work with you, Christof!\nI will not make any spoilers here, but Christof's solution is very out of the box and unique, so I highly recommend you to check out his solution writeup as well:\n`<PLACEHOLDER FOR CHRISTOF'S SOLUTION WRITEUP>`.\n\n# TLDR\nThe detection part of the solution is a hybrid 2.5D (2D encoder / 3D decoder) detection model. There were 4 checkpoints (4 folds) in the final ensemble.\nHaving an input volume of shape `[D, H, W]` the model first reduces the depth of the input volume by a factor of 4 using `nn.Conv3d` with stride 4 (`[D//4, H, W]`).\nThen we pass the reduced feature maps through a 2D encoder (`maxxvit_rmlp_small_rw_256.sw_in1k`) and take the last feature map (`[D//4, H//32, W//32]`).\nNext we pass it via 3D-CNN decoder to reduce output feature map even more to `[D//32, H//32, W//32]`. Finally, we pass this feature map to the object detection head which predicts logits and offsets map. \nThe model is trained with a custom loss function that mimics PP-Yolo loss function with a few modifications for 3D detection.\nWe accelerate model inference with NVidia TensorRT to achieve more than 200% speedup compared to eager PyTorch runtime and leverage two T4 GPUs to run predictions in parallel. Total runtime for detection part was ~5 hours.\n\n## Introduction\n\nI started initially with a 3D SegResNet detection model (taken from our [CryoET challenge solution](https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046)) solution. \nThe only change I did was to use output stride of 4 instead of 2. \nThis approach was ok, without external data I was able to achieve 0.831 on the public LB. \nHowever, I was not satisfied with the results and wanted to try something different.\n\nNext, I implemented a hybrid 2.5D (2D encoder / 3D decoder) model also with output stride of 4. \nThis model used a 2D encoder (`maxxvit_rmlp_small_rw_256.sw_in1k`) and a 3D decoder to reduce the depth of the input volume by a factor of 4.\nAt this point I also added [external data](https://www.kaggle.com/datasets/brendanartley/cryoet-flagellar-motors-dataset) by @brendanartley (Kudos for sharing it with the community!). \nThis boosted the public LB score to 0.846. At this point I dropped the idea of fully 3D-CNN and focused on 2.5D models as they we much faster at train and inference.\n\n## Modeling approach \n\nMy final approach uses hybrid 3D-2D-3D architecture with a 2D encoder in the middle. \nThe model itself trained for a 3D object detection task, with a training objective matching [CryoET challenge solution](https://www.kaggle.com/competitions/czii-cryo-et-object-identification/discussion/561440#3117046).\nIn the nutshell, model predicts a class map of shape `[B, C, D/32, H/32, W/32]` and an offsets map of shape `[B, 3, D/32, H/32, W/32]` where `B` is the batch size, `C` is the number of classes (1 in our case), and `D`, `H`, `W` are the depth, height, and width of the input volume respectively.\nInitial 3D convolution with stride 4 reduces the depth of the input volume by a factor of 4, which allows us to use a 2D encoder to extract features from the input volume.\n\n### Stem\n\nThe stem of the model is a 3D convolution with kernel size 3x3x3 and stride 4. It serves a purpose of reducing the depth of the input volume and\nextract initial representations from the input data. The stem is followed by an instance normalization layer to normalize the feature maps. \nUse of large kernel size and 8 output channels prevent information loss and extracts meaningful features from the input volume.\n\n```python\nself.initial_conv = nn.Sequential(\n    nn.Conv3d(\n        in_channels=1,\n        out_channels=8,\n        kernel_size=(7, 3, 3),\n        stride=(4, 1, 1),\n        padding=(3, 1, 1),\n        bias=False,\n    ),\n    nn.InstanceNorm3d(8),\n)\n```\n\n### 2D Encoder\n\nFor the 2D encoder, I used `maxxvit_rmlp_small_rw_256.sw_in1k` from the [timm library](https://github.com/huggingface/pytorch-image-models).\nThe choice of this specific encoder was motivated by presence of ViT blocks in the architecture, which are known to be effective in capturing long-range dependencies in the data.\nMy assumption was that is it important for the task of detecting bacterial flagellar motors, as these structures are small but exist at the specific locations with regard to the bacterial cell body.\nAnd having a transformer-based architecture in the middle of the model would help to capture these dependencies. \n\n```python\nself.backbone = timm.create_model(\n    model_name=\"maxxvit_rmlp_small_rw_256.sw_in1k\",\n    pretrained=True,\n    features_only=True,\n    in_chans=8,\n)\n\ndef forward_25d_backbone(self, x: torch.Tensor):\n    \"\"\"Process 3D input through 2D backbone.\n\n    Args:\n        x: Input tensor of shape (B, C, D, H, W)\n\n    Returns:\n        List of feature maps, each of shape (B, C_i, D // self.initial_conv_stride, H_i, W_i)\n        where D_i = D // d_stride for all feature maps\n    \"\"\"\n    # Apply initial 3D convolution to reduce D dimension\n    x = self.initial_conv(x)  # Now D dimension is reduced by d_stride\n    B, C, D, H, W = x.shape\n    x = einops.rearrange(x, \"b c d h w -> (b d) c h w\")\n    features = self.backbone(x)\n    features_3d = einops.rearrange(features[-1], \"(b d) c h w -> b c d h w\", d=D)\n    return features_3d\n```\n\n#### 3D Decoder\n\nAfter obtaining the feature maps from the 2D encoder, I pass them through a 3D decoder to reduce the depth of the feature maps by a factor of 8 to obtain the final feature maps of shape `[B, C, D/32, H/32, W/32]`.\nIn terms of architecture, the decoder consists two `Conv3d + Normalization + Activation` where first Conv3d layer has stride of 2 in depth dimension.\n\nThe goal of the decoder is to incorporate the spatial information from the 2D encoder into a consistent 3D representation that can be used for object detection.\n\n```python\nin_channels = backbone_channels[-1]\nout_channels = None\n\nself.neck = nn.Sequential()\nfor out_channels in config.decoder_channels:\n    self.neck.append(\n        nn.Sequential(\n            nn.Conv3d(in_channels, out_channels, kernel_size=1, bias=False),\n            nn.Conv3d(\n                out_channels,\n                out_channels,\n                kernel_size=(3, 3, 3),\n                padding=1,\n                stride=(2, 1, 1),\n                bias=False,\n                groups=config.decoder_num_groups,\n            ),\n            get_norm_layer(config.decoder_norm_type, out_channels, config.decoder_num_groups),\n            get_activation(config.decoder_activation, inplace=True),\n            nn.Dropout3d(config.decoder_dropout),\n            nn.Conv3d(\n                out_channels, out_channels, kernel_size=(3, 3, 3), padding=1, bias=False, groups=config.decoder_num_groups\n            ),\n            get_norm_layer(config.decoder_norm_type, out_channels, config.decoder_num_groups),\n            get_activation(config.decoder_activation, inplace=True),\n        )\n    )\n    in_channels = out_channels\n```\n\n#### 3D Object Detection Head\n\nThe final part of the model is the 3D object detection head that predicts class logits and offsets map. \n\n```python\n\nclass ObjectDetectionHead(nn.Module):\n    def __init__(\n        self,\n        in_channels: int,\n        num_classes: int,\n        stride: int,\n        head_kernel_size: int = 3,\n        intermediate_channels: int = 64,\n        offset_intermediate_channels: int = 32,\n        norm_type: Literal[\"instance\", \"batch\", \"group\"] = \"instance\",\n        num_groups: int = 32,\n        activation: str = \"silu\",\n    ):\n        super().__init__()\n\n        def make_conv_block(in_ch: int, out_ch: int) -> nn.Sequential:\n            return nn.Sequential(\n                nn.Conv3d(in_ch, out_ch, kernel_size=head_kernel_size, padding=head_kernel_size // 2),\n                get_activation(activation, inplace=True),\n                get_norm_layer(norm_type, out_ch, num_groups),\n                nn.Conv3d(out_ch, out_ch, kernel_size=head_kernel_size, padding=head_kernel_size // 2),\n                get_activation(activation, inplace=True),\n                get_norm_layer(norm_type, out_ch, num_groups),\n            )\n\n        self.stride = stride\n\n        self.cls_stem = make_conv_block(in_channels, intermediate_channels)\n        self.cls_head = nn.Conv3d(intermediate_channels, num_classes, kernel_size=1, padding=0)\n\n        self.offset_stem = make_conv_block(in_channels, offset_intermediate_channels)\n        self.offset_head = nn.Conv3d(offset_intermediate_channels, 3, kernel_size=1, padding=0)\n\n        torch.nn.init.zeros_(self.offset_head.weight)\n        torch.nn.init.constant_(self.offset_head.bias, 0)\n\n\n    def forward(self, features):\n        logits = self.cls_head(self.cls_stem(features))\n        offsets = self.offset_head(self.offset_stem(features)).tanh() * stride\n        return logits, offsets\n```\n\n\n\n## Training\n\nI used 4-fold stratified group split. Stratification was done by Voxel size while external data was additionally groupped by dataset id to prevent data leakage.\nFor validation I used only tomos with 0 or 1 motor instances.\n\nTraining epoch used fixed number of random crops (4) per study and fixed number of random crops around each motor instance (8). \nFor data augmentations I used:\n* Random flips along X, Y, Z axes.\n* Random rotations along Z-axis (+- 180 degrees)\n* Random brightness, contrast & gamma alterations\n* Slight rotations along X and Y axis (+-10) \n* Heavy scale jitter to cover all resolution in 8-20A range (With mode around 13A)\n* Additional anisotropic scale jitter +-10% along each axis\n* Mixup with 0.5 probability (I found that in this specific competition mixup improved training speed a lot - with mixup I was able to achieve same accuracy within 25 epochs as without mixup it required at least 50 epochs of training).\n\nDuring training, I used 0.5x input scale resolution and my input volumes were of size 128x256x256px.\n\n## Validation\n\nFor validation, I used sliding window approach with the same window size and 0.5x overlap between tiles. \nDuring validation individual tiles accumulated to final classmap and offsets map and F2 score was computed on the final maps. \nAfter each epoch, I computed  thresholds that maximizes F2 score on the validation set. \nI saved top-5 models for training experiment which I later averaged which almost always increased the F2 score.\n\nAs many users noticed, the local validation was overly optimistic and did not reflect the true performance of the model on the public LB. \nAt first, I thought it was due to the fact that LB contains tomos in higher resolution that released training data.\nHowever, after adding external data to the training set, I found that the local validation score is still much higher than the public LB score.\n\nOnly two days before the end of the competition I found that the reason for this discrepancy is that the public/private LB contains approximately 50/50 positive/negative studies, while the local validation set contains much more positive studies.\nAfter splitting each tomo volume in half ([D,H,W] -> [D,H, W/2:], [D,H,W/2:]) and computing predictions for each half separately, I was able to achieve a much more realistic local validation score that is closer to the public LB score:\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F1504864%2F6759d72f963e467b1c48ab296e6954ce%2Fv16_val_halves_oof_curves_0.45x_128x256_56x72.png?generation=1749133450173405&alt=media)\n\nIt was too late to change the training strategy, but I was able to use this knowledge to select the best blending method of our final ensemble.\n\n## Blenidng\n\nChristof's and mine solutions are very different in nature and how raw model's predictions looked like.\nThe only way to take our prediction CSVs and blend them somehow. \n\nThe validation on halves helped us find the best blending method which I called \"Winner takes all with double Otsu\".\n\nI will explain the Winner takes all approach first and then explain the double Otsu part.\n\n### Winner takes all\n\n1. Compute normalized ranks for each solution's scores.\n2. For each prediction, take the solution with the highest rank and use its coordinates and *normalized rank instead of raw score*.\n\n### Double Otsu\n\n\"Winner takes all with double Otsu\" method extends the Winner takes all approach with an additional step supress negative predictions.\n\n1. We take the raw scores from both solutions and compute Otsu threshold for each solution separately. \n2. We binarize scores array of each prediction using the computed threshold. This gives us two binary masks.\n3. We use these masks to find the most possible negative predictions (where both masks are 0). If both models agrees (in terms of binary masks) that the prediction is negative, we assign it a score of 0 and set the coordinates to -1.\n4. Remaining predictions are processed using the Winner takes all approach.\n\n```markdown\n| Method                         | Score (percentile)   |\n|:-------------------------------|:---------------------|\n| EK Predictions                 | 0.9115 (54.2%)       |\n| CH Predictions                 | 0.9413 (54.7%)       |\n| ---                            | ---                  |\n| Winner Takes All (Rank-based)  | 0.9408 (55.7%)       |\n| Double Otsu Blend              | 0.9421 (54.2%)       |\n```\n\nAs you can see, the double Otsu blend increased the score a little bit compared to the Winner takes all approach.\n\nOther methods like simple averaging, rank-averaging, weighted coordinate blending, re-weighting based in IoU and others did not work well for this competition.\n\nThat what we used for the final submission.\n\n## Pitfalls\n\nUsually, training at `bfloat16` precision is my default go-to option. Unfortunately not always it works as intended especially when you have to do inference in `float16` (Due to the fact ONNXRuntime does not support `bfloat16` inference). Just a days before the competition end I realized my `bf16` checkpoints are completely unusable at fp16 precision and fp32 is too slow. \n\nSo I had to retrain my models from scratch in fp16-mixed precision (Hello `torch.amp`!). Imagine my surprise when I've got much higher accuracy of the final models. Not only models ended up more accurate, the training dynamics improved and converted to a best score twice as fast.\n\nA `bfloat16` offers great dynamic range, but it comes at a cost of some precision loss which can be crucial. Important not to forget about it :)",
    "3218350": "bloodaxe Congratulations on the 4th place!\nSplitting the validation tomograms in half was a really clever idea.\n\nBy the way, as mentioned in the public discussions, the positive tomograms seem to contain false negatives (i.e., unlabeled motors).\nDid you apply any data correction or adjustments to address this?",
    "3218508": "Yes, I saw the mentions of mistakes (both FP and FN) in the tomo. But I never got enough time to reannotate / correct those mistakes.\nIn fact I ever wrote a script to some semi-automatic active learning, but as it often happens on kaggle - you have to give up some things in order to win a fight against time :) Instead I relied on extra data in hope that it will dilute those mistakes enough 🤷‍♂️",
    "3218733": "Congrats, and nice write-up @bloodaxe! The \"splitting each tomo volume in half\" validation strategy is a very clever idea.\n\nWhat is anisotropic scale jitter?",
    "3219309": "Anisotropic scale resizes input volume at different scale factors along each axis. This is purely extra data augmentation to prevent overfitting and make the model more robust to “squished bacteria” (if this is a thing). I use +-10% of along each scale.\n\nThis extend the isotropic scale jitter (+-75%, but capped to ensure effective resolution stays in 8-20A/px). And technically all scale and rotation operations are making a final 4x4 transformation matrix that encodes translation, rotation and scale transformations which represents random rotation, cropping and scale. \n\nBy having only one volume resamplibg op I preserve the most of the signal and also save the time as volume resample is somewhat costly operation.",
    "3219989": "bloodaxe Congrats! Pray for Ukraine 🙏"
  },
  "source": "meta"
}