I Built an AI Model to Predict India’s Monsoon Failures. Here’s What 36 Years of Rainfall Data Taught Me About El Niño.
A GIS developer’s deep dive into why some droughts hurt more than others, and how I turned a climate question into a working forecast for Maharashtra’s farmers
A few weeks ago I asked myself a question that should have a simple answer: will the Indian Ocean Dipole save us from this year’s El Niño?
It’s the kind of question you’d expect an agricultural economist or a climate scientist to answer, not a GIS developer with a laptop and too much curiosity. But the more I read, the more I realized nobody was answering it, with anything I could actually point to and say “here, look, this is the data.”
So I built it myself.
What started as a curiosity turned into something I genuinely didn’t expect a working model that correctly flagged every major drought year in Maharashtra’s recent history, and one that’s now telling me 2026 is going to be rough. Not vaguely rough. Specifically: eight districts in Marathwada and Vidarbha are sitting in “critical” territory before the monsoon has even properly started.
Here’s how I got there, and what I learned about why some El Niño years devastate Indian agriculture while others barely register.
The question nobody answers
If you’ve followed Indian monsoon coverage, you’ve heard this before: El Niño suppresses rainfall, La Niña boosts it. It’s true, broadly. But it’s also incomplete in a way that matters a lot if you’re a farmer in Beed deciding whether to plant cotton or hold back.
The missing piece is the Indian Ocean Dipole, the IOD. It’s the temperature difference between the western and eastern Indian Ocean, and depending on its phase, it can either cushion an El Niño’s damage or make it catastrophically worse.
The textbook example is 1997. That year had the strongest El Niño of the 20th century. By every conventional metric, India should have seen a brutal drought. Instead, the monsoon came in close to normal, because a strongly positive IOD was working against the El Niño the whole season, like two weather systems arm-wrestling and ending in a draw.
Compare that to 2002, where El Niño showed up with a negative IOD. No buffer. Severe drought.
This isn’t speculation, it’s published climate science going back to Saji et al.’s original 1999 IOD paper. What surprised me was that nobody had built something that took this interaction and turned it into an actual district-level risk map for Maharashtra.
So that became the project.
Pulling 36 years of rainfall data is harder than it sounds
I want to be honest about the unglamorous part first, because every “I built an ML model” article skips straight to the results, and that’s not how it actually goes.
Getting real IMD (India Meteorological Department) gridded rainfall data into a usable format took longer than building the model itself. IMD publishes daily rainfall at 0.25-degree resolution going back decades, but it comes as raw binary .grd files, no headers, no metadata, just a wall of float32 numbers you have to know the exact grid dimensions to even read correctly.
def _read_single_grd(path, year, lat_idx, lon_idx):
days_in_year = 366 if calendar.isleap(year) else 365
lat_size, lon_size = 129, 135
expected_vals = days_in_year * lat_size * lon_size
raw = array.array("f")
with open(path, "rb") as f:
raw.fromfile(f, path.stat().st_size // raw.itemsize)
data_3d = np.transpose(
np.reshape(np.array(raw, dtype=np.float32),
(days_in_year, lat_size, lon_size), order="C"),
(0, 2, 1),
)That transpose on the last line cost me an afternoon. Get the axis order wrong and you’ll silently produce a rainfall map that’s geographically scrambled, looks plausible, means nothing.
Once I had clean daily grids, I had to spatially average them down to district level by checking which 0.25° grid cells actually fall inside each of Maharashtra’s 36 district boundaries, then sum June through September to get the seasonal total. Multiply that across 36 districts and 36 years and you’ve got over a thousand district-season observations, each one needing its own polygon containment check.
The Indian Ocean Dipole data was its own headache. The standard source, NOAA’s PSL correlation index, turned out to use a different scaling than the actual sea-surface-temperature-based DMI. I plugged it in, ran my ENSO classification, and got a result that flagged 76 out of 79 years as El Niño years.
That’s obviously wrong. El Niño happens roughly once every 3–5 years, not 96% of the time.
I ended up cross-referencing the Oceanic Niño Index against NOAA CPC’s official published table and embedding verified values directly rather than trusting a live download that kept silently returning garbage:
_EMBEDDED_ONI_JJAS = {
1997: 2.00, # strongest El Nino of the century
2002: 0.80, 2009: 0.60, 2015: 1.80, 2023: 1.15,
#verified against CPC's published table, not re-derived
}Once that fix went in, the model correctly captured every single known El Niño year in Indian monsoon history — 1991, 1992, 1994, 1997, 2002, 2004, 2009, 2015, 2023 — with zero misses. That felt like the first real validation that the project wasn’t just producing plausible-looking nonsense.
Get Anusha Kiran’s stories in your inbox
Join Medium for free to get updates from this writer.
Here’s the full pipeline, end to end, from raw IMD binaries to a deployed dashboard:
Building the feature set that actually tells the story
Once the climate indices were clean, I built out 29 features per district per year. Some of the more important ones:
- Rainfall deficit — how far below the district’s 36-year average the season fell
- ONI during the monsoon and ONI in the preceding March-May (the pre-monsoon signal turned out to be unexpectedly predictive, it’s basically an early warning before the season even starts)
- The DMI equivalents for IOD
- Two interaction flags that turned out to matter more than almost anything else:
enso_iod_compound(El Niño + negative IOD — the worst combination) andenso_offset_iod(El Niño + positive IOD, the buffered case) - Drought streak — how many consecutive bad years a district has had, because vulnerability compounds
- Cropland fraction, pulled from ESA’s WorldCover satellite product, because a district that’s 85% farmland is exposed very differently than one that’s 40% urban
I fed all of this into an XGBoost classifier predicting four risk tiers, Low, Moderate, High, Critical.
Why I didn’t trust my first accuracy number
The first time I ran cross-validation with a standard random k-fold split, I got 98% accuracy. I should have been thrilled. Instead I got suspicious.
Here’s the problem: adjacent districts share weather. If Akola gets hit by a drought, Amravati right next door almost certainly does too, because they’re under the same monsoon system. A random split puts some of Akola’s years in training and some in testing, which means the model is essentially being tested on data it’s already half-seen through its geographic neighbor. That’s leakage, and it inflates your accuracy in a way that doesn’t reflect how the model would perform on a genuinely new region.
So I switched to leave-one-zone-out spatial cross-validation, holding out an entire agro-ecological zone (Vidarbha, Marathwada, Western Maharashtra, or Konkan) at a time, training on the other three, and testing on the one the model has never seen.
for fold_i, test_zone in enumerate(unique_zones):
train_mask = zones != test_zone
test_mask = zones == test_zone
model = XGBClassifier(**XGB_PARAMS)
model.fit(X[train_mask], y[train_mask],
sample_weight=sample_weights[train_mask])
y_pred = model.predict(X[test_mask])
acc = accuracy_score(y[test_mask], y_pred)That honest number came in at 94.7% accuracy, with an F1-macro of 91.6%. Lower than the inflated 98%, but a number I can actually defend.
The Vidarbha fold was the hardest to predict, it makes sense once you think about it, because Vidarbha has both cotton districts (extremely rain-dependent) and rice districts (more buffered), creating internal variation that doesn’t exist as sharply in the other zones.
The chart that’s the entire point of this project
Everything above was infrastructure. This is the actual finding.
When I grouped every district-year by its ENSO phase and IOD phase simultaneously, the composite stress scores lined up almost exactly the way the climate literature predicted, except now I had my own numbers, computed from my own pipeline, not borrowed from a paper.
Look at the three El Niño rows. The stress score drops by about 15% purely as a function of which way the IOD is leaning that year. Same ENSO phase, different outcome, because of an ocean basin six thousand kilometers away.
When I checked feature importance on the trained XGBoost model, not my assumption, the model’s own ranking of what mattered most for its predictions, the top feature wasn’t rainfall deficit. It was the El Niño + positive IOD interaction flag. Second was the pre-monsoon ONI signal. Third was the El Niño + negative IOD flag.
The model independently rediscovered the IOD-ENSO relationship from raw data, without me telling it that relationship existed. That’s the part that made this project feel like more than an exercise.
What it’s actually saying about 2026
I ran the trained model against IMD’s official seasonal outlook for this year, El Niño developing through the season, IOD currently neutral with a weak positive lean possible late.
The result isn’t subtle. Eight districts across Marathwada and Vidarbha are sitting in Critical territory, with Beed, Parbhani, and Osmanabad at the top of the list, all districts with heavy soybean dependence and minimal irrigation buffer. Eleven more are in High risk.
Konkan, on the coast, comes out fine. Rice paddies there get enough baseline moisture that even a weak monsoon doesn’t tip them into serious stress.
This is a forecast, not a fact, I want to be upfront about that distinction, because it’s tempting to present model output as more certain than it is. But it’s a forecast grounded in 34 years of how this exact climate combination has actually played out on the ground, not a guess.
What I’d tell someone starting a project like this
Don’t trust a downloaded climate index without checking it against known historical events first. I lost real time to a bad ENSO classification before I thought to sanity-check it against years everyone already knows were El Niño years.
Spatial autocorrelation is the silent killer of any geographic ML project. If your accuracy looks too good and your data has any geographic structure at all, assume leakage until proven otherwise.
And build the explanation layer before you think you need it. The first version of my dashboard showed feature names like enso_offset_iod with no context, and it was genuinely useless to anyone outside the project. Plain language descriptions next to every technical feature turned a model into something people could actually read.
The full dashboard, the risk map, the ENSO × IOD explainer, the spatial CV results, is live (https://varsha-kharif-mapper.netlify.app/) and you can explore it yourself. If you’re working on Indian monsoon data and hit the same IOD index mismatch I did, I’d genuinely like to compare notes.








