The problem in brief
You receive 500 infrared satellite images of Hurricane Beryl (June-July 2024), each labelled with the storm's maximum sustained wind in knots, and 500 unlabelled images of Hurricane Milton (October 2024). You must predict Milton's wind speed from the pixels alone: an automated version of the Dvorak technique that forecasters use to read intensity from cloud patterns. Looking up Milton's real intensities is explicitly forbidden, and so is exploiting the timestamps. What makes the task hard is not the images but the split: training on one storm and testing on another storm, in a different ocean basin, that becomes stronger (155 kt) than anything in the training set (145 kt).
How you are scored
Root mean squared error over the 500 Milton images, lower is better:
The organisers publish no threshold, and the test labels are private. Because Milton is a historical storm, its official NHC best track is public; we used it only after freezing our models, to estimate how they would score, never to fit or tune anything. Since we do not know exactly how the organisers built Milton's labels, we report two proxies: the daily maximum of the best track (this rule reproduces Beryl's training labels, see below) and the best track interpolated in time. Treat them as approximate test scores.
First look at the data
The images. These are colour-enhanced IR pictures (640 x 480, GOES-16 band 13) in which colour encodes cloud-top temperature: grey and white for warm, low clouds, then blue, green, red and finally yellow for the coldest, highest tops, which mark the most vigorous convection. A banner at the bottom carries the colour bar and also the date and time: a model could read the date from it, so we crop it away.

The labels are coarse. Beryl's 500 labels take only five values, one per calendar day: 35, 65, 115, 130 and 145 kt. Comparing with the public best track shows they are the daily maximum: for example, every image of 30 June is labelled 115 kt, although Beryl was at 70 kt at midnight and only reached 115 kt at 18 UTC. So a 01:30 image of a 70 kt storm is labelled 115 kt. Three consequences:
- there are really only five independent training targets, not 500;
- consecutive frames (10 minutes apart) are near-duplicates, so a random train/validation split leaks and gives absurdly optimistic scores;
- even a perfect "physical" model cannot reach a low RMSE against such labels.
The test storm looks different. Milton grew in the Gulf of Mexico, where the convection was much colder: on 5 October, at only 35 kt, it already shows a red cloud mass larger than Beryl's at 130 kt. And at peak intensity its eye is a pinhole of 2 to 3 pixels.
A first baseline
A constant prediction is the reference. Predicting the training mean (109 kt) everywhere gives a proxy RMSE around 50 kt; the sample submission's constant 75 kt gives 39 to 54 kt depending on the proxy. To validate on Beryl honestly we use leave-one-day-out cross-validation: train on four days, predict the fifth. A constant gets 41.6 kt this way.
The key idea
The decisive idea is a validation question, not a model: what does my validation measure? Leave-one-day-out on Beryl measures generalisation to another day of the same storm. The test is generalisation to another storm. These are different things, and on this task they disagree.
We first built features in the obvious way. The colour bar in each image gives a lookup from colour to a "coldness" value between 0 (warm) and 1 (coldest), then we locate the storm centre (the densest patch of blue-or-colder pixels) and measure, inside discs of radius 20, 40, 80 and 140 pixels, the fraction of pixels colder than each colour level. That is 20 "cloud-area" numbers per image. A ridge regression on them gets the best leave-one-day-out score of everything we tried, 18.9 kt, much better than a pretrained ResNet-18 embedding (23.9 kt).
On Milton it collapses (proxy RMSE 44 to 54 kt): it predicts about 100 kt for the 35 kt tropical storm, because Milton's cold cloud shield is huge from the start. The model has learned "more cold cloud means stronger" from one storm, which is a correlation of Beryl's life cycle, not a law of physics. With five effective training targets and 20 features, the ridge can essentially memorise which day an image comes from.
The physics says something more specific. The Dvorak technique estimates intensity mainly from the eye and the ring of cold cloud around it: a strong hurricane has a warm, well-defined eye enclosed by a complete ring of very cold tops; a weak storm has a cold blob without a clear eye, however large. So we built only four features that follow that logic:
eye_score: the largest contrast between a warm pixel and the coldest-closed ring of radius 5 to 12 pixels around it;eyewall_q10: the 10th percentile of coldness on that ring, i.e. how cold the weakest point of the eyewall is (a broken ring has a warm gap, so its 10th percentile is low);eye_c: how warm the eye itself is;cdo_mean: mean coldness of the central dense overcast within 40 pixels.
Using a percentile of the ring rather than its mean is the important detail: it rewards a closed, symmetric eyewall, the visual signature that separates a 150 kt "buzzsaw" from a lopsided burst of convection.
This 4-feature model is worse in leave-one-day-out (34.8 kt) but clearly better on Milton (proxy RMSE 30.5 to 38.8 kt): it correctly keeps the early tropical storm near 60 to 75 kt and rises during the explosive intensification.

Honesty note: we chose the 4-feature model after seeing the proxy scores of the first one, so its proxy score is somewhat optimistic. A choice we could have defended in advance is the principle behind it: with only five independent labels and a known distribution shift, prefer few, physically motivated features over many correlated ones, even at the cost of in-domain validation.
The full solution
- Coldness map. Read the colour bar from the banner (rows 456 to 467), build a lookup table from RGB to the position of the nearest colour-bar entry, and drop the banner rows.
- Centre. Smooth the mask "colder than blue" with a Gaussian of 15 pixels and take its maximum.
- Eye and eyewall. On a 141 x 141 window around the centre, compute a 10th-percentile filter over an annulus (radius 5 to 12 px) and pick the pixel that maximises "ring coldness minus pixel coldness":
ring = percentile_filter(w, 10, footprint=ANNULUS) # weakest point of a ring
score = ring - w # closed cold ring around a warm spot
iy, ix = np.unravel_index(score.argmax(), score.shape)
feats = dict(eye_score=score[iy, ix], eyewall_q10=ring[iy, ix],
eye_c=w[iy, ix], cdo_mean=w[30:111, 30:111].mean())
- Model. Standardise the four features and fit a ridge regression (alpha by internal CV). A linear model can extrapolate a little beyond 145 kt, which trees cannot do at all.
- Validation. Leave-one-day-out on Beryl; then, once frozen, proxy scoring against Milton's best track.
Results
| approach | leave-one-day-out RMSE (Beryl) | proxy test RMSE, daily max | proxy test RMSE, interpolated |
|---|---|---|---|
| constant 75 kt (sample submission) | n/a | 53.5 | 39.3 |
| constant training mean (109 kt) | 41.6 | 49.3 | 50.4 |
| ResNet-18 ImageNet features + ridge | 23.9 | 48.2 | 50.9 |
| 20 cloud-area features, extra trees | 22.5 | not run | not run |
| 20 cloud-area features + ridge | 18.9 | 44.0 | 53.6 |
| same, plus temporal median filter over 13 frames | n/a | 43.7 | 53.3 |
| cloud areas + Dvorak features, ridge | 18.2 | 43.4 | 50.6 |
| 4 Dvorak-style features + ridge (final) | 34.8 | 38.8 | 30.5 |
All rows are in knots. The ranking by validation on Beryl is almost exactly the reverse of the ranking on Milton. Even the final model is far from good: it never predicts above about 145 kt, and Milton's pinhole eye is too small for our 2-pixel smoothing, so the peak is underestimated by 30 kt or more. Possible next steps, not tried here: detecting the eye at full resolution without smoothing, adding the radius of the coldest closed ring as Dvorak does, and training on more storms (which the rules forbid here).
What to remember
- Count your independent labels. 500 images with 5 distinct, day-level labels is a 5-example problem; that dictates both the validation scheme and how many features you can afford.
- Group your validation by the unit of correlation (here the day), never split near-duplicate frames at random.
- Validation should mimic the test shift. In-storm validation rewarded the model that generalised worst to a new storm. When the test domain differs, simple features grounded in the physics travel better than many correlated ones.
- Read the labels against a reference. Recognising "daily maximum" labels explains much of the error that no model can remove.
- Remove leaks on purpose: crop the banner that prints the date, and never let public test answers touch training or tuning.