{
  "id": 663139,
  "title": "9th Place Gold - TCN+GBDT Ensemble",
  "url": "/competitions/MABe-mouse-behavior-detection/discussion/663139",
  "author_name": "Pavel",
  "post_date": "2025-12-16T15:45:31.925000",
  "votes": 14,
  "comment_count": 0,
  "views": 0,
  "content": "<p>Huge thanks to the organisers for this interesting and insightful competition — I really enjoyed it!</p>\n<p>It was also nice to see that good results were possible with modest compute.</p>\n<h1>TL;DR</h1>\n<p>Per-frame classification via a weighted ensemble of <strong>GBDT</strong> models (XGBoost / LightGBM / CatBoost) and <strong>TCN</strong> models. Both trained on windowed temporal features.</p>\n<p>Rich feature engineering + augmentations (TCN only) + greedy hill-climbing ensembling.</p>\n<p>Source code: <a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe\" target=\"_blank\">https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe</a></p>\n<h1>High-level Overview</h1>\n<p>Initial plan was to implement this in order:</p>\n<ol>\n<li><strong>GBDT baseline</strong></li>\n<li><strong>TCN</strong></li>\n<li><strong>Conv + Transformer</strong></li>\n</ol>\n<p>My assumption was that given enough quality data each successive model would be better than previous. In practice, GBDT-only and TCN-only pipelines performed similarly and the biggest gain came from ensembling.</p>\n<p>I didn’t manage to beat my TCN baseline with Transformer (and didn’t end up ensembling Transformer models - now I think it might have helped).</p>\n<h1>Training regime</h1>\n<p>I modeled the task as per-frame classification using features from a fixed window centered at the target frame.</p>\n<p>For each approach, the training loop was roughly:</p>\n<ol>\n<li>Coarse hyperparameter + features iteration on one fold</li>\n<li>Select best checkpoint by <strong>PR-AUC (aka mAP)</strong></li>\n<li>Train promising configs on 5 folds</li>\n<li>Select models using <strong>nested F1</strong>:<ul>\n<li>For each fold: pick a threshold that maximizes F1 on the other folds</li>\n<li>Evaluate F1 on the held-out fold</li>\n<li>Average across folds</li></ul></li>\n</ol>\n<h1>Fold Split</h1>\n<p>It was slightly tricky because:</p>\n<ul>\n<li>All frames from a video should stay in the same fold</li>\n<li>Each video may contain multiple positive/negative segments for multiple actions</li>\n</ul>\n<p>I assigned folds for each video, optimizing for evenly spread <strong>positive segment duration</strong> across folds for each (action, lab).</p>\n<h1>GBDT</h1>\n<p>The first goal was to build XGBoost baseline beating public notebook scores. </p>\n<p>A central idea in feature engineering was <strong>normalizing coordinates into the agent’s body frame</strong>:</p>\n<ul>\n<li>Scale so the agent body has fixed length</li>\n<li>Align agent body vertically</li>\n<li>Apply this transform to everything in the given window</li>\n</ul>\n<p>Feature families (<a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/gbdt/configs.py\" target=\"_blank\">see file with all GBDT configs here</a>):</p>\n<ul>\n<li>Normalized keypoints (agent + target)</li>\n<li>Velocities (multiple time lags to past and future)</li>\n<li>Accelerations</li>\n<li>Curvature</li>\n<li>Pairwise distances</li>\n<li>Dot/cross products of velocities between landmarks</li>\n<li>Angles between segments</li>\n<li>etc.</li>\n</ul>\n<p>At the beginning I repeated this a few times: trained a few models, inspected feature importances, adjusted feature set, go back to training. </p>\n<p>There was a separate model per each action. Data was downsampled to ~<strong>1M frames</strong> per training run. Also adjusted weights of specific labs (at most one at a time) and weights of all positive samples.</p>\n<p>First non-zero XGBoost submission: <strong>0.474 Public LB</strong> (10th at the time, wow!).</p>\n<p>Then I've trained LightGBM and CatBoost models in a similar fashion. Best \"globally best per (action, lab)\" GBDT: <strong>0.507 Public LB</strong>. Ensembling all GBDTs gave only a modest bump: up to <strong>0.511 Public LB</strong>.</p>\n<p>Unexpectedly, horizontal flip didn't help the score. At this point, only a few weeks were left until the end of the comp, so I switched to TCN.</p>\n<h1>TCN</h1>\n<p>In my solution it was a few stacked 1D Conv blocks with increasing dilation (e.g. 4 temporal blocks with dilations [1, 2, 4, 8]). One sample is a [T, F] tensor there T is time steps from a fixed window and F is a number of features. One model predictions probability distribution for all actions and all labs. <a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/tcn.py\" target=\"_blank\">Implementation</a>.</p>\n<p>Feature set was similar in nature to GBDT, but taking into account that now we explicitly have landmarks from each of T time steps around target frame. I used windows of length 2s-4s and T around 30-45. Every accessible categorical info was mapped into a learnable embedding.</p>\n<p>Compared to GBDT, it was more convenient to manage feature processing - it could be done entirely on GPU, so data loader basically had to supply only a few integers per sample (video id, agent, target, frame). <a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/features_lookup_gpu.py\" target=\"_blank\">Implementation</a>.</p>\n<p>I've trained dozens of models maximising total F1 on one fold, then selected best model for each action, trained them on 5 folds. </p>\n<p>Augmentations were very important. Most of them are inspired by <a href=\"https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze\" target=\"_blank\">1st place solution ASL Fingerspelling</a> [1]</p>\n<p>The most impactful were (<a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/landmark_feature_extractor.py\" target=\"_blank\">implementation</a>):</p>\n<ul>\n<li>horizontal flip. The biggest and easiest uplift: about 0.02 on PR-AUC</li>\n<li>dropping entire landmark groups</li>\n<li>widening / thinning mice body width</li>\n<li>spatial cutout</li>\n</ul>\n<p>Didn't help:</p>\n<ul>\n<li>time stretch augmentation</li>\n<li>AWP</li>\n<li>focal loss, ranking loss</li>\n<li>small random shift of ground truth segment</li>\n<li>coordinates jitter</li>\n<li>conv+transformer (inspired by <a href=\"https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with\" target=\"_blank\">1st place Isolated Sign Language</a> [2]</li>\n</ul>\n<p>Best TCN-only (no ensembling): <strong>Public LB 0.509</strong>. Ensembling improved TCN-only <strong>Public LB to 0.521</strong>.</p>\n<h1>Ensembling</h1>\n<p>I used greedy hill climbing (inspired by <a href=\"https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing\" target=\"_blank\">this writeup</a> [3] by Chris Deotte). Additional features include: different objectives (F1, nested F1, log-loss, etc), beam search, logit vs probability space. <a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/common/ensemble_building_primitives.py#L331\" target=\"_blank\">Implementation</a></p>\n<p>The best approach turned out to be just maximising nested F1 and allow as many models as possible. In the best solution I used up to 12 per (action, lab).</p>\n<p>Combining GBDT and TCN models was the crucial part of the solution and boosted <strong>Public LB to 0.540</strong>.</p>\n<h1>Postprocessing and Inference</h1>\n<p>Smoothing predictions with a small temporal kernel improved the score by ~0.001–0.003. </p>\n<p>I implemented smoothing as \"strided inference\" (best: <code>stride = 7</code>), which also significantly sped up inference:</p>\n<ol>\n<li>Pick a checkpoint (from up to <strong>12</strong> per <em>(action, lab)</em>).</li>\n<li>Choose a <code>base from {0, 1, ..., stride-1}</code>.</li>\n<li>Run inference on frames:\n<code>base, base + stride, base + 2·stride, ...</code></li>\n<li>Fill in probabilities for the missing frames via <strong>linear interpolation</strong></li>\n<li>For the next checkpoint, rotate the offset:\n<code>base = (base + 1) % stride</code>.</li>\n</ol>\n<p>Another thing that boosted score by ~0.002–0.004 was paired action thresholds, designed to improve action selection when &gt;1 actions are bigger than their thresholds:</p>\n<ol>\n<li>For each pair of actions on frames there they are both bigger than their threshold, collect all (p1, p2) + which action was actually correct</li>\n<li>Remove pairs with &lt;K occurences</li>\n<li>Fit threshold on p1-p2 maximising average F1 of those actions</li>\n<li>On inference for cases there &gt;1 actions are realised, select the best via tournament (i.e. the one which win the most pairs)</li>\n</ol>\n<p>Didn't help:</p>\n<ul>\n<li>probability calibration</li>\n<li>TTA (horizontal flip)</li>\n</ul>\n<h1>Results</h1>\n<table>\n<thead>\n<tr>\n<th>Pipeline</th>\n<th>CV</th>\n<th>Public LB</th>\n<th>Private LB</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>xgboost, per-action</td>\n<td>-</td>\n<td>0.474</td>\n<td>0.458</td>\n</tr>\n<tr>\n<td>xgboost, per-action, per-lab</td>\n<td>0.497</td>\n<td>0.501</td>\n<td>0.482</td>\n</tr>\n<tr>\n<td>xgboost, lgbm, catboost, per-action, per-lab</td>\n<td>0.506</td>\n<td>0.507</td>\n<td>0.486</td>\n</tr>\n<tr>\n<td>ensemble (xgboost, lgbm, catboost, up to 3 models)</td>\n<td>0.524*</td>\n<td>0.511</td>\n<td>0.492</td>\n</tr>\n<tr>\n<td>TCN, per-action, per-lab</td>\n<td>0.518</td>\n<td>0.509</td>\n<td>0.478</td>\n</tr>\n<tr>\n<td>ensemble (TCN, up to 6 models)</td>\n<td>0.540*</td>\n<td>0.515</td>\n<td>0.489</td>\n</tr>\n<tr>\n<td>above, but replace with models on 100% of data</td>\n<td>-</td>\n<td>0.521</td>\n<td>0.490</td>\n</tr>\n<tr>\n<td>ensemble (GBDT, TCN, up to 6 models)</td>\n<td>0.556*</td>\n<td>0.540</td>\n<td>0.514</td>\n</tr>\n<tr>\n<td>ensemble (GBDT, TCN, up to 12 models)</td>\n<td>0.558*</td>\n<td>0.539</td>\n<td>0.515</td>\n</tr>\n<tr>\n<td>above + pair threshold tournament</td>\n<td>0.560*</td>\n<td>0.538</td>\n<td>0.516</td>\n</tr>\n</tbody>\n</table>\n<p>* ensembling CV scores are highly inflated, because it is a direct optimisation objective of huge ensembling space</p>\n<p>I checked ensembling parameters in OOF fashion:</p>\n<ol>\n<li>Iterate over validation fold (0..4)</li>\n<li>Fit ensemble on the remaining (=training) folds, maximising nested F1 there</li>\n<li>Fit optimal F1 thresholds on the training folds</li>\n<li>Apply ensemble and thresholds to the validation fold</li>\n<li>Average F1 score over validation folds</li>\n</ol>\n<p>This was much more honest assessment. In particular, 6 model ensemble was best on Public LB, but the cross validation showed that more models = better score, which turned out to be true on private LB.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F172360%2Fffc9c533191bcf9cf641544fc24d8e6c%2Fensembling_plot.png?generation=1765899796201861&amp;alt=media\" alt=\"Ensembling Cross Validation\"></p>\n<h1>Notes</h1>\n<p>I didn't use any unlabeled data, and also noticed issues with 25 fps Adaptable Snail data only few days till the end, so was not able to incorporate this knowledge meaningfully :( </p>\n<h2>References</h2>\n<ol>\n<li><a href=\"https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze\" target=\"_blank\">https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze</a></li>\n<li><a href=\"https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with\" target=\"_blank\">https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with</a></li>\n<li><a href=\"https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing\" target=\"_blank\">https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing</a></li>\n</ol>",
  "messages": [
    {
      "id": 3377592,
      "postDate": "2025-12-16T15:45:31.927Z",
      "content": "<p>Huge thanks to the organisers for this interesting and insightful competition — I really enjoyed it!</p>\n<p>It was also nice to see that good results were possible with modest compute.</p>\n<h1>TL;DR</h1>\n<p>Per-frame classification via a weighted ensemble of <strong>GBDT</strong> models (XGBoost / LightGBM / CatBoost) and <strong>TCN</strong> models. Both trained on windowed temporal features.</p>\n<p>Rich feature engineering + augmentations (TCN only) + greedy hill-climbing ensembling.</p>\n<p>Source code: <a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe\" target=\"_blank\">https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe</a></p>\n<h1>High-level Overview</h1>\n<p>Initial plan was to implement this in order:</p>\n<ol>\n<li><strong>GBDT baseline</strong></li>\n<li><strong>TCN</strong></li>\n<li><strong>Conv + Transformer</strong></li>\n</ol>\n<p>My assumption was that given enough quality data each successive model would be better than previous. In practice, GBDT-only and TCN-only pipelines performed similarly and the biggest gain came from ensembling.</p>\n<p>I didn’t manage to beat my TCN baseline with Transformer (and didn’t end up ensembling Transformer models - now I think it might have helped).</p>\n<h1>Training regime</h1>\n<p>I modeled the task as per-frame classification using features from a fixed window centered at the target frame.</p>\n<p>For each approach, the training loop was roughly:</p>\n<ol>\n<li>Coarse hyperparameter + features iteration on one fold</li>\n<li>Select best checkpoint by <strong>PR-AUC (aka mAP)</strong></li>\n<li>Train promising configs on 5 folds</li>\n<li>Select models using <strong>nested F1</strong>:<ul>\n<li>For each fold: pick a threshold that maximizes F1 on the other folds</li>\n<li>Evaluate F1 on the held-out fold</li>\n<li>Average across folds</li></ul></li>\n</ol>\n<h1>Fold Split</h1>\n<p>It was slightly tricky because:</p>\n<ul>\n<li>All frames from a video should stay in the same fold</li>\n<li>Each video may contain multiple positive/negative segments for multiple actions</li>\n</ul>\n<p>I assigned folds for each video, optimizing for evenly spread <strong>positive segment duration</strong> across folds for each (action, lab).</p>\n<h1>GBDT</h1>\n<p>The first goal was to build XGBoost baseline beating public notebook scores. </p>\n<p>A central idea in feature engineering was <strong>normalizing coordinates into the agent’s body frame</strong>:</p>\n<ul>\n<li>Scale so the agent body has fixed length</li>\n<li>Align agent body vertically</li>\n<li>Apply this transform to everything in the given window</li>\n</ul>\n<p>Feature families (<a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/gbdt/configs.py\" target=\"_blank\">see file with all GBDT configs here</a>):</p>\n<ul>\n<li>Normalized keypoints (agent + target)</li>\n<li>Velocities (multiple time lags to past and future)</li>\n<li>Accelerations</li>\n<li>Curvature</li>\n<li>Pairwise distances</li>\n<li>Dot/cross products of velocities between landmarks</li>\n<li>Angles between segments</li>\n<li>etc.</li>\n</ul>\n<p>At the beginning I repeated this a few times: trained a few models, inspected feature importances, adjusted feature set, go back to training. </p>\n<p>There was a separate model per each action. Data was downsampled to ~<strong>1M frames</strong> per training run. Also adjusted weights of specific labs (at most one at a time) and weights of all positive samples.</p>\n<p>First non-zero XGBoost submission: <strong>0.474 Public LB</strong> (10th at the time, wow!).</p>\n<p>Then I've trained LightGBM and CatBoost models in a similar fashion. Best \"globally best per (action, lab)\" GBDT: <strong>0.507 Public LB</strong>. Ensembling all GBDTs gave only a modest bump: up to <strong>0.511 Public LB</strong>.</p>\n<p>Unexpectedly, horizontal flip didn't help the score. At this point, only a few weeks were left until the end of the comp, so I switched to TCN.</p>\n<h1>TCN</h1>\n<p>In my solution it was a few stacked 1D Conv blocks with increasing dilation (e.g. 4 temporal blocks with dilations [1, 2, 4, 8]). One sample is a [T, F] tensor there T is time steps from a fixed window and F is a number of features. One model predictions probability distribution for all actions and all labs. <a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/tcn.py\" target=\"_blank\">Implementation</a>.</p>\n<p>Feature set was similar in nature to GBDT, but taking into account that now we explicitly have landmarks from each of T time steps around target frame. I used windows of length 2s-4s and T around 30-45. Every accessible categorical info was mapped into a learnable embedding.</p>\n<p>Compared to GBDT, it was more convenient to manage feature processing - it could be done entirely on GPU, so data loader basically had to supply only a few integers per sample (video id, agent, target, frame). <a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/features_lookup_gpu.py\" target=\"_blank\">Implementation</a>.</p>\n<p>I've trained dozens of models maximising total F1 on one fold, then selected best model for each action, trained them on 5 folds. </p>\n<p>Augmentations were very important. Most of them are inspired by <a href=\"https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze\" target=\"_blank\">1st place solution ASL Fingerspelling</a> [1]</p>\n<p>The most impactful were (<a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/landmark_feature_extractor.py\" target=\"_blank\">implementation</a>):</p>\n<ul>\n<li>horizontal flip. The biggest and easiest uplift: about 0.02 on PR-AUC</li>\n<li>dropping entire landmark groups</li>\n<li>widening / thinning mice body width</li>\n<li>spatial cutout</li>\n</ul>\n<p>Didn't help:</p>\n<ul>\n<li>time stretch augmentation</li>\n<li>AWP</li>\n<li>focal loss, ranking loss</li>\n<li>small random shift of ground truth segment</li>\n<li>coordinates jitter</li>\n<li>conv+transformer (inspired by <a href=\"https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with\" target=\"_blank\">1st place Isolated Sign Language</a> [2]</li>\n</ul>\n<p>Best TCN-only (no ensembling): <strong>Public LB 0.509</strong>. Ensembling improved TCN-only <strong>Public LB to 0.521</strong>.</p>\n<h1>Ensembling</h1>\n<p>I used greedy hill climbing (inspired by <a href=\"https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing\" target=\"_blank\">this writeup</a> [3] by Chris Deotte). Additional features include: different objectives (F1, nested F1, log-loss, etc), beam search, logit vs probability space. <a href=\"https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/common/ensemble_building_primitives.py#L331\" target=\"_blank\">Implementation</a></p>\n<p>The best approach turned out to be just maximising nested F1 and allow as many models as possible. In the best solution I used up to 12 per (action, lab).</p>\n<p>Combining GBDT and TCN models was the crucial part of the solution and boosted <strong>Public LB to 0.540</strong>.</p>\n<h1>Postprocessing and Inference</h1>\n<p>Smoothing predictions with a small temporal kernel improved the score by ~0.001–0.003. </p>\n<p>I implemented smoothing as \"strided inference\" (best: <code>stride = 7</code>), which also significantly sped up inference:</p>\n<ol>\n<li>Pick a checkpoint (from up to <strong>12</strong> per <em>(action, lab)</em>).</li>\n<li>Choose a <code>base from {0, 1, ..., stride-1}</code>.</li>\n<li>Run inference on frames:\n<code>base, base + stride, base + 2·stride, ...</code></li>\n<li>Fill in probabilities for the missing frames via <strong>linear interpolation</strong></li>\n<li>For the next checkpoint, rotate the offset:\n<code>base = (base + 1) % stride</code>.</li>\n</ol>\n<p>Another thing that boosted score by ~0.002–0.004 was paired action thresholds, designed to improve action selection when &gt;1 actions are bigger than their thresholds:</p>\n<ol>\n<li>For each pair of actions on frames there they are both bigger than their threshold, collect all (p1, p2) + which action was actually correct</li>\n<li>Remove pairs with &lt;K occurences</li>\n<li>Fit threshold on p1-p2 maximising average F1 of those actions</li>\n<li>On inference for cases there &gt;1 actions are realised, select the best via tournament (i.e. the one which win the most pairs)</li>\n</ol>\n<p>Didn't help:</p>\n<ul>\n<li>probability calibration</li>\n<li>TTA (horizontal flip)</li>\n</ul>\n<h1>Results</h1>\n<table>\n<thead>\n<tr>\n<th>Pipeline</th>\n<th>CV</th>\n<th>Public LB</th>\n<th>Private LB</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>xgboost, per-action</td>\n<td>-</td>\n<td>0.474</td>\n<td>0.458</td>\n</tr>\n<tr>\n<td>xgboost, per-action, per-lab</td>\n<td>0.497</td>\n<td>0.501</td>\n<td>0.482</td>\n</tr>\n<tr>\n<td>xgboost, lgbm, catboost, per-action, per-lab</td>\n<td>0.506</td>\n<td>0.507</td>\n<td>0.486</td>\n</tr>\n<tr>\n<td>ensemble (xgboost, lgbm, catboost, up to 3 models)</td>\n<td>0.524*</td>\n<td>0.511</td>\n<td>0.492</td>\n</tr>\n<tr>\n<td>TCN, per-action, per-lab</td>\n<td>0.518</td>\n<td>0.509</td>\n<td>0.478</td>\n</tr>\n<tr>\n<td>ensemble (TCN, up to 6 models)</td>\n<td>0.540*</td>\n<td>0.515</td>\n<td>0.489</td>\n</tr>\n<tr>\n<td>above, but replace with models on 100% of data</td>\n<td>-</td>\n<td>0.521</td>\n<td>0.490</td>\n</tr>\n<tr>\n<td>ensemble (GBDT, TCN, up to 6 models)</td>\n<td>0.556*</td>\n<td>0.540</td>\n<td>0.514</td>\n</tr>\n<tr>\n<td>ensemble (GBDT, TCN, up to 12 models)</td>\n<td>0.558*</td>\n<td>0.539</td>\n<td>0.515</td>\n</tr>\n<tr>\n<td>above + pair threshold tournament</td>\n<td>0.560*</td>\n<td>0.538</td>\n<td>0.516</td>\n</tr>\n</tbody>\n</table>\n<p>* ensembling CV scores are highly inflated, because it is a direct optimisation objective of huge ensembling space</p>\n<p>I checked ensembling parameters in OOF fashion:</p>\n<ol>\n<li>Iterate over validation fold (0..4)</li>\n<li>Fit ensemble on the remaining (=training) folds, maximising nested F1 there</li>\n<li>Fit optimal F1 thresholds on the training folds</li>\n<li>Apply ensemble and thresholds to the validation fold</li>\n<li>Average F1 score over validation folds</li>\n</ol>\n<p>This was much more honest assessment. In particular, 6 model ensemble was best on Public LB, but the cross validation showed that more models = better score, which turned out to be true on private LB.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F172360%2Fffc9c533191bcf9cf641544fc24d8e6c%2Fensembling_plot.png?generation=1765899796201861&amp;alt=media\" alt=\"Ensembling Cross Validation\"></p>\n<h1>Notes</h1>\n<p>I didn't use any unlabeled data, and also noticed issues with 25 fps Adaptable Snail data only few days till the end, so was not able to incorporate this knowledge meaningfully :( </p>\n<h2>References</h2>\n<ol>\n<li><a href=\"https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze\" target=\"_blank\">https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze</a></li>\n<li><a href=\"https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with\" target=\"_blank\">https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with</a></li>\n<li><a href=\"https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing\" target=\"_blank\">https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing</a></li>\n</ol>",
      "rawMarkdown": "Huge thanks to the organisers for this interesting and insightful competition — I really enjoyed it!\n\nIt was also nice to see that good results were possible with modest compute.\n\n# TL;DR\n\nPer-frame classification via a weighted ensemble of **GBDT** models (XGBoost / LightGBM / CatBoost) and **TCN** models. Both trained on windowed temporal features.\n\nRich feature engineering + augmentations (TCN only) + greedy hill-climbing ensembling.\n\nSource code: https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe\n\n# High-level Overview\n\nInitial plan was to implement this in order:\n\n1. **GBDT baseline**\n2. **TCN**\n3. **Conv + Transformer**\n\nMy assumption was that given enough quality data each successive model would be better than previous. In practice, GBDT-only and TCN-only pipelines performed similarly and the biggest gain came from ensembling.\n\nI didn’t manage to beat my TCN baseline with Transformer (and didn’t end up ensembling Transformer models - now I think it might have helped).\n\n# Training regime\n\nI modeled the task as per-frame classification using features from a fixed window centered at the target frame.\n\nFor each approach, the training loop was roughly:\n\n1. Coarse hyperparameter + features iteration on one fold\n2. Select best checkpoint by **PR-AUC (aka mAP)**\n3. Train promising configs on 5 folds\n4. Select models using **nested F1**:\n   - For each fold: pick a threshold that maximizes F1 on the other folds\n   - Evaluate F1 on the held-out fold\n   - Average across folds\n\n# Fold Split\n\nIt was slightly tricky because:\n\n- All frames from a video should stay in the same fold\n- Each video may contain multiple positive/negative segments for multiple actions\n\nI assigned folds for each video, optimizing for evenly spread **positive segment duration** across folds for each (action, lab).\n\n# GBDT\n\nThe first goal was to build XGBoost baseline beating public notebook scores. \n\nA central idea in feature engineering was **normalizing coordinates into the agent’s body frame**:\n\n- Scale so the agent body has fixed length\n- Align agent body vertically\n- Apply this transform to everything in the given window\n\nFeature families ([see file with all GBDT configs here](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/gbdt/configs.py)):\n\n- Normalized keypoints (agent + target)\n- Velocities (multiple time lags to past and future)\n- Accelerations\n- Curvature\n- Pairwise distances\n- Dot/cross products of velocities between landmarks\n- Angles between segments\n- etc.\n\nAt the beginning I repeated this a few times: trained a few models, inspected feature importances, adjusted feature set, go back to training. \n\nThere was a separate model per each action. Data was downsampled to ~**1M frames** per training run. Also adjusted weights of specific labs (at most one at a time) and weights of all positive samples.\n\nFirst non-zero XGBoost submission: **0.474 Public LB** (10th at the time, wow!).\n\nThen I've trained LightGBM and CatBoost models in a similar fashion. Best \"globally best per (action, lab)\" GBDT: **0.507 Public LB**. Ensembling all GBDTs gave only a modest bump: up to **0.511 Public LB**.\n\nUnexpectedly, horizontal flip didn't help the score. At this point, only a few weeks were left until the end of the comp, so I switched to TCN.\n\n# TCN\n\nIn my solution it was a few stacked 1D Conv blocks with increasing dilation (e.g. 4 temporal blocks with dilations [1, 2, 4, 8]). One sample is a [T, F] tensor there T is time steps from a fixed window and F is a number of features. One model predictions probability distribution for all actions and all labs. [Implementation](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/tcn.py).\n\nFeature set was similar in nature to GBDT, but taking into account that now we explicitly have landmarks from each of T time steps around target frame. I used windows of length 2s-4s and T around 30-45. Every accessible categorical info was mapped into a learnable embedding.\n\nCompared to GBDT, it was more convenient to manage feature processing - it could be done entirely on GPU, so data loader basically had to supply only a few integers per sample (video id, agent, target, frame). [Implementation](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/features_lookup_gpu.py).\n\nI've trained dozens of models maximising total F1 on one fold, then selected best model for each action, trained them on 5 folds. \n\nAugmentations were very important. Most of them are inspired by [1st place solution ASL Fingerspelling](https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze) [1]\n\nThe most impactful were ([implementation](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/landmark_feature_extractor.py)):\n- horizontal flip. The biggest and easiest uplift: about 0.02 on PR-AUC\n- dropping entire landmark groups\n- widening / thinning mice body width\n- spatial cutout\n\nDidn't help:\n- time stretch augmentation\n- AWP\n- focal loss, ranking loss\n- small random shift of ground truth segment\n- coordinates jitter\n- conv+transformer (inspired by [1st place Isolated Sign Language](https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with) [2]\n\nBest TCN-only (no ensembling): **Public LB 0.509**. Ensembling improved TCN-only **Public LB to 0.521**.\n\n# Ensembling\n\nI used greedy hill climbing (inspired by [this writeup](https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing) [3] by Chris Deotte). Additional features include: different objectives (F1, nested F1, log-loss, etc), beam search, logit vs probability space. [Implementation](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/common/ensemble_building_primitives.py#L331)\n\nThe best approach turned out to be just maximising nested F1 and allow as many models as possible. In the best solution I used up to 12 per (action, lab).\n\nCombining GBDT and TCN models was the crucial part of the solution and boosted **Public LB to 0.540**.\n\n# Postprocessing and Inference\n\nSmoothing predictions with a small temporal kernel improved the score by ~0.001–0.003. \n\nI implemented smoothing as \"strided inference\" (best: `stride = 7`), which also significantly sped up inference:\n\n1. Pick a checkpoint (from up to **12** per *(action, lab)*).\n2. Choose a `base from {0, 1, ..., stride-1}`.\n3. Run inference on frames:\n   `base, base + stride, base + 2·stride, ...`\n4. Fill in probabilities for the missing frames via **linear interpolation**\n5. For the next checkpoint, rotate the offset:\n   `base = (base + 1) % stride`.\n\nAnother thing that boosted score by ~0.002–0.004 was paired action thresholds, designed to improve action selection when >1 actions are bigger than their thresholds:\n1. For each pair of actions on frames there they are both bigger than their threshold, collect all (p1, p2) + which action was actually correct\n2. Remove pairs with <K occurences\n3. Fit threshold on p1-p2 maximising average F1 of those actions\n4. On inference for cases there >1 actions are realised, select the best via tournament (i.e. the one which win the most pairs)\n\nDidn't help:\n- probability calibration\n- TTA (horizontal flip)\n\n\n# Results\n\n| Pipeline | CV | Public LB | Private LB |\n|---|---:|---:|---:|\n| xgboost, per-action | - | 0.474 | 0.458 |\n| xgboost, per-action, per-lab | 0.497 | 0.501 | 0.482 |\n| xgboost, lgbm, catboost, per-action, per-lab | 0.506 | 0.507 | 0.486 |\n| ensemble (xgboost, lgbm, catboost, up to 3 models) | 0.524* | 0.511 | 0.492 |\n| TCN, per-action, per-lab | 0.518 | 0.509 | 0.478 |\n| ensemble (TCN, up to 6 models) | 0.540* | 0.515 | 0.489 |\n| above, but replace with models on 100% of data | - | 0.521 | 0.490 |\n| ensemble (GBDT, TCN, up to 6 models) | 0.556* | 0.540 | 0.514 |\n| ensemble (GBDT, TCN, up to 12 models) | 0.558* | 0.539 | 0.515 |\n| above + pair threshold tournament | 0.560* | 0.538 | 0.516 |\n\n\\* ensembling CV scores are highly inflated, because it is a direct optimisation objective of huge ensembling space\n\nI checked ensembling parameters in OOF fashion:\n1. Iterate over validation fold (0..4)\n2. Fit ensemble on the remaining (=training) folds, maximising nested F1 there\n3. Fit optimal F1 thresholds on the training folds\n4. Apply ensemble and thresholds to the validation fold\n5. Average F1 score over validation folds\n\nThis was much more honest assessment. In particular, 6 model ensemble was best on Public LB, but the cross validation showed that more models = better score, which turned out to be true on private LB.\n\n<img\n  src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F172360%2Fffc9c533191bcf9cf641544fc24d8e6c%2Fensembling_plot.png?generation=1765899796201861&alt=media\"\n  alt=\"Ensembling Cross Validation\"\n  width=\"650\"\n/>\n\n# Notes\n\nI didn't use any unlabeled data, and also noticed issues with 25 fps Adaptable Snail data only few days till the end, so was not able to incorporate this knowledge meaningfully :( \n\n## References\n\n1. https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze\n2. https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with\n3. https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing\n",
      "votes": 14
    }
  ],
  "comments": [],
  "raw_markdown_by_id": {
    "3377592": "Huge thanks to the organisers for this interesting and insightful competition — I really enjoyed it!\n\nIt was also nice to see that good results were possible with modest compute.\n\n# TL;DR\n\nPer-frame classification via a weighted ensemble of **GBDT** models (XGBoost / LightGBM / CatBoost) and **TCN** models. Both trained on windowed temporal features.\n\nRich feature engineering + augmentations (TCN only) + greedy hill-climbing ensembling.\n\nSource code: https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe\n\n# High-level Overview\n\nInitial plan was to implement this in order:\n\n1. **GBDT baseline**\n2. **TCN**\n3. **Conv + Transformer**\n\nMy assumption was that given enough quality data each successive model would be better than previous. In practice, GBDT-only and TCN-only pipelines performed similarly and the biggest gain came from ensembling.\n\nI didn’t manage to beat my TCN baseline with Transformer (and didn’t end up ensembling Transformer models - now I think it might have helped).\n\n# Training regime\n\nI modeled the task as per-frame classification using features from a fixed window centered at the target frame.\n\nFor each approach, the training loop was roughly:\n\n1. Coarse hyperparameter + features iteration on one fold\n2. Select best checkpoint by **PR-AUC (aka mAP)**\n3. Train promising configs on 5 folds\n4. Select models using **nested F1**:\n   - For each fold: pick a threshold that maximizes F1 on the other folds\n   - Evaluate F1 on the held-out fold\n   - Average across folds\n\n# Fold Split\n\nIt was slightly tricky because:\n\n- All frames from a video should stay in the same fold\n- Each video may contain multiple positive/negative segments for multiple actions\n\nI assigned folds for each video, optimizing for evenly spread **positive segment duration** across folds for each (action, lab).\n\n# GBDT\n\nThe first goal was to build XGBoost baseline beating public notebook scores. \n\nA central idea in feature engineering was **normalizing coordinates into the agent’s body frame**:\n\n- Scale so the agent body has fixed length\n- Align agent body vertically\n- Apply this transform to everything in the given window\n\nFeature families ([see file with all GBDT configs here](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/gbdt/configs.py)):\n\n- Normalized keypoints (agent + target)\n- Velocities (multiple time lags to past and future)\n- Accelerations\n- Curvature\n- Pairwise distances\n- Dot/cross products of velocities between landmarks\n- Angles between segments\n- etc.\n\nAt the beginning I repeated this a few times: trained a few models, inspected feature importances, adjusted feature set, go back to training. \n\nThere was a separate model per each action. Data was downsampled to ~**1M frames** per training run. Also adjusted weights of specific labs (at most one at a time) and weights of all positive samples.\n\nFirst non-zero XGBoost submission: **0.474 Public LB** (10th at the time, wow!).\n\nThen I've trained LightGBM and CatBoost models in a similar fashion. Best \"globally best per (action, lab)\" GBDT: **0.507 Public LB**. Ensembling all GBDTs gave only a modest bump: up to **0.511 Public LB**.\n\nUnexpectedly, horizontal flip didn't help the score. At this point, only a few weeks were left until the end of the comp, so I switched to TCN.\n\n# TCN\n\nIn my solution it was a few stacked 1D Conv blocks with increasing dilation (e.g. 4 temporal blocks with dilations [1, 2, 4, 8]). One sample is a [T, F] tensor there T is time steps from a fixed window and F is a number of features. One model predictions probability distribution for all actions and all labs. [Implementation](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/tcn.py).\n\nFeature set was similar in nature to GBDT, but taking into account that now we explicitly have landmarks from each of T time steps around target frame. I used windows of length 2s-4s and T around 30-45. Every accessible categorical info was mapped into a learnable embedding.\n\nCompared to GBDT, it was more convenient to manage feature processing - it could be done entirely on GPU, so data loader basically had to supply only a few integers per sample (video id, agent, target, frame). [Implementation](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/features_lookup_gpu.py).\n\nI've trained dozens of models maximising total F1 on one fold, then selected best model for each action, trained them on 5 folds. \n\nAugmentations were very important. Most of them are inspired by [1st place solution ASL Fingerspelling](https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze) [1]\n\nThe most impactful were ([implementation](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/dl/landmark_feature_extractor.py)):\n- horizontal flip. The biggest and easiest uplift: about 0.02 on PR-AUC\n- dropping entire landmark groups\n- widening / thinning mice body width\n- spatial cutout\n\nDidn't help:\n- time stretch augmentation\n- AWP\n- focal loss, ranking loss\n- small random shift of ground truth segment\n- coordinates jitter\n- conv+transformer (inspired by [1st place Isolated Sign Language](https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with) [2]\n\nBest TCN-only (no ensembling): **Public LB 0.509**. Ensembling improved TCN-only **Public LB to 0.521**.\n\n# Ensembling\n\nI used greedy hill climbing (inspired by [this writeup](https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing) [3] by Chris Deotte). Additional features include: different objectives (F1, nested F1, log-loss, etc), beam search, logit vs probability space. [Implementation](https://github.com/PavelSavchenkov/kaggle-gold-9th-place-mabe/blob/main/common/ensemble_building_primitives.py#L331)\n\nThe best approach turned out to be just maximising nested F1 and allow as many models as possible. In the best solution I used up to 12 per (action, lab).\n\nCombining GBDT and TCN models was the crucial part of the solution and boosted **Public LB to 0.540**.\n\n# Postprocessing and Inference\n\nSmoothing predictions with a small temporal kernel improved the score by ~0.001–0.003. \n\nI implemented smoothing as \"strided inference\" (best: `stride = 7`), which also significantly sped up inference:\n\n1. Pick a checkpoint (from up to **12** per *(action, lab)*).\n2. Choose a `base from {0, 1, ..., stride-1}`.\n3. Run inference on frames:\n   `base, base + stride, base + 2·stride, ...`\n4. Fill in probabilities for the missing frames via **linear interpolation**\n5. For the next checkpoint, rotate the offset:\n   `base = (base + 1) % stride`.\n\nAnother thing that boosted score by ~0.002–0.004 was paired action thresholds, designed to improve action selection when >1 actions are bigger than their thresholds:\n1. For each pair of actions on frames there they are both bigger than their threshold, collect all (p1, p2) + which action was actually correct\n2. Remove pairs with <K occurences\n3. Fit threshold on p1-p2 maximising average F1 of those actions\n4. On inference for cases there >1 actions are realised, select the best via tournament (i.e. the one which win the most pairs)\n\nDidn't help:\n- probability calibration\n- TTA (horizontal flip)\n\n\n# Results\n\n| Pipeline | CV | Public LB | Private LB |\n|---|---:|---:|---:|\n| xgboost, per-action | - | 0.474 | 0.458 |\n| xgboost, per-action, per-lab | 0.497 | 0.501 | 0.482 |\n| xgboost, lgbm, catboost, per-action, per-lab | 0.506 | 0.507 | 0.486 |\n| ensemble (xgboost, lgbm, catboost, up to 3 models) | 0.524* | 0.511 | 0.492 |\n| TCN, per-action, per-lab | 0.518 | 0.509 | 0.478 |\n| ensemble (TCN, up to 6 models) | 0.540* | 0.515 | 0.489 |\n| above, but replace with models on 100% of data | - | 0.521 | 0.490 |\n| ensemble (GBDT, TCN, up to 6 models) | 0.556* | 0.540 | 0.514 |\n| ensemble (GBDT, TCN, up to 12 models) | 0.558* | 0.539 | 0.515 |\n| above + pair threshold tournament | 0.560* | 0.538 | 0.516 |\n\n\\* ensembling CV scores are highly inflated, because it is a direct optimisation objective of huge ensembling space\n\nI checked ensembling parameters in OOF fashion:\n1. Iterate over validation fold (0..4)\n2. Fit ensemble on the remaining (=training) folds, maximising nested F1 there\n3. Fit optimal F1 thresholds on the training folds\n4. Apply ensemble and thresholds to the validation fold\n5. Average F1 score over validation folds\n\nThis was much more honest assessment. In particular, 6 model ensemble was best on Public LB, but the cross validation showed that more models = better score, which turned out to be true on private LB.\n\n<img\n  src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F172360%2Fffc9c533191bcf9cf641544fc24d8e6c%2Fensembling_plot.png?generation=1765899796201861&alt=media\"\n  alt=\"Ensembling Cross Validation\"\n  width=\"650\"\n/>\n\n# Notes\n\nI didn't use any unlabeled data, and also noticed issues with 25 fps Adaptable Snail data only few days till the end, so was not able to incorporate this knowledge meaningfully :( \n\n## References\n\n1. https://www.kaggle.com/competitions/asl-fingerspelling/writeups/darragh-dieter-1st-place-solution-improved-squeeze\n2. https://www.kaggle.com/competitions/asl-signs/writeups/hoyeol-sohn-1st-place-solution-1dcnn-combined-with\n3. https://www.kaggle.com/competitions/playground-series-s5e5/writeups/chris-deotte-1st-place-gpu-hill-climbing\n"
  }
}