{
  "id": 566528,
  "title": "4th place: Factorized modelling approach",
  "content": "<p>Full model* code: <a href=\"https://www.kaggle.com/code/herrahuu/4th-place-solution\" target=\"_blank\">https://www.kaggle.com/code/herrahuu/4th-place-solution</a><br>\nPrivate score: 0.69936, Public score: 0.69724, total runtime: 4h<br>\n*this version also includes LightGBM model not mentioned below, but impact is minimal in ensemble</p>\n<p>For comparison solo model scores (without bagging):<br>\nCatBoost: 0.69784, 0.69500, 30min<br>\nXGBoost: 0.69765, 0.69538, 5min<br>\nTabM: 0.69636, 0.69383, 6min<br>\nLightGBM: 0.69793, 0.69516, 5min</p>\n<p>~~~~</p>\n<p>It was fun to make a small comeback to Kaggle after a long break. Especially given this nice \"old school\" competition, not too big &amp; tabular dataset. Below is a short summary of my solution.</p>\n<h1>Introduction</h1>\n<p>The goal of this competition was to rank patients based on their risk scores for allogeneic HCT transplantation events. As a first step it's useful to think what a perfect solution would look like. In this case that would be:</p>\n<ol>\n<li>all patients with no event (efs=0) should be at the bottom of the list (no order defined between them)</li>\n<li>for efs = 1, patients are ordered by the event times</li>\n</ol>\n<p>Clearly this ranking problem is non-differentiable, especially as the 1) introduces step function like behavior for the desired ranks. One common way to handle this type of problems is to introduce some sort of soft/smoothed version of the original task. However, in my experience, such approaches tend to make things unstable during training, or just don't really match to the original problem that well if smoothed too much. </p>\n<p>So instead, my main idea for the competition was to follow the steps 1-2) directly and divide the problem into two parts: </p>\n<ol>\n<li>predict the probability of the event</li>\n<li>predict the expected ranking position among patients with events</li>\n</ol>\n<p>Finally, given these two predictions, we can calculate the expected ranking position as a risk score.</p>\n<h1>Model formulation:</h1>\n<pre><code>risk_score = P(event = )*(s0_group/) + P(event = )*(s0_group + (-s0_group)*E[rank% | event = ])\n, where s0_group = sum_{race_group = group} P(event = ) / N_group \n</code></pre>\n<h2>P(event):</h2>\n<ul>\n<li>binary classification for efs events</li>\n<li>censored data points are treated as a partial observations</li>\n<li>weights for those data points are calculated as cumulative densities given by Kaplan-Meier estimator and scaled to (0,1) range.</li>\n</ul>\n<h2>E[rank% | event = 1]:</h2>\n<ul>\n<li>conditional regression model to predict time based rankings for data points where event did happen</li>\n<li>the model is trained using only data points which have efs = 1</li>\n<li>target is rank% = rank(-time)/N. </li>\n<li>so, target values are between 0 and 1, 0 = longest time and 1 = shortest</li>\n<li>with all algorithms, I'm using target transformation of the form: inverse_normal_cdf(rank%) \"z-score\", and then predictions are transformed back to percentages by normal_cdf(pred)</li>\n</ul>\n<h1>Algorithms:</h1>\n<p>For both of these tasks, three different models are implemented using the following packages/algorithms:</p>\n<ul>\n<li>TabM</li>\n<li>CatBoost</li>\n<li>XGBoost</li>\n</ul>\n<p>So, in total six models are trained. First their predictions are merged by just calculating weighted sums (separately for both time and event using different weights), and then finally risk scores are calculated by the the formula given above.</p>",
  "comments": [
    {
      "id": 3157197,
      "author_name": "MJeremy",
      "author_url": "",
      "post_date": "2025-03-23T05:36:52.177000",
      "content": "<p>hi thanks for sharing. But I got quite confused at the risk score formulae, I feel mathematically the </p>\n<pre><code>risk_score = P() * P(rank | )\n</code></pre>\n<p>How did you come up with the s0_group / 2 and also the additions of the formulae?</p>",
      "votes": 1,
      "replies": [
        {
          "id": 3157420,
          "author_name": "Herra Huu",
          "author_url": "",
          "post_date": "2025-03-23T12:26:28.997000",
          "content": "<p>Your formula would represents the joint probability distribution rather than the expected value.</p>\n<p>To build intuition around the original formulation, consider that we would like to emulate the function scipy.rankdata(data, method='average') (<a href=\"https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.rankdata.html\" target=\"_blank\">https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.rankdata.html</a> ). In that context, the term s0_group/2 corresponds to the contribution of the parameter method='average'.</p>",
          "votes": 1,
          "replies": []
        }
      ]
    },
    {
      "id": 3141870,
      "author_name": "Aleksandr  Larko",
      "author_url": "",
      "post_date": "2025-03-06T00:50:53.807000",
      "content": "<p>This is awesome , good job! Congratulations！</p>",
      "votes": 1,
      "replies": []
    },
    {
      "id": 3150721,
      "author_name": "Robert Hatch",
      "author_url": "",
      "post_date": "2025-03-15T20:29:35.830000",
      "content": "<p>I notice you did training on full train data. How did you tune hyperparameters for each model and ensemble weights for the final predictions? Using normal CV methods and later retrain on full with tuned params and weights?</p>",
      "votes": 0,
      "replies": [
        {
          "id": 3150741,
          "author_name": "Herra Huu",
          "author_url": "",
          "post_date": "2025-03-15T21:29:03.017000",
          "content": "<p>Yeah, exactly that for both hyperparameters&amp;ensemble weights.</p>",
          "votes": 1,
          "replies": []
        }
      ]
    },
    {
      "id": 3141889,
      "author_name": "Robert Hatch",
      "author_url": "",
      "post_date": "2025-03-06T01:09:04.433000",
      "content": "<p>Nice, that's basically exactly what I did as well for 5th! Congrats!</p>\n<p>I did go ahead and use a million models, and blend in predictions from an ensemble of 'normal' risk score predictions. And I post-processed for a really nice boost… but the core was the same formula basis.</p>",
      "votes": 0,
      "replies": []
    },
    {
      "id": 3141842,
      "author_name": "SCRIPTCHEF",
      "author_url": "",
      "post_date": "2025-03-06T00:23:52.380000",
      "content": "<p>This is awesome, thanks for sharing! Congrats!</p>",
      "votes": 0,
      "replies": []
    },
    {
      "id": 3141840,
      "author_name": "",
      "author_url": "",
      "post_date": "2025-03-06T00:21:56.520000",
      "content": "",
      "votes": 0,
      "replies": []
    }
  ],
  "comment_count": 8,
  "messages": [
    {
      "id": 3141829,
      "postDate": "2025-03-06T00:15:34.587Z",
      "content": "<p>Full model* code: <a href=\"https://www.kaggle.com/code/herrahuu/4th-place-solution\" target=\"_blank\">https://www.kaggle.com/code/herrahuu/4th-place-solution</a><br>\nPrivate score: 0.69936, Public score: 0.69724, total runtime: 4h<br>\n*this version also includes LightGBM model not mentioned below, but impact is minimal in ensemble</p>\n<p>For comparison solo model scores (without bagging):<br>\nCatBoost: 0.69784, 0.69500, 30min<br>\nXGBoost: 0.69765, 0.69538, 5min<br>\nTabM: 0.69636, 0.69383, 6min<br>\nLightGBM: 0.69793, 0.69516, 5min</p>\n<p>~~~~</p>\n<p>It was fun to make a small comeback to Kaggle after a long break. Especially given this nice \"old school\" competition, not too big &amp; tabular dataset. Below is a short summary of my solution.</p>\n<h1>Introduction</h1>\n<p>The goal of this competition was to rank patients based on their risk scores for allogeneic HCT transplantation events. As a first step it's useful to think what a perfect solution would look like. In this case that would be:</p>\n<ol>\n<li>all patients with no event (efs=0) should be at the bottom of the list (no order defined between them)</li>\n<li>for efs = 1, patients are ordered by the event times</li>\n</ol>\n<p>Clearly this ranking problem is non-differentiable, especially as the 1) introduces step function like behavior for the desired ranks. One common way to handle this type of problems is to introduce some sort of soft/smoothed version of the original task. However, in my experience, such approaches tend to make things unstable during training, or just don't really match to the original problem that well if smoothed too much. </p>\n<p>So instead, my main idea for the competition was to follow the steps 1-2) directly and divide the problem into two parts: </p>\n<ol>\n<li>predict the probability of the event</li>\n<li>predict the expected ranking position among patients with events</li>\n</ol>\n<p>Finally, given these two predictions, we can calculate the expected ranking position as a risk score.</p>\n<h1>Model formulation:</h1>\n<pre><code>risk_score = P(event = )*(s0_group/) + P(event = )*(s0_group + (-s0_group)*E[rank% | event = ])\n, where s0_group = sum_{race_group = group} P(event = ) / N_group \n</code></pre>\n<h2>P(event):</h2>\n<ul>\n<li>binary classification for efs events</li>\n<li>censored data points are treated as a partial observations</li>\n<li>weights for those data points are calculated as cumulative densities given by Kaplan-Meier estimator and scaled to (0,1) range.</li>\n</ul>\n<h2>E[rank% | event = 1]:</h2>\n<ul>\n<li>conditional regression model to predict time based rankings for data points where event did happen</li>\n<li>the model is trained using only data points which have efs = 1</li>\n<li>target is rank% = rank(-time)/N. </li>\n<li>so, target values are between 0 and 1, 0 = longest time and 1 = shortest</li>\n<li>with all algorithms, I'm using target transformation of the form: inverse_normal_cdf(rank%) \"z-score\", and then predictions are transformed back to percentages by normal_cdf(pred)</li>\n</ul>\n<h1>Algorithms:</h1>\n<p>For both of these tasks, three different models are implemented using the following packages/algorithms:</p>\n<ul>\n<li>TabM</li>\n<li>CatBoost</li>\n<li>XGBoost</li>\n</ul>\n<p>So, in total six models are trained. First their predictions are merged by just calculating weighted sums (separately for both time and event using different weights), and then finally risk scores are calculated by the the formula given above.</p>",
      "rawMarkdown": "Full model* code: https://www.kaggle.com/code/herrahuu/4th-place-solution\nPrivate score: 0.69936, Public score: 0.69724, total runtime: 4h\n*this version also includes LightGBM model not mentioned below, but impact is minimal in ensemble\n\nFor comparison solo model scores (without bagging):\nCatBoost: 0.69784, 0.69500, 30min\nXGBoost: 0.69765, 0.69538, 5min\nTabM: 0.69636, 0.69383, 6min\nLightGBM: 0.69793, 0.69516, 5min\n\n~~~~\n\nIt was fun to make a small comeback to Kaggle after a long break. Especially given this nice \"old school\" competition, not too big & tabular dataset. Below is a short summary of my solution.\n\n# Introduction\nThe goal of this competition was to rank patients based on their risk scores for allogeneic HCT transplantation events. As a first step it's useful to think what a perfect solution would look like. In this case that would be:\n1.  all patients with no event (efs=0) should be at the bottom of the list (no order defined between them)\n2. for efs = 1, patients are ordered by the event times\n\nClearly this ranking problem is non-differentiable, especially as the 1) introduces step function like behavior for the desired ranks. One common way to handle this type of problems is to introduce some sort of soft/smoothed version of the original task. However, in my experience, such approaches tend to make things unstable during training, or just don't really match to the original problem that well if smoothed too much. \n\nSo instead, my main idea for the competition was to follow the steps 1-2) directly and divide the problem into two parts: \n1.  predict the probability of the event\n2.  predict the expected ranking position among patients with events\n\nFinally, given these two predictions, we can calculate the expected ranking position as a risk score.\n\n# Model formulation:\n```python\nrisk_score = P(event = 0)*(s0_group/2) + P(event = 1)*(s0_group + (1-s0_group)*E[rank% | event = 1])\n, where s0_group = sum_{race_group = group} P(event = 0) / N_group \n\n```\n## P(event):\n- binary classification for efs events\n- censored data points are treated as a partial observations\n- weights for those data points are calculated as cumulative densities given by Kaplan-Meier estimator and scaled to (0,1) range.\n\n## E[rank% | event = 1]:\n- conditional regression model to predict time based rankings for data points where event did happen\n- the model is trained using only data points which have efs = 1\n- target is rank% = rank(-time)/N. \n- so, target values are between 0 and 1, 0 = longest time and 1 = shortest\n- with all algorithms, I'm using target transformation of the form: inverse_normal_cdf(rank%) \"z-score\", and then predictions are transformed back to percentages by normal_cdf(pred)\n\n# Algorithms:\nFor both of these tasks, three different models are implemented using the following packages/algorithms:\n- TabM\n- CatBoost\n- XGBoost\n\nSo, in total six models are trained. First their predictions are merged by just calculating weighted sums (separately for both time and event using different weights), and then finally risk scores are calculated by the the formula given above.\n\n\n",
      "votes": 53
    },
    {
      "id": 3157197,
      "postDate": "2025-03-23T05:36:52.177Z",
      "content": "<p>hi thanks for sharing. But I got quite confused at the risk score formulae, I feel mathematically the </p>\n<pre><code>risk_score = P() * P(rank | )\n</code></pre>\n<p>How did you come up with the s0_group / 2 and also the additions of the formulae?</p>",
      "rawMarkdown": "hi thanks for sharing. But I got quite confused at the risk score formulae, I feel mathematically the \n\n```\nrisk_score = P(event) * P(rank | event)\n```\n\nHow did you come up with the s0_group / 2 and also the additions of the formulae?",
      "votes": 1,
      "replies": [
        {
          "id": 3157420,
          "postDate": "2025-03-23T12:26:28.997Z",
          "content": "<p>Your formula would represents the joint probability distribution rather than the expected value.</p>\n<p>To build intuition around the original formulation, consider that we would like to emulate the function scipy.rankdata(data, method='average') (<a href=\"https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.rankdata.html\" target=\"_blank\">https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.rankdata.html</a> ). In that context, the term s0_group/2 corresponds to the contribution of the parameter method='average'.</p>",
          "rawMarkdown": "Your formula would represents the joint probability distribution rather than the expected value.\n\nTo build intuition around the original formulation, consider that we would like to emulate the function scipy.rankdata(data, method='average') (https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.rankdata.html ). In that context, the term s0_group/2 corresponds to the contribution of the parameter method='average'.",
          "votes": 1
        }
      ]
    },
    {
      "id": 3141870,
      "postDate": "2025-03-06T00:50:53.807Z",
      "content": "<p>This is awesome , good job! Congratulations！</p>",
      "rawMarkdown": "This is awesome , good job! Congratulations！",
      "votes": 1
    },
    {
      "id": 3150721,
      "postDate": "2025-03-15T20:29:35.830Z",
      "content": "<p>I notice you did training on full train data. How did you tune hyperparameters for each model and ensemble weights for the final predictions? Using normal CV methods and later retrain on full with tuned params and weights?</p>",
      "rawMarkdown": "I notice you did training on full train data. How did you tune hyperparameters for each model and ensemble weights for the final predictions? Using normal CV methods and later retrain on full with tuned params and weights?",
      "replies": [
        {
          "id": 3150741,
          "postDate": "2025-03-15T21:29:03.017Z",
          "content": "<p>Yeah, exactly that for both hyperparameters&amp;ensemble weights.</p>",
          "rawMarkdown": "Yeah, exactly that for both hyperparameters&ensemble weights.",
          "votes": 1
        }
      ]
    },
    {
      "id": 3141889,
      "postDate": "2025-03-06T01:09:04.433Z",
      "content": "<p>Nice, that's basically exactly what I did as well for 5th! Congrats!</p>\n<p>I did go ahead and use a million models, and blend in predictions from an ensemble of 'normal' risk score predictions. And I post-processed for a really nice boost… but the core was the same formula basis.</p>",
      "rawMarkdown": "Nice, that's basically exactly what I did as well for 5th! Congrats!\n\nI did go ahead and use a million models, and blend in predictions from an ensemble of 'normal' risk score predictions. And I post-processed for a really nice boost... but the core was the same formula basis."
    },
    {
      "id": 3141842,
      "postDate": "2025-03-06T00:23:52.380Z",
      "content": "<p>This is awesome, thanks for sharing! Congrats!</p>",
      "rawMarkdown": "This is awesome, thanks for sharing! Congrats!"
    },
    {
      "id": 3141840,
      "postDate": "2025-03-06T00:21:56.520Z",
      "rawMarkdown": "",
      "isDeleted": true
    }
  ],
  "complete": true,
  "source": "live",
  "fetched_at": "2026-10-19T19:44:54Z",
  "competition_id": 70942,
  "competition_slug": "equity-post-HCT-survival-predictions",
  "team_id": 13137282,
  "solution_url": "/competitions/equity-post-HCT-survival-predictions/writeups/herra-huu-4th-place-factorized-modelling-approach"
}