<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Hackathon on Benedykt Huszcza | Blog</title><link>https://blog.huszcza.dev/tags/hackathon/</link><description>Recent content in Hackathon on Benedykt Huszcza | Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 07 Apr 2026 16:00:00 +0000</lastBuildDate><atom:link href="https://blog.huszcza.dev/tags/hackathon/index.xml" rel="self" type="application/rss+xml"/><item><title>Transformers Cutting Down Trees - EnsembleAI 2026</title><link>https://blog.huszcza.dev/p/ensemble-ai-2026/</link><pubDate>Tue, 07 Apr 2026 16:00:00 +0000</pubDate><guid>https://blog.huszcza.dev/p/ensemble-ai-2026/</guid><description>&lt;img src="https://blog.huszcza.dev/p/ensemble-ai-2026/cover.jpeg" alt="Featured image of post Transformers Cutting Down Trees - EnsembleAI 2026" />&lt;h2 id="prologue">Prologue
&lt;/h2>&lt;p>No excuses, I took a long time to write this post. Post-hackathon fatigue can hit hard, and describing what we managed to achieve during those 24 hours is no small challenge, because there were many attempts and many different approaches. But now, looking through the train window on my way from Suwalki to Poznan, I can feel the writing flow taking over, just like a Windows update on a random Tuesday at 12:40.&lt;/p>
&lt;p>Writing flow aside, the reality we had to face on site was way less poetic.&lt;/p>
&lt;p>Imagine a table with 64 million rows. I know that is hard to picture, so here is some help: 64 million rows in Times New Roman is about 1,300,000 A4 pages.&lt;/p>
&lt;p>Now imagine reading those 1,300,000 pages and then predicting energy consumption from them. Not exactly easy. So as we all know, for this kind of challenge the first thing we usually reach for is decision trees. We did the same at first. But after a few hours we decided to do something completely different and used a model that was originally designed for almost the opposite kind of task, and only recently started being adapted to many other domains. Come along if you want to see a forest of regression trees first, and then I will tell you how that one crazy experiment brought us &lt;strong>1st place out of 45 teams in this task&lt;/strong>, and why sometimes it is worth throwing the safe instruction manual out the window.&lt;/p>
&lt;h2 id="a-short-intro-to-the-ensembleai-hackathon-format">&lt;strong>A short intro to the EnsembleAI hackathon format&lt;/strong>
&lt;/h2>&lt;p>To understand the emotions my team and I felt during this fierce battle, we need to start with the hackathon format, because it is at least unusual and gives dopamine hits stronger than Instagram Reels.
Each of the 4 tasks is scored separately, and points are assigned based on submitted solutions specific to each task. In task 3, which I worked on, that was for example a CSV file with predictions of monthly energy consumption for a given time interval. Because of this setup, the leaderboard page was the central place of the hackathon, where each position in a task translated into points.
Submissions could be sent only at predefined intervals, among other reasons to avoid DDoS-ing the servers. So after every upload there was always a tense waiting period: did our solution improve the ranking, and by how much?
&lt;img src="https://blog.huszcza.dev/p/ensemble-ai-2026/meme.png"
width="974"
height="528"
srcset="https://blog.huszcza.dev/p/ensemble-ai-2026/meme_hu6f14dcefde730a9f07f77544d8968fdb_607536_480x0_resize_box_3.png 480w, https://blog.huszcza.dev/p/ensemble-ai-2026/meme_hu6f14dcefde730a9f07f77544d8968fdb_607536_1024x0_resize_box_3.png 1024w"
loading="lazy"
alt="Meme about waiting for results"
class="gallery-image"
data-flex-grow="184"
data-flex-basis="442px"
>&lt;/p>
&lt;h2 id="but-maybe-from-the-beginning-what-how-where-and-why">&lt;strong>But maybe from the beginning: what, how, where, and why?&lt;/strong>
&lt;/h2>&lt;p>The task was defined by one of the hackathon partners, Euros Energy, which also provided the data. So what was it about? In the problem statement, we got a clear picture of how mass electrification is a milestone for Poland&amp;rsquo;s energy transition. But for energy distributors, the fast growth in heat pumps creates major challenges. That is why accurate demand forecasting is essential to prevent grid overloads and, as a result, failures.&lt;/p>
&lt;h2 id="the-data-we-got">&lt;strong>The data we got&lt;/strong>
&lt;/h2>&lt;p>When we talk about machine learning and prediction, it would be a shame not to start with the data, so let us do exactly that. Each team had access to 3 main datasets:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Train&lt;/strong>: October 2024 - April 2025&lt;/li>
&lt;li>&lt;strong>Validation&lt;/strong>: May 2025 - June 2025&lt;/li>
&lt;li>&lt;strong>Test&lt;/strong>: July 2025 - October 2025&lt;/li>
&lt;/ul>
&lt;p>We made predictions on that last dataset for every submission, but here comes the twist that decided everything. It was the familiar Kaggle mechanism: Public vs Private Leaderboard. The Test set was technically available to everyone, but&amp;hellip; it did not include our &amp;ldquo;y&amp;rdquo; target. So there was no way to retrain on it or verify results on our own.&lt;/p>
&lt;p>For the full 24 hours, we were fighting &amp;ldquo;in the dark,&amp;rdquo; seeing results only for a small slice of the data on the board. But those points did not carry the final weight in the overall ranking. The final score deciding the podium was computed on the remaining, fully hidden part of Test, and nobody knew those results until the very end. That made the last minutes of the hackathon pure emotional lottery, because summer behavior could be very different from the autumn-winter period we mostly trained on.&lt;/p>
&lt;p>In practice, the evaluation looked like this:&lt;/p>
&lt;div style="border-left: 4px solid #59ff00; padding: 15px 5px; margin: 20px 0;">
&lt;table style="width: 100%; border-collapse: collapse; font-family: sans-serif; font-size: 1.4rem;">
&lt;thead>
&lt;tr style="border-bottom: 2px solid #555;">
&lt;th style="text-align: left; padding: 10px;">Score&lt;/th>
&lt;th style="text-align: left; padding: 10px;">Months used&lt;/th>
&lt;th style="text-align: left; padding: 10px;">Weights&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr style="border-bottom: 1px solid #ddd;">
&lt;td style="padding: 12px 10px;">&lt;strong>Leaderboard (visible)&lt;/strong>&lt;/td>
&lt;td style="padding: 12px 10px;">Validation only (May - Jun 2025)&lt;/td>
&lt;td style="padding: 12px 10px; color: #bbffd8;">-&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="padding: 12px 10px;">&lt;strong>Final score&lt;/strong>&lt;/td>
&lt;td style="padding: 12px 10px;">Validation + Test (May - Oct 2025)&lt;/td>
&lt;td style="padding: 12px 10px;">&lt;strong>2/6 valid&lt;/strong> + &lt;strong>4/6 test&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;/div>
&lt;p>In short: in the end we had around 600 sensors sending logs every 5 minutes in the periods above, which gave us around 64 million rows (10.42 GB!) to analyze.&lt;/p>
&lt;h2 id="goal">&lt;strong>Goal&lt;/strong>
&lt;/h2>&lt;p>Short and simple: the prediction target was not instantaneous power, but the monthly average value of the grid load indicator (x2) for each device. So we moved from high-resolution data (readings every 5 minutes) to monthly aggregates. Below is the exact formula from the task description:&lt;/p>
&lt;blockquote>
&lt;p>For each device &lt;strong>d&lt;/strong> and forecast month &lt;strong>m&lt;/strong>, we needed to predict the &lt;strong>average x2 value&lt;/strong> across all 5-minute readings in that month:&lt;/p>
&lt;p align="center" style="font-size: 1.8rem; padding: 10px 10px 10px 4px; background: rgba(0,0,0,0.05); border-radius: 8px;">
&lt;b>target&lt;sub>d,m&lt;/sub> = (1 / N&lt;sub>d,m&lt;/sub>) * &amp;sum; x&lt;sub>2&lt;/sub>&lt;sup>(d,m,i)&lt;/sup>&lt;/b>
&lt;/p>
&lt;/blockquote>
&lt;p>And the metric on both the &lt;em>live&lt;/em> and final leaderboard was MAE:&lt;/p>
&lt;blockquote>
&lt;p align="center" style="font-size: 1.8rem; padding: 10px 10px 10px 4px; background: rgba(0,0,0,0.05); border-radius: 8px;">
&lt;b>MAE = (1 / n) * &amp;sum; | y&lt;sub>i&lt;/sub> - ŷ&lt;sub>i&lt;/sub> |&lt;/b>
&lt;/p>
&lt;/blockquote>
&lt;p>So, time to describe our efforts and the road that took us straight to 3rd place in the whole hackathon.&lt;/p>
&lt;h2 id="feature-engineering-and-data-preprocessing">&lt;strong>Feature engineering and data preprocessing&lt;/strong>
&lt;/h2>&lt;p>At the start, of course, we had to inspect the data and distributions closely, and that is what I did. But even before that, at the very end of the organizer instructions, we found this section:&lt;/p>
&lt;p>&lt;img src="https://blog.huszcza.dev/p/ensemble-ai-2026/dos.png"
width="1732"
height="1196"
srcset="https://blog.huszcza.dev/p/ensemble-ai-2026/dos_hub36c6c845528ee5e145621c676a1d04d_464433_480x0_resize_box_3.png 480w, https://blog.huszcza.dev/p/ensemble-ai-2026/dos_hub36c6c845528ee5e145621c676a1d04d_464433_1024x0_resize_box_3.png 1024w"
loading="lazy"
alt="Instruction section about DoS"
class="gallery-image"
data-flex-grow="144"
data-flex-basis="347px"
>&lt;/p>
&lt;p>At that point I thought we should start there and add information for each sensor about which energy distributor it belongs to. Surely every team would do that, right? Right?? Well, in the end it turned out they did not :D and who knows, maybe that gave us those extra points.&lt;/p>
&lt;p>The data included latitude and longitude for every sensor, so based on that I decided to locate each device in a specific voivodeship by querying the GeoPy API. It turned out the data was probably anonymized or contained errors, because some locations were incorrect and GeoPy could not find the right place. In those cases, we used KNN to find the nearest sensor with valid coordinates. Then a mapping assigned each voivodeship to one of the distributors such as PGE, Enea, or Tauron, and that gave us our first interesting feature.
Another important aspect was data aggregation. There was a lot of data, enough to overwhelm many models, so we chose hourly aggregation. It seemed to significantly reduce dataset size, remove noise from 5-minute logs, create room for pattern detection, and still remain a useful prediction unit.&lt;/p>
&lt;p>Overall, the problem was quite interesting because at first I approached it as a time-series prediction task. But after deeper thought, this is really a &lt;strong>plain regression problem&lt;/strong>. Sure, measurements come every 5 minutes, but the target is MONTHLY! That is a strong aggregation, and as my university professor would say: we clearly need the sharpest axe possible for this prediction, not a scalpel. Plus, a fairly universal axe that can connect important features in autumn and then apply those insights in summer too.&lt;/p>
&lt;h2 id="first-approach">&lt;strong>First approach&lt;/strong>
&lt;/h2>&lt;p>My first approach was CatBoost. We had some categorical and numerical features, so I decided boosting trees could fit this world quite well. So we went full speed with CatBoost and the following hyperparameters (without tuning at that point):&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;span class="lnt">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">CatBoostRegressor&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">iterations&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">800&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">learning_rate&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.05&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">depth&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">loss_function&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;MAE&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cat_features&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">CATEGORICAL_FEATURES&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">random_seed&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">42&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">verbose&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>And as they say: boom. It hit hard, because our first model got 0.0074 MAE. 0.0074!!!! That is really tiny&amp;hellip; especially with monthly aggregation and this data profile.&lt;/p>
&lt;p>&lt;img src="https://blog.huszcza.dev/p/ensemble-ai-2026/first_leaderboard.png"
width="1776"
height="796"
srcset="https://blog.huszcza.dev/p/ensemble-ai-2026/first_leaderboard_hucabe40d2662f153b762bb8379145bb15_408589_480x0_resize_box_3.png 480w, https://blog.huszcza.dev/p/ensemble-ai-2026/first_leaderboard_hucabe40d2662f153b762bb8379145bb15_408589_1024x0_resize_box_3.png 1024w"
loading="lazy"
alt="First leaderboard result"
class="gallery-image"
data-flex-grow="223"
data-flex-basis="535px"
>&lt;/p>
&lt;p>Then came a barrage of feature-engineering rounds, exploration, and trial-and-error. In the end, while fighting other teams that reached similar results and eventually overtook us, our last CatBoost step was Optuna to squeeze as much as possible out of it. We got MAE = 0.0044. Every model iteration was a real battle, and I still think getting that value from a tree model alone was a strong result. Especially because, slight spoiler, Transformer is a much heavier architecture, so it is hard to compare the two directly since they sit at opposite ends of efficiency and compute requirements. Still, I consider that result really good given our knowledge and skills.&lt;/p>
&lt;h2 id="autobots-roll-out">&lt;strong>Autobots, roll out&lt;/strong>
&lt;/h2>&lt;p>When did we abandon our beautiful tree? First, when I felt that further changes, attempts, and feature engineering were no longer moving the needle, or moved it too little to climb higher. Second, when a team literally called &amp;ldquo;Transformers&amp;rdquo; beat us and, in a way, inspired us.
After a short research phase, I decided to bring truly heavy artillery: Feature Tokenizer Transformer. It is a relatively fresh architecture that has recently become more and more popular in Kaggle competitions.&lt;/p>
&lt;p>&lt;img src="https://blog.huszcza.dev/p/ensemble-ai-2026/ja_i_transformer.png"
width="942"
height="716"
srcset="https://blog.huszcza.dev/p/ensemble-ai-2026/ja_i_transformer_hu705965a24e9c18d0f3b7477bc26c0691_991375_480x0_resize_box_3.png 480w, https://blog.huszcza.dev/p/ensemble-ai-2026/ja_i_transformer_hu705965a24e9c18d0f3b7477bc26c0691_991375_1024x0_resize_box_3.png 1024w"
loading="lazy"
class="gallery-image"
data-flex-grow="131"
data-flex-basis="315px"
>&lt;/p>
&lt;h3 id="general-idea-and-mechanism-of-feature-tokenizer-transformer">General idea and mechanism of Feature Tokenizer Transformer
&lt;/h3>&lt;p>The description below is based on the paper that introduced &lt;a class="link" href="https://arxiv.org/abs/2106.11959" target="_blank" rel="noopener"
>FT-Transformer&lt;/a>. The images also come from the same source.&lt;/p>
&lt;p>From the top: in our dataset, and in tabular datasets in general, we mostly deal with two types of features: categorical and numerical.&lt;/p>
&lt;p>As we know, Transformers were widely used in NLP in generative models like GPT, or encoder-decoder models like T5. So how do we force this architecture to process not token embeddings this time, but categories and numbers together?&lt;/p>
&lt;h3 id="main-component-feature-tokenizer">Main component: Feature Tokenizer
&lt;/h3>&lt;p>This is exactly what the Feature Tokenizer does. It is the key gem of this approach, and it works in two specific ways:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Numerical features:&lt;/strong> relatively straightforward -&amp;gt; we take a scalar, multiply it by a learned weight vector with embedding-size length, add bias, and that scalar gets stretched into an embedding of the target size.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Categorical features:&lt;/strong> similar to NLP token handling. Each feature value is first transformed into a &lt;em>one-hot encoding&lt;/em> representation, then multiplied by a weight matrix. In short math terms, this is selecting a specific row from that matrix plus, of course, bias.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&lt;em>One-hot encoding&lt;/em> means changing a categorical value into a binary vector. Sounds weird, but it is simple. Example: we have a feature &amp;ldquo;Color&amp;rdquo; in a motorcycle dataset. Suppose there are two colors: red and black. In vector form, that is &lt;code>[Red, Black]&lt;/code>, so red is first position, black second. The one-hot representation is like turning lights on, so red is &lt;code>[1,0]&lt;/code>, black is &lt;code>[0,1]&lt;/code>.&lt;/p>
&lt;/blockquote>
&lt;p>All feature values are concatenated into a large matrix &lt;strong>&lt;em>T&lt;/em>&lt;/strong>. Then on top of it we append a randomly initialized &lt;code>[CLS]&lt;/code> vector with the same length. Next, the entire matrix is processed and passed into the Transformer, so &lt;strong>&lt;em>T&lt;/em>&lt;/strong> represents one row in our table (including that extra &lt;code>[CLS]&lt;/code> vector). Diagram below:&lt;/p>
&lt;p>&lt;img src="https://blog.huszcza.dev/p/ensemble-ai-2026/arch_ft_transformer.png"
width="1114"
height="528"
srcset="https://blog.huszcza.dev/p/ensemble-ai-2026/arch_ft_transformer_hud70f235597918efb8c1f944ac24428aa_94832_480x0_resize_box_3.png 480w, https://blog.huszcza.dev/p/ensemble-ai-2026/arch_ft_transformer_hud70f235597918efb8c1f944ac24428aa_94832_1024x0_resize_box_3.png 1024w"
loading="lazy"
alt="FT-Transformer architecture"
class="gallery-image"
data-flex-grow="210"
data-flex-basis="506px"
>&lt;/p>
&lt;p>But why &lt;code>[CLS]&lt;/code>? CLS stands for &lt;em>Classification&lt;/em>, and the main role of this vector is gathering information across all layers during the forward pass.&lt;/p>
&lt;p>Then, as you can see, our &lt;strong>&lt;em>T&lt;/em>&lt;/strong> vector with processed features goes into the Transformer, passes normalization, and then goes to &lt;em>Multi-Head Self-Attention&lt;/em>. This layer lets the model discover the context needed to get a result closest to ideal. In our case, context means other columns in the table, so values from matrix &lt;strong>&lt;em>T&lt;/em>&lt;/strong>. That context is what, among other things, gets accumulated in &lt;code>[CLS]&lt;/code>.&lt;/p>
&lt;p>And why &lt;strong>Multi-Head&lt;/strong>? Similar to language models where one head can capture grammar and another emotion, here each head looks for a different context in our data row. That means one head can track hard geographic dependencies (for example, consumption vs voivodeship/operator), another can search for hidden technical relations (pump model vs consumption), and &lt;code>[CLS]&lt;/code> receives a full multidimensional picture instead of one averaged mush.&lt;/p>
&lt;p>Finally, we discard all other rows from matrix &lt;strong>&lt;em>T&lt;/em>&lt;/strong> except &lt;code>[CLS]&lt;/code>, which carries the core information needed for downstream processing (in our case, predicting specific consumption), and that goes straight into classification/regression.&lt;/p>
&lt;p>That is the extended short version of how the whole thing works under the hood.&lt;/p>
&lt;h2 id="applying-ft-transformer-in-our-task">Applying FT-Transformer in our task
&lt;/h2>&lt;h3 id="final-feature-engineering">Final feature engineering
&lt;/h3>&lt;p>During those 24 hours I tested many feature ideas, often asking an LLM if it had interesting suggestions. So here is what we added and finally used to train our Transformer, though some of these features were also used for CatBoost.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>deviceType&lt;/strong> helps the model capture differences in operating behavior.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>x3&lt;/strong> is an additional categorical feature carrying information about heating curve type.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>operator&lt;/strong> lets the model account for differences from operating conditions and policies.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>voivodeship&lt;/strong> adds geographic context affecting climate and system seasonality.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>device_operator_combo&lt;/strong> captures interactions specific to a given device-operator pair.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>t1_mean-t13_mean&lt;/strong> is the average value of signals t1-t13 in a time window, describing typical level.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>t8_max&lt;/strong> is the maximum of t8, describing extreme peaks and high-load episodes.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>t8_std&lt;/strong> is the standard deviation of t8, measuring signal variability.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>t7_max&lt;/strong> is the maximum of t7, indicating short extreme system states.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>t4_min&lt;/strong> is the minimum of t4, useful for detecting deep drops.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>delta_load&lt;/strong> is the change in load over time points, capturing system dynamics.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>delta_source&lt;/strong> is the change on the source side, potentially reflecting switches or power condition jumps.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>cwu_demand&lt;/strong> is DHW demand, directly affecting system operation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>delta_temp_out_in&lt;/strong> is output-input temperature difference, describing energy transfer and process efficiency.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>cwu_spike&lt;/strong> is a flag for sudden DHW demand increase, useful for short and abrupt events.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>hour_sin&lt;/strong> is sine of hour-of-day, encoding cyclic time without artificial jump between 23:00 and 00:00.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>hour_cos&lt;/strong> is cosine of hour-of-day, complementing the above and reconstructing full daily phase.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>month_sin&lt;/strong> is sine of month, representing yearly seasonality continuously.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>month_cos&lt;/strong> is cosine of month, closing cyclical season representation together with month_sin.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="under-the-hood-network-head-and-hyperparameters">Under the hood: network, head, and hyperparameters
&lt;/h3>&lt;p>Theory is theory, but now let us move to how we adapted these Transformer blocks to our dataset.&lt;/p>
&lt;p>In theory, numbers are linearly projected by learned vectors. But we went one step further: each numerical feature was first processed before entering Transformer by a small neural network, namely MLP (Multi Layer Perceptron):&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">nn.Sequential( nn.Linear(1, embed_dim // 2), nn.ReLU(), nn.Linear(embed_dim // 2, embed_dim), )
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>We did this because not all features influence the result linearly, so we injected some nonlinearity before Transformer input.&lt;/p>
&lt;p>Categorical features were embedded in the standard way described above. The only addition was OOV slots (Out of Vocabulary), in case an operator or deviceType was unseen.
What happens next is the classic Feature Tokenizer Transformer described earlier. Hyperparameters we used:&lt;/p>
&lt;ul>
&lt;li>Embedding size: 64&lt;/li>
&lt;li>Multi head attentions: 8&lt;/li>
&lt;li>Transformer layers: 3&lt;/li>
&lt;li>Dropout: 0.1&lt;/li>
&lt;/ul>
&lt;p>After data passes all Transformer layers, we reach the final part, the regression head. The idea is simple: from the whole matrix we extract only the specific [CLS] vector mentioned earlier. Why this one? Because thanks to attention, it has absorbed information from all other columns and carries a condensed representation of the full row.&lt;/p>
&lt;p>The remaining vectors (for example region-related) are simply cut off because they already did their job. Our [CLS] goes into a tiny neural head made of normalization layer and ReLU activation, which finally compresses all those complex numbers into one final value.&lt;/p>
&lt;p>At the very end, we also added a hard safety guard. Since we predict energy consumption, negative values make no physical sense, so we clipped everything below zero to prevent nonsense outputs.&lt;/p>
&lt;h3 id="training-phase">Training phase
&lt;/h3>&lt;p>A few words about how we approached model training overall. We wanted to do it efficiently, without pointless Transformer training and without wasting precious hackathon time. We had two main phases:&lt;/p>
&lt;p>&lt;strong>Phase 1, the test ground&lt;/strong> Instead of training on everything, we made a hard time cut at the beginning of February. The model trained on data before that date and then predicted the future, what happened after February 1. Why date split and not random? Because for energy consumption, random split would cause data leakage, meaning the model would see the &amp;ldquo;future&amp;rdquo; to predict the &amp;ldquo;past.&amp;rdquo; In this phase we also added Early Stopping so training stopped when improvement stalled. Of course, we saved all checkpoints. This phase gave us realistic MAE before submitting anything to organizers.&lt;/p>
&lt;p>&lt;strong>Phase 2, full speed ahead&lt;/strong> After many tests in Phase 1 confirmed architecture stability, we moved to Phase 2 -&amp;gt; &lt;strong>more data = better model&lt;/strong>. At the end we removed the February 1 cutoff and fed all available historical training data. This heavily fed and tuned model generated final predictions that went into our final &lt;em>submission&lt;/em> file.&lt;/p>
&lt;h3 id="small-tip-at-the-end">Small tip at the end
&lt;/h3>&lt;p>It is worth mentioning that the Transformer learned a scaled mean average x2 value using StandardScaler. Neural networks generally like normalized values, so this likely added another brick to more stable and efficient FT-Transformer training. Right before saving predicted values to output, predictions were properly inverse-scaled to target values.&lt;/p>
&lt;p>&lt;img src="https://blog.huszcza.dev/p/ensemble-ai-2026/leaderboard_task_3.png"
width="606"
height="598"
srcset="https://blog.huszcza.dev/p/ensemble-ai-2026/leaderboard_task_3_hu7d4fa0308172fdb5b7f873a3a5d5d218_207247_480x0_resize_box_3.png 480w, https://blog.huszcza.dev/p/ensemble-ai-2026/leaderboard_task_3_hu7d4fa0308172fdb5b7f873a3a5d5d218_207247_1024x0_resize_box_3.png 1024w"
loading="lazy"
alt="Final leaderboard result"
class="gallery-image"
data-flex-grow="101"
data-flex-basis="243px"
>&lt;/p>
&lt;h2 id="epilogue">Epilogue
&lt;/h2>&lt;p>So why could this work, and now we can say it &lt;strong>did work&lt;/strong>? It is hard to say anything with 100% certainty, because large and complex neural networks are still kind of black boxes. Surely each of the listed practices helped a bit. But if I had to pick one thing with bigger impact, I would point to the famous &lt;em>Multi-Head Self-Attention&lt;/em> mechanism.
The main challenge in this data was extracting universal knowledge from autumn-winter months, when heat pumps typically run at high load, and transferring that knowledge to summer consumption, when usage is much lower. In FT-Transformer, the context mechanism could model how strongly features affect output and how much specific attributes should be considered in special cases. On top of that, our nonlinear MLP that processed numerical values could enrich these features and assign more meaningful influence. As we know, Transformers can generalize well, and I believe that was the first violin in this task.
Still, credit goes to the teams right behind us. Even though the second team had a worse result than ours (by over 50%), we were probably the only team that pulled out such heavy artillery as Transformer for this task. Other teams used tree regressors like LightGBM, and considering the complexity gap between our architecture and theirs, they did a really great job. Still, we managed to take the lead, and we can be proud of our solution.&lt;/p>
&lt;h2 id="so-next-year">So&amp;hellip; next year?
&lt;/h2>&lt;p>Another EnsembleAI and another time I had an amazing experience. Huge thanks to the organizers for such a great event and to my DNS team (Team of Missing Szymon), in this lineup:&lt;/p>
&lt;ul>
&lt;li>&lt;a class="link" href="https://www.linkedin.com/in/jhudziak/" target="_blank" rel="noopener"
>Jakub Hudziak&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://www.linkedin.com/in/jakub-binkowski-80136825b/" target="_blank" rel="noopener"
>Jakub Binkowski&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://www.linkedin.com/in/maciej-kaszkowiak/" target="_blank" rel="noopener"
>Maciej Kaszkowiak&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://www.linkedin.com/in/maciej-mazur-90064b2b4/" target="_blank" rel="noopener"
>Maciej Mazur&lt;/a>&lt;/li>
&lt;li>and of course me :D&lt;/li>
&lt;/ul>
&lt;p>We brought the fire, guys, and I hope not for the last time. I may be repeating myself, but I mean it every single time. So, see you next year?&lt;/p></description></item></channel></rss>