In conflict-affected countries, education is rarely disrupted by a single event. Schools close after attacks, families move away, roads become unsafe, and teachers stop coming. Organisations trying to keep children learning have limited money and limited field staff, so they face a blunt question:
Where is education access most likely to be disrupted, and where should we look first?
I built the Mali Education Disruption Index (EDI) to give a first, data-driven answer to that question. It scores all 50 of Mali's cercles (the country's second-level administrative areas, roughly a district) and puts the result on an interactive map.
- 🗺️ Live map: https://megphapha.github.io/EduDisruption-Index/
- đź’» Code and data: https://github.com/MegPhapha/EduDisruption-Index

The finished map: each of Mali's 50 cercles coloured by priority tier, with the five Critical cercles labelled by name.
This post walks through the data, the messy parts of cleaning it, the scoring formula, how I tried to stay honest about missing data, and one thing the finished map revealed about my own formula.
The data: three open sources
Everything comes from public datasets on the Humanitarian Data Exchange (HDX):
| What | Source | Period |
|---|---|---|
| Conflict events and fatalities | ACLED | 2020–2024 |
| School status (open / closed) | OCHA Mali school registry | 2023 snapshot |
| Population per cercle | OCHA Mali admin-2 estimates | 2020 |
Across Mali, ACLED records 6,471 conflict events and 17,941 fatalities for 2020–2024. Of the 4,491 schools I could match to a cercle, 63% were recorded as closed.
The messy part: getting three files to agree
Joining three datasets on a place name sounds easy. It wasn't.
1. Names don't match. The same cercle can appear as Niafunké, Niafunke or NIAFUNKE depending on the file. Every join goes through a small normalisation function that lowercases and strips French accents:
def normalize(s):
if s is None:
return ''
s = str(s).strip().lower()
replacements = {
'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e',
'à ': 'a', 'â': 'a', 'ä': 'a',
'ô': 'o', 'ö': 'o',
'Ă®': 'i', 'ĂŻ': 'i',
'Ă»': 'u', 'ĂĽ': 'u', 'Ăą': 'u',
'ç': 'c'
}
for accented, plain in replacements.items():
s = s.replace(accented, plain)
return s
Matching also checks longer names first, so Tin-Essako is tried before shorter names that could match part of it.
2. The spreadsheets fight back. The school registry has three rows of metadata above its real header row. Rather than add pandas and openpyxl as dependencies, I treated the .xlsx for what it is, a ZIP archive of XML files, and read it with the standard library:
with zipfile.ZipFile('data/raw/mali_schools_2023.xlsx') as z:
with z.open('xl/sharedStrings.xml') as f:
strings = [el.text or "" for el in ET.parse(f).findall('.//{...}t')]
# ...then walk the sheet's <row> elements, skipping the metadata rows
3. ACLED's summary file is "sparse". The cercle and year columns are filled in only on the first row of each group; the rows below leave them blank. A stateful reader carries the last-seen values forward:
cur_c, cur_y = None, None
for row in rows:
if row_data.get('C'): # new cercle starts here
cur_c = match_cercle(row_data['C'])
if row_data.get('H'): # new year starts here
cur_y = row_data['H']
if cur_c and cur_y and year_start <= int(cur_y) <= year_end:
stats[cur_c]['events'] += int(float(row_data.get('I') or 0))
stats[cur_c]['fatalities'] += int(float(row_data.get('J') or 0))
The whole pipeline, cleaning plus scoring plus generating the web pages, is about 770 lines of Python with no third-party dependencies. That was deliberate: anyone can clone the repo and rerun it without setting up an environment.
The formula
Each cercle gets a score between 0 and 1:
EDI = 0.50 Ă— closure rate
+ 0.25 Ă— conflict events per 100k people
+ 0.25 Ă— fatalities per 100k people
Each input is divided by the highest value of any cercle, so all three sit on a 0–1 scale before weighting.
- School closures (50%) get the most weight because they measure the outcome itself: are schools working or not?
- Conflict events per 100k (25%) capture what closure data misses, such as unsafe routes, displacement and fear of attending. Dividing by population stops big cercles winning on volume alone.
- Fatalities per 100k (25%) add severity. One attack with many casualties is a different signal from many minor incidents.
Being honest about missing data
The biggest problem in this project wasn't code. It was coverage. Automated matching linked schools to only some cercles:
| Coverage | Rule | Cercles |
|---|---|---|
| Full | 10 or more schools matched | 14 |
| Partial | 3–9 matched | 9 |
| Limited | 1–2 matched | 4 |
| Conflict-only | none matched | 23 |
That creates a trap. A cercle with one matched school that happens to be closed shows a "100% closure rate" and shoots up the ranking on the strength of a single data point.
Rather than hide this, I made it part of the output:
- Data-Limited tier. Cercles with fewer than 3 matched schools and fewer than 100 conflict events per 100k people are taken out of the ranking and shown in grey. That's 22 of the 50. The message is "we can't tell yet", not "this place is fine".
- Severe conflict overrides thin data. Cercles with weak school data but 100 or more events per 100k (such as Kidal and Tessalit) keep a conflict-driven tier, because that signal is strong enough to act on by itself.
- A fixed-size Critical list. The top 5 cercles by score that have reliable school data (3 or more matched schools) are marked Critical. Using a fixed count instead of a score cut-off gives a short, defensible list no matter where the score boundaries fall.
What it found
Five cercles reach the Critical tier, about 856,000 people in total:
| Cercle | Region | EDI | Schools closed |
|---|---|---|---|
| Ménaka | Gao | 0.557 | 8 of 10 |
| Tombouctou | Tombouctou | 0.532 | 11 of 11 |
| Bourem | Gao | 0.522 | 4 of 4 |
| Bankass | Mopti | 0.519 | 29 of 30 |
| Ansongo | Gao | 0.516 | 4 of 5 |
They fall into three geographic clusters: the Niger River corridor in Gao (Ménaka, Bourem, Ansongo), Tombouctou, and Bankass on the central plateau near the Burkina Faso border.
Plotting the two main signals against each other shows where they agree. Cercles in the upper right have both high conflict and high closure rates. Cercles along the bottom edge are the data gaps: zero closures recorded, simply because no schools were matched.

Conflict intensity (log scale) against school closure rate. Each bubble is a cercle, sized by population. Hollow rings mark cercles with little or no school data, which is why so many sit at 0%.
Does the answer depend on the time window?
A ranking that flips when you nudge an assumption isn't much use for planning. So I reran the whole pipeline using only 2022–2024 conflict data and compared the results.
Four of the five Critical cercles stayed in place (Ménaka, Tombouctou, Bourem, Bankass). Only the fifth slot changed: Ansongo dropped out and Diré came in. Ansongo's ranking leans on heavy conflict in 2020–2021, while Diré's 100% closure rate doesn't depend on the window. That gives field teams a four-cercle core they can trust, plus one borderline slot worth discussing.
Turning it into a map people can use
The output is a static site on GitHub Pages: a map page and a dashboard page. A Python script writes both HTML files directly, with every number computed from the data at build time, so the text on the page can never drift from the analysis.
The map uses Leaflet with cercle boundaries from geoBoundaries (CC BY 4.0). A few design choices made the biggest difference:
Shaded areas, not dots. My first version drew one circle per cercle, placed at its main town and sized by score:

Before: one dot per cercle. It's accurate, but it's hard to see which areas are affected or how big they are.
Filling each cercle's real boundary by tier (the map at the top of this post) makes the geographic clusters jump out immediately. It also makes the data gaps visible: the big grey, dash-outlined block in the south-west is the Data-Limited tier.
A legend that says what to do. Each tier is paired with a suggested action: Critical → field-validate first, High → next phase of assessment, Data-Limited → verify data before ruling out. A colour scale on its own leaves the reader to work that out.
A story panel. A one-sentence answer, headline numbers with their sources, and cluster cards that zoom the map to those cercles when clicked.

The story panel answers the question before the reader has to interpret any colours. The cluster cards cover all five Critical cercles, plus one card for the data gap.
Show the numbers behind every score. Clicking a cercle opens its figures and a bar splitting the score into its three parts.

Clicking Bankass shows its raw figures and how its score of 0.519 is built. Hold on to that bar; it matters later.
Explain the rules on the page itself. Below the map, a "How to read this map" section sets out the three indicators with their weights and sources, and the rule for every tier in plain language, so nobody has to open the methodology file to understand a colour.

The "How to read this map" section and the link to the repository, where every figure can be reproduced.
A dashboard for the detail. A second page holds the charts: the top 10 cercles, the conflict-versus-closure scatter, the tier split and conflict by region.

The analytics dashboard. The Critical cercles are highlighted in red in the top-10 chart.
Two practical lessons:
- Embed your boundaries. I inlined the simplified GeoJSON (rounded to 3 decimal places) straight into the page instead of fetching it at runtime. The page grew from about 75 KB to about 300 KB, but it works offline and can't break when someone else's URL changes.
- Check your basemap. CARTO's free basemap tiles now need an API key, and without one they show an "API KEY REQUIRED" watermark. My live site was showing it before I noticed. I switched to Esri's light grey canvas, which still works without a key.
What the map taught me about my own formula
That score-breakdown bar turned out to be the most useful thing I built, because it exposed a weakness in the formula.
Look at Bankass above: 0.48 of its 0.519 comes from closures, and only 0.02 and 0.01 from conflict. That isn't because Bankass is peaceful. It recorded 313 conflict events and 1,170 fatalities.
The cause is normalising to the maximum. One cercle, Abeibara, a sparsely populated area in the Kidal region, has 1,072 events and 6,840 fatalities per 100,000 people. That's about 40 times the median cercle's event rate (25 per 100k). Because every cercle is divided by Abeibara's value, the conflict parts of most cercles' scores shrink to almost nothing. In practice, the "50/25/25" index ranks most of Mali by closure rate alone.
The fix is straightforward: cap each indicator at a high percentile (say the 95th) before scaling, or rank on a log scale. I haven't changed it yet, because the Critical list is the part people would act on and I want to check how much it moves first. But I would never have spotted this from the ranking table alone. Showing how each score is built is a check on your own method, not only a courtesy to readers.
Limitations
- 23 of 50 cercles have no school data matched. The Data-Limited tier makes this visible, but it doesn't fix it.
- Closure status is a snapshot. The registry shows current status, not when a school closed or whether it has reopened.
- ACLED fatality counts are conservative, and probably most under-reported in exactly the remote cercles where school data is also thin.
- ACLED is licensed CC BY-NC, so the outputs can't be reused commercially.
- No automatic refresh. The pipeline runs once; ACLED publishes weekly updates that a scheduled job could pull in.
The most useful finding is a disagreement
The map is a starting point for conversation, not an answer. Where it disagrees with what education teams know on the ground, that disagreement is the most valuable result. It points either to a gap in the data or to a condition the data can't see, and either one improves the method.
The approach isn't specific to Mali. Any country with ACLED coverage, a school registry and admin-level population figures can be scored the same way. The code, data, methodology and source notes are all public:
👉 https://github.com/MegPhapha/EduDisruption-Index
I'd appreciate feedback, especially from anyone who has built composite indices before. How would you handle the normalisation problem?
Next in the series: turning this index into a bilingual AI tutor that keeps students learning when the classroom is gone.
Top comments (0)