The graduate squeeze: young graduates, unemployment and AI exposure in Africa¶
Evidence from Ghana, Nigeria, Kenya, Egypt, Senegal and South Africa
Emmanuel Edudzie, October 2026. Analysis code written with substantial help from Claude (Anthropic), under the author's direction.
The question. The first note in this series found that few young workers hold jobs exposed to generative AI, and that the exposure sits in office work. The second found that students are a large share of early AI users. This note asks about the people in between: young graduates. Do they find work, and is the work they find the kind that generative AI could change?
Graduates here means people with advanced education in the ILO's aggregate levels, which is tertiary education: ISCED 2011 levels 5–8 (short-cycle tertiary, bachelor's, master's, doctoral). Section 1 checks this against the detailed ISCED levels in the data.
Data sources¶
| File | What it is |
|---|---|
UNE_DEAP_SEX_AGE_EDU_RT_A.csv |
ILOSTAT: unemployment rate by sex, age and education (%), annual |
EMP_TEMP_SEX_AGE_EDU_NB_A.csv |
ILOSTAT: employment by sex, age and education (thousands), annual |
EMP_TEMP_SEX_OCU_EDU_NB_A.csv |
ILOSTAT: employment by sex, occupation and education (thousands), annual |
¶
All three ILOSTAT files were downloaded on 30 October 2026 for Egypt, Ghana, Kenya, Nigeria, Senegal and South Africa, 2015 onwards.
0. Set-up¶
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
ROOT = Path.cwd()
RAW, CLEAN, FIG = ROOT / "data" / "raw", ROOT / "data" / "clean", ROOT / "outputs" / "figures"
for folder in (CLEAN, FIG):
folder.mkdir(parents=True, exist_ok=True)
pd.set_option("display.width", 160)
SURFACE, INK, INK_2, GRID = "#fcfcfb", "#0b0b0b", "#52514e", "#e4e3df"
BLUE, GREY, REFERENCE = "#2a78d6", "#c3c2b7", "#8a8984"
plt.rcParams.update({
"figure.facecolor": SURFACE, "axes.facecolor": SURFACE, "savefig.facecolor": SURFACE,
"axes.edgecolor": GRID, "axes.labelcolor": INK_2, "xtick.color": INK_2, "ytick.color": INK,
"text.color": INK, "font.size": 10, "axes.spines.top": False, "axes.spines.right": False,
})
def frame(fig, title, subtitle, source):
"""Title and subtitle at the top left, source line at the bottom left."""
fig.text(0.01, 0.985, title, fontsize=12, fontweight="bold", color=INK, va="top")
fig.text(0.01, 0.925, subtitle, fontsize=9.5, color=INK_2, va="top")
fig.text(0.01, 0.01, source, fontsize=7.5, color=INK_2, va="bottom")
def half_up(x, places=1):
"""Round halves up (12.25 -> 12.3). Python's round() can go either way."""
return float(Decimal(str(round(float(x), 6))).quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP))
# The latest survey year in each file for every country (section 1 checks the sources)
SELECTED = {"Ghana": 2025, "Nigeria": 2025, "Kenya": 2021, "Egypt": 2024, "Senegal": 2025, "South Africa": 2025}
COUNTRIES = list(SELECTED)
EDU_ORDER = ["Less than basic", "Basic", "Intermediate", "Advanced"]
GROUP_NAMES = {
"1": "Managers", "2": "Professionals", "3": "Technicians and associate professionals",
"4": "Clerical support workers", "5": "Service and sales workers",
"6": "Skilled agricultural, forestry and fishery workers", "7": "Craft and related trades workers",
"8": "Plant and machine operators, and assemblers", "9": "Elementary occupations",
}
def load(name):
"""Read an ILOSTAT file, keep the chosen country-years and the aggregate education levels."""
d = pd.read_csv(RAW / f"{name}.csv")
d.columns = ["country", "source", "indicator", "sex", "classif1", "edu", "time", "value", "status",
"note_classif", "note_indicator", "note_source"]
keep = np.array([SELECTED.get(c) == t for c, t in zip(d["country"], d["time"])])
d = d[keep & d["edu"].str.startswith("Education (Aggregate levels)").to_numpy()].copy()
d["edu"] = d["edu"].str.split(": ").str[1]
return d
une = load("UNE_DEAP_SEX_AGE_EDU_RT_A")
emp_age = load("EMP_TEMP_SEX_AGE_EDU_NB_A")
emp_occ = load("EMP_TEMP_SEX_OCU_EDU_NB_A")
pd.concat({"unemployment": une.groupby(["country", "time"])["source"].first(),
"employment by age": emp_age.groupby(["country", "time"])["source"].first(),
"employment by occupation": emp_occ.groupby(["country", "time"])["source"].first()}, axis=1)
| unemployment | employment by age | employment by occupation | ||
|---|---|---|---|---|
| country | time | |||
| Egypt | 2024 | LFS - Labour Force Sample Survey | LFS - Labour Force Sample Survey | LFS - Labour Force Sample Survey |
| Ghana | 2025 | LFS - Labour Force Survey | LFS - Labour Force Survey | LFS - Labour Force Survey |
| Kenya | 2021 | HS - Continuous household survey | HS - Continuous household survey | HS - Continuous household survey |
| Nigeria | 2025 | LFS - Unemployment, Under-employment Watch | LFS - Unemployment, Under-employment Watch | LFS - Unemployment, Under-employment Watch |
| Senegal | 2025 | LFS - National Employment Survey | LFS - National Employment Survey | LFS - National Employment Survey |
| South Africa | 2025 | LFS - Quarterly Labour Force Survey | LFS - Quarterly Labour Force Survey | LFS - Quarterly Labour Force Survey |
1b. "Advanced" means tertiary education¶
The detailed ISCED 2011 levels 5–8 should add up to the "Advanced" aggregate. We check it on total employment aged 15+ in each country that reports ISCED 2011.
raw_age = pd.read_csv(RAW / "EMP_TEMP_SEX_AGE_EDU_NB_A.csv")
raw_age = raw_age[np.array([SELECTED.get(c) == t for c, t in zip(raw_age["ref_area.label"], raw_age["time"])])
& (raw_age["sex.label"] == "Total") & (raw_age["classif1.label"] == "Age (Youth, adults): 15+")]
rows = []
for country, d in raw_age.groupby("ref_area.label"):
v = d.set_index("classif2.label")["obs_value"]
isced = v[v.index.str.startswith("Education (ISCED-11)")]
if len(isced):
rows.append({"country": country, "ISCED 5-8 (000s)": isced[isced.index.str.contains(r": [5-8]\.")].sum(),
"Advanced (000s)": v["Education (Aggregate levels): Advanced"]})
pd.DataFrame(rows).round(1)
| country | ISCED 5-8 (000s) | Advanced (000s) | |
|---|---|---|---|
| 0 | Egypt | 6107.6 | 6107.6 |
| 1 | Ghana | 1297.4 | 1297.4 |
| 2 | Kenya | 1045.8 | 1045.8 |
| 3 | Nigeria | 7919.2 | 7919.2 |
| 4 | Senegal | 279.1 | 279.1 |
| 5 | South Africa | 3426.3 | 3426.3 |
1c. Cells ILOSTAT flags as unreliable¶
Small samples are flagged. We list the flagged cells that touch the figures used below.
flag_une = une[(une["status"] == "Unreliable") & une["classif1"].isin(
["Age (Youth, adults): 15-24", "Age (10-year bands): 25-34"]) & une["edu"].isin(["Advanced", "Total"])]
flag_occ = emp_occ[(emp_occ["status"] == "Unreliable") & (emp_occ["edu"] == "Advanced")]
display(flag_une[["country", "sex", "classif1", "edu", "value"]])
flag_occ[["country", "sex", "classif1", "value"]]
| country | sex | classif1 | edu | value | |
|---|---|---|---|---|---|
| 10572 | Kenya | Male | Age (Youth, adults): 15-24 | Advanced | 18.737 |
| 16401 | Senegal | Male | Age (Youth, adults): 15-24 | Advanced | 21.998 |
| 16422 | Senegal | Female | Age (Youth, adults): 15-24 | Advanced | 36.777 |
| country | sex | classif1 | value | |
|---|---|---|---|---|
| 336 | Egypt | Female | Occupation (ISCO-08): 6. Skilled agricultural,... | 4.338 |
| 6496 | Ghana | Total | Occupation (Skill level): X - Not elsewhere cl... | 4.601 |
| 6523 | Ghana | Male | Occupation (Skill level): X - Not elsewhere cl... | 4.601 |
| 6545 | Ghana | Female | Occupation (Skill level): Skill level 1 ~ low | 4.609 |
| 6550 | Ghana | Female | Occupation (Skill level): X - Not elsewhere cl... | NaN |
| 6762 | Ghana | Total | Occupation (ISCO-08): 0. Armed forces occupations | 4.601 |
| 6821 | Ghana | Male | Occupation (ISCO-08): 0. Armed forces occupations | 4.601 |
| 6870 | Ghana | Female | Occupation (ISCO-08): 8. Plant and machine ope... | 2.344 |
| 6875 | Ghana | Female | Occupation (ISCO-08): 9. Elementary occupations | 4.609 |
| 6880 | Ghana | Female | Occupation (ISCO-08): 0. Armed forces occupations | NaN |
| 11980 | Kenya | Total | Occupation (Skill level): X - Not elsewhere cl... | NaN |
| 12010 | Kenya | Male | Occupation (Skill level): X - Not elsewhere cl... | NaN |
| 12040 | Kenya | Female | Occupation (Skill level): X - Not elsewhere cl... | NaN |
| 12261 | Kenya | Total | Occupation (ISCO-08): X. Not elsewhere classified | NaN |
| 12308 | Kenya | Male | Occupation (ISCO-08): 7. Craft and related tra... | 8.185 |
| 12326 | Kenya | Male | Occupation (ISCO-08): X. Not elsewhere classified | NaN |
| 12373 | Kenya | Female | Occupation (ISCO-08): 7. Craft and related tra... | 3.993 |
| 12391 | Kenya | Female | Occupation (ISCO-08): X. Not elsewhere classified | NaN |
| 13791 | Nigeria | Total | Occupation (ISCO-08): 0. Armed forces occupations | 33.415 |
| 13848 | Nigeria | Male | Occupation (ISCO-08): 0. Armed forces occupations | 19.481 |
| 13897 | Nigeria | Female | Occupation (ISCO-08): 8. Plant and machine ope... | 28.539 |
| 13905 | Nigeria | Female | Occupation (ISCO-08): 0. Armed forces occupations | NaN |
| 15091 | Senegal | Female | Occupation (Skill level): Skill level 1 ~ low | 2.050 |
| 15295 | Senegal | Total | Occupation (ISCO-08): 6. Skilled agricultural,... | 2.608 |
| 15300 | Senegal | Total | Occupation (ISCO-08): 7. Craft and related tra... | 3.267 |
| 15306 | Senegal | Total | Occupation (ISCO-08): 8. Plant and machine ope... | 3.137 |
| 15316 | Senegal | Total | Occupation (ISCO-08): 0. Armed forces occupations | 4.638 |
| 15362 | Senegal | Male | Occupation (ISCO-08): 6. Skilled agricultural,... | 2.208 |
| 15367 | Senegal | Male | Occupation (ISCO-08): 7. Craft and related tra... | 2.460 |
| 15373 | Senegal | Male | Occupation (ISCO-08): 8. Plant and machine ope... | 2.968 |
| 15383 | Senegal | Male | Occupation (ISCO-08): 0. Armed forces occupations | 4.166 |
| 15401 | Senegal | Female | Occupation (ISCO-08): 1. Managers | 2.958 |
| 15424 | Senegal | Female | Occupation (ISCO-08): 5. Service and sales wor... | 5.363 |
| 15429 | Senegal | Female | Occupation (ISCO-08): 6. Skilled agricultural,... | NaN |
| 15434 | Senegal | Female | Occupation (ISCO-08): 7. Craft and related tra... | NaN |
| 15440 | Senegal | Female | Occupation (ISCO-08): 8. Plant and machine ope... | NaN |
| 15445 | Senegal | Female | Occupation (ISCO-08): 9. Elementary occupations | 2.050 |
| 15450 | Senegal | Female | Occupation (ISCO-08): 0. Armed forces occupations | NaN |
| 20727 | South Africa | Total | Occupation (ISCO-88): 6. Skilled agricultural ... | 5.601 |
| 20750 | South Africa | Total | Occupation (ISCO-88): 0. Armed forces | 3.942 |
| 20797 | South Africa | Male | Occupation (ISCO-88): 6. Skilled agricultural ... | 4.760 |
| 20820 | South Africa | Male | Occupation (ISCO-88): 0. Armed forces | 3.942 |
| 20867 | South Africa | Female | Occupation (ISCO-88): 6. Skilled agricultural ... | NaN |
| 20890 | South Africa | Female | Occupation (ISCO-88): 0. Armed forces | NaN |
None of the flagged cells is a headline total. The figures for graduate men and women aged 15–24 in Kenya and Senegal rest on flagged cells, so the sex split below uses ages 25–34.
AGES = {"Age (Youth, adults): 15-24": "15–24", "Age (10-year bands): 25-34": "25–34"}
u = une[une["classif1"].isin(AGES)].assign(age=lambda d: d["classif1"].map(AGES))
rates = (u[u["sex"] == "Total"].pivot_table(index=["country", "age"], columns="edu", values="value")
[EDU_ORDER + ["Total"]].reindex(COUNTRIES, level=0))
rates.to_csv(CLEAN / "unemployment_by_education.csv")
rates.round(1)
| edu | Less than basic | Basic | Intermediate | Advanced | Total | |
|---|---|---|---|---|---|---|
| country | age | |||||
| Ghana | 15–24 | 3.5 | 4.0 | 7.4 | 13.2 | 5.9 |
| 25–34 | 1.7 | 2.5 | 4.9 | 10.7 | 4.4 | |
| Nigeria | 15–24 | 3.8 | 4.6 | 7.2 | 18.5 | 7.0 |
| 25–34 | 2.8 | 3.7 | 4.5 | 16.9 | 5.9 | |
| Kenya | 15–24 | 9.6 | 8.2 | 14.6 | 22.8 | 12.3 |
| 25–34 | 4.3 | 5.0 | 10.0 | 11.7 | 8.1 | |
| Egypt | 15–24 | 5.6 | 6.0 | 13.9 | 42.8 | 15.3 |
| 25–34 | 2.6 | 3.0 | 6.0 | 17.7 | 8.5 | |
| Senegal | 15–24 | 2.3 | 10.2 | 17.6 | 29.8 | 5.0 |
| 25–34 | 3.2 | 9.4 | 21.3 | 22.0 | 7.8 | |
| South Africa | 15–24 | 46.3 | 52.4 | 56.2 | 35.8 | 53.2 |
| 25–34 | 34.2 | 44.2 | 37.7 | 21.4 | 37.3 |
gap = rates[["Advanced", "Total"]].copy()
gap["graduates ÷ all"] = gap["Advanced"] / gap["Total"]
gap.round(1)
| edu | Advanced | Total | graduates ÷ all | |
|---|---|---|---|---|
| country | age | |||
| Ghana | 15–24 | 13.2 | 5.9 | 2.2 |
| 25–34 | 10.7 | 4.4 | 2.5 | |
| Nigeria | 15–24 | 18.5 | 7.0 | 2.7 |
| 25–34 | 16.9 | 5.9 | 2.9 | |
| Kenya | 15–24 | 22.8 | 12.3 | 1.9 |
| 25–34 | 11.7 | 8.1 | 1.5 | |
| Egypt | 15–24 | 42.8 | 15.3 | 2.8 |
| 25–34 | 17.7 | 8.5 | 2.1 | |
| Senegal | 15–24 | 29.8 | 5.0 | 5.9 |
| 25–34 | 22.0 | 7.8 | 2.8 | |
| South Africa | 15–24 | 35.8 | 53.2 | 0.7 |
| 25–34 | 21.4 | 37.3 | 0.6 |
Reading this. In five of the six countries, graduates are more likely than their peers to be unemployed, at both ages. South Africa is the exception: unemployment is very high for everyone, and lowest for graduates. A common explanation for the pattern elsewhere is that where most work is informal and there are no unemployment benefits, people with little education cannot afford to stay unemployed, while graduates can wait longer for the formal jobs they trained for.
2b. Graduate unemployment by sex, ages 25–34¶
by_sex = (u[(u["age"] == "25–34") & (u["edu"] == "Advanced")]
.pivot_table(index="country", columns="sex", values="value")[["Female", "Male", "Total"]]
.reindex(COUNTRIES))
by_sex.to_csv(CLEAN / "graduate_unemployment_by_sex_25_34.csv")
by_sex.round(1)
| sex | Female | Male | Total |
|---|---|---|---|
| country | |||
| Ghana | 11.0 | 10.6 | 10.7 |
| Nigeria | 16.9 | 17.0 | 16.9 |
| Kenya | 12.6 | 10.8 | 11.7 |
| Egypt | 31.0 | 10.6 | 17.7 |
| Senegal | 25.2 | 20.2 | 22.0 |
| South Africa | 24.7 | 16.9 | 21.4 |
2c. Chart: unemployment of graduates and of all young people¶
fig, axes = plt.subplots(1, 2, figsize=(8, 4.2), sharey=True)
fig.subplots_adjust(left=0.15, right=0.97, top=0.74, bottom=0.14, wspace=0.14)
order = rates.xs("25–34", level="age")["Advanced"].sort_values().index.tolist() # bottom to top
for ax, age in zip(axes, ["15–24", "25–34"]):
r = rates.xs(age, level="age").loc[order]
for y, (country, row) in enumerate(r.iterrows()):
ax.plot([row["Total"], row["Advanced"]], [y, y], color=GRID, linewidth=3, zorder=1)
ax.scatter(row["Total"], y, s=60, color=GREY, zorder=2, label="All in the age group" if y == 0 else None)
ax.scatter(row["Advanced"], y, s=60, color=BLUE, zorder=3, label="Graduates" if y == 0 else None)
label = f"{half_up(row['Advanced'], 0):.0f}"
if row["Advanced"] >= row["Total"]: # graduate dot on the right: label to its right
ax.text(row["Advanced"] + 2.2, y, label, va="center", ha="left", fontsize=8.5, color=BLUE, fontweight="bold")
else: # South Africa: graduate dot on the left
ax.text(row["Advanced"] - 2.2, y, label, va="center", ha="right", fontsize=8.5, color=BLUE, fontweight="bold")
ax.set_title(f"Aged {age}", fontsize=9.5, loc="left", fontweight="bold", color=INK)
ax.set_xlim(0, 62)
ax.set_xticks([0, 20, 40, 60], ["0", "20", "40", "60%"])
ax.grid(axis="x", color=GRID, linewidth=0.8)
ax.set_axisbelow(True)
ax.spines[["left", "bottom"]].set_visible(False)
ax.tick_params(axis="both", length=0)
ax.set_yticks(range(len(order)), order)
axes[0].legend(frameon=False, loc="lower left", bbox_to_anchor=(0.0, 1.08), ncol=2, fontsize=8.5,
handletextpad=0.2, columnspacing=1.2)
frame(fig, "Young graduates are more likely to be unemployed than their peers, except in South Africa",
"Unemployment rate (%): people with tertiary education against everyone in the age group",
"Source: ILOSTAT, unemployment rate by sex, age and education (Ghana 2025, Nigeria 2025, Kenya 2021, Egypt 2024,\n"
"Senegal 2025, South Africa 2025). Graduates = advanced (tertiary) education, ISCED 2011 levels 5–8.")
fig.savefig(FIG / "graduate_unemployment.png", dpi=200)
plt.show()
3. How many young workers are graduates?¶
Share of employed people with tertiary education, by age.
AGE3 = {"Age (Youth, adults): 15-24": "15–24", "Age (10-year bands): 25-34": "25–34", "Age (Youth, adults): 25+": "25+"}
e = emp_age[(emp_age["sex"] == "Total") & emp_age["classif1"].isin(AGE3)].assign(age=lambda d: d["classif1"].map(AGE3))
e = e.pivot_table(index=["country", "age"], columns="edu", values="value")
grad_share = (e["Advanced"] / e["Total"] * 100).unstack("age").reindex(COUNTRIES)
grad_share.to_csv(CLEAN / "graduate_share_of_employment.csv")
grad_share.round(1)
| age | 15–24 | 25+ | 25–34 |
|---|---|---|---|
| country | |||
| Ghana | 5.6 | 10.0 | 14.1 |
| Nigeria | 4.1 | 9.8 | 11.7 |
| Kenya | 3.4 | 5.7 | 7.9 |
| Egypt | 10.3 | 20.1 | 25.6 |
| Senegal | 1.3 | 6.7 | 8.7 |
| South Africa | 8.6 | 19.6 | 18.1 |
4. Where do employed graduates work?¶
ILOSTAT publishes employment by occupation and education for all ages together, not by age, so this section covers all employed graduates. It uses the nine major occupation groups and the exposure tiers from the first note:
| Tier | Occupation groups |
|---|---|
| High | 4 Clerical support |
| Mixed | 2 Professionals, 3 Technicians (finance, ICT and media highly exposed; teaching and health not) |
| Partial | 5 Service and sales |
| Low | 1 Managers, 6 Farming, 7 Crafts, 8 Machine operators, 9 Elementary work |
Senegal is excluded here: 65% of its workers' occupations are unclassified. South Africa reports the older ISCO-88 groups, as in the first note. Unclassified workers and the armed forces are left out of the denominators.
o = emp_occ[emp_occ["country"] != "Senegal"].copy()
o["scheme"] = o["classif1"].str.extract(r"\((ISCO-08|ISCO-88)\)")[0]
o = o.dropna(subset=["scheme"])
preferred = o.groupby("country")["scheme"].agg(lambda s: "ISCO-08" if "ISCO-08" in set(s) else "ISCO-88")
o = o[o["scheme"] == o["country"].map(preferred)]
o["group"] = o["classif1"].str.split(": ").str[1].str.split(".").str[0]
total = o[(o["group"] == "Total") & (o["sex"] == "Total")].set_index(["country", "edu"])["value"]
unclassified = o[(o["group"] == "X") & (o["sex"] == "Total")].set_index(["country", "edu"])["value"]
print("Unclassified occupation, % of employed:")
display((unclassified / total * 100).unstack("edu").reindex(columns=EDU_ORDER + ["Total"]).round(1))
nine = o[o["group"].isin(list("123456789"))].copy()
nine["share"] = nine["value"] / nine.groupby(["country", "sex", "edu"])["value"].transform("sum") * 100
nine.to_csv(CLEAN / "employment_shares_by_group_and_education.csv", index=False)
TIERS = {"4": "High: clerical support", "2": "Mixed: professionals and technicians",
"3": "Mixed: professionals and technicians", "5": "Partial: service and sales"}
TIER_ORDER = ["High: clerical support", "Mixed: professionals and technicians",
"Partial: service and sales", "Low: farming, crafts, operators, elementary, managers"]
nine["tier"] = nine["group"].map(TIERS).fillna(TIER_ORDER[3])
tiers = nine.groupby(["country", "sex", "edu", "tier"])["share"].sum().unstack("tier")[TIER_ORDER]
tiers.round(2).to_csv(CLEAN / "employment_shares_by_tier_and_education.csv")
tiers.xs("Total", level="sex").round(1)
Unclassified occupation, % of employed:
| edu | Less than basic | Basic | Intermediate | Advanced | Total |
|---|---|---|---|---|---|
| country | |||||
| Egypt | 14.5 | 12.8 | 6.0 | 2.6 | 8.4 |
| Ghana | NaN | NaN | NaN | NaN | NaN |
| Kenya | 2.0 | 1.2 | 0.9 | NaN | 1.1 |
| Nigeria | 1.6 | 2.5 | 3.1 | 3.1 | 2.8 |
| South Africa | 26.5 | 9.3 | 3.2 | 1.0 | 6.2 |
| tier | High: clerical support | Mixed: professionals and technicians | Partial: service and sales | Low: farming, crafts, operators, elementary, managers | |
|---|---|---|---|---|---|
| country | edu | ||||
| Egypt | Advanced | 8.8 | 60.3 | 12.8 | 18.1 |
| Basic | 0.6 | 2.1 | 19.6 | 77.6 | |
| Intermediate | 6.5 | 14.2 | 20.7 | 58.7 | |
| Less than basic | 0.2 | 1.0 | 17.5 | 81.3 | |
| Total | 4.7 | 18.7 | 18.3 | 58.4 | |
| Ghana | Advanced | 6.8 | 49.9 | 15.0 | 28.3 |
| Basic | 0.4 | 1.2 | 27.7 | 70.7 | |
| Intermediate | 3.2 | 6.7 | 32.0 | 58.1 | |
| Less than basic | 0.0 | 0.4 | 16.3 | 83.3 | |
| Level not stated | NaN | NaN | 58.5 | 41.5 | |
| Total | 1.5 | 6.7 | 25.4 | 66.4 | |
| Kenya | Advanced | 3.1 | 52.3 | 2.8 | 41.8 |
| Basic | 0.1 | 2.1 | 6.2 | 91.6 | |
| Intermediate | 1.7 | 12.3 | 12.0 | 74.0 | |
| Less than basic | NaN | 2.6 | 3.7 | 93.7 | |
| Level not stated | 0.0 | 20.7 | 0.0 | 79.3 | |
| Total | 0.9 | 9.1 | 8.2 | 81.7 | |
| Nigeria | Advanced | 4.6 | 32.5 | 27.2 | 35.6 |
| Basic | 0.3 | 1.7 | 30.8 | 67.3 | |
| Intermediate | 0.7 | 6.4 | 33.8 | 59.1 | |
| Less than basic | NaN | 1.5 | 25.1 | 73.4 | |
| Total | 0.9 | 7.1 | 32.0 | 59.9 | |
| South Africa | Advanced | 13.4 | 49.7 | 8.2 | 28.7 |
| Basic | 3.6 | 2.3 | 16.2 | 77.8 | |
| Intermediate | 16.3 | 9.1 | 23.6 | 51.0 | |
| Less than basic | 0.6 | 1.7 | 8.0 | 89.7 | |
| Level not stated | 5.7 | 8.9 | 13.1 | 72.2 | |
| Total | 10.8 | 14.7 | 17.3 | 57.3 |
OCC_COUNTRIES = [c for c in COUNTRIES if c != "Senegal"]
t = tiers.xs("Total", level="sex")
office = pd.DataFrame({"Graduates": t.xs("Advanced", level="edu")[TIER_ORDER[:2]].sum(axis=1),
"All workers": t.xs("Total", level="edu")[TIER_ORDER[:2]].sum(axis=1)}).reindex(OCC_COUNTRIES)
# basic or less: recombine the two lowest levels by numbers employed, not by averaging shares
low_edu = nine[(nine["sex"] == "Total") & nine["edu"].isin(["Less than basic", "Basic"])]
low_edu = low_edu.assign(office=low_edu["tier"].isin(TIER_ORDER[:2]))
office["Basic or less"] = (low_edu[low_edu["office"]].groupby("country")["value"].sum()
/ low_edu.groupby("country")["value"].sum() * 100)
office["graduates ÷ all"] = office["Graduates"] / office["All workers"]
office.round(1)
| Graduates | All workers | Basic or less | graduates ÷ all | |
|---|---|---|---|---|
| country | ||||
| Ghana | 56.7 | 8.2 | 1.3 | 6.9 |
| Nigeria | 37.1 | 8.0 | 1.8 | 4.6 |
| Kenya | 55.4 | 10.1 | 2.3 | 5.5 |
| Egypt | 69.1 | 23.4 | 1.8 | 3.0 |
| South Africa | 63.0 | 25.5 | 5.5 | 2.5 |
Graduates are far more likely than other workers to hold clerical, professional and technical jobs, the tiers where generative AI exposure is concentrated. Part of the "mixed" tier is teaching and health, which are not exposed, so this is an upper bound on graduates' exposure.
4b. Chart: exposure tiers of graduates' jobs and of all jobs¶
TIER_STYLE = {TIER_ORDER[0]: (BLUE, 1.0), TIER_ORDER[1]: (BLUE, 0.62), TIER_ORDER[2]: (BLUE, 0.3), TIER_ORDER[3]: (GREY, 1.0)}
SHORT = {TIER_ORDER[0]: "High: clerical", TIER_ORDER[1]: "Mixed: professionals, technicians",
TIER_ORDER[2]: "Partial: service, sales", TIER_ORDER[3]: "Low: other"}
fig, ax = plt.subplots(figsize=(8, 5.4))
fig.subplots_adjust(left=0.2, right=0.86, top=0.8, bottom=0.1)
labels, ypos, data = [], [], []
for i, country in enumerate(OCC_COUNTRIES[::-1]):
for j, (edu, name) in enumerate([("Total", "All workers"), ("Advanced", "Graduates")]):
y = i * 2.6 + j
ypos.append(y)
labels.append(f"{country} {name}" if j == 1 else name)
data.append(t.loc[(country, edu)])
data = pd.DataFrame(data)
left = np.zeros(len(data))
for tier in TIER_ORDER:
colour, alpha = TIER_STYLE[tier]
ax.barh(ypos, data[tier], left=left, color=colour, alpha=alpha, edgecolor=SURFACE, linewidth=1.2, height=0.8,
label=SHORT[tier])
left += data[tier].to_numpy()
for y, v in zip(ypos, data[TIER_ORDER[:2]].sum(axis=1)):
ax.text(103, y, f"{half_up(v, 0):.0f}%", va="center", fontsize=9, fontweight="bold", color=BLUE)
ax.text(103, max(ypos) + 1.1, "Office-based\n(high + mixed)", fontsize=8, color=INK_2, va="bottom")
ax.set_yticks(ypos, [lab.replace(" ", "\n") if "Graduates" in lab else lab for lab in labels], fontsize=8.5)
ax.set_xlim(0, 100)
ax.set_xticks([0, 25, 50, 75, 100])
ax.tick_params(axis="both", length=0)
ax.spines[["left", "bottom"]].set_visible(False)
ax.legend(frameon=False, loc="lower left", bbox_to_anchor=(-0.22, 1.0), ncol=4, fontsize=8, handlelength=1.2,
columnspacing=1.0)
frame(fig, "Graduates hold the office jobs where generative AI exposure is concentrated",
"Share of employed people with a known occupation, by exposure tier of their occupation group (%)",
"Source: ILOSTAT, employment by sex, occupation and education (Ghana 2025, Nigeria 2025, Kenya 2021, Egypt 2024,\n"
"South Africa 2025); tiers from ILO Working Paper 140. All ages. South Africa uses ISCO-88 major groups.")
fig.savefig(FIG / "graduate_exposure_tiers.png", dpi=200)
plt.show()
5. How many graduates work below graduate level?¶
The ILO's standard (normative) measure links occupation groups to skill levels: groups 1–3 (managers, professionals, technicians) are skill levels 3–4, which usually call for tertiary education; groups 4–9 are skill levels 1–2. A graduate in groups 4–9 is counted as working below graduate level ("overeducated").
grads = nine[(nine["sex"] == "Total") & (nine["edu"] == "Advanced")].pivot_table(
index="country", columns="group", values="share").reindex(OCC_COUNTRIES)
below = pd.DataFrame({
"Clerical (group 4)": grads["4"],
"Service and sales (group 5)": grads["5"],
"Farming, crafts, operators, elementary (6–9)": grads[["6", "7", "8", "9"]].sum(axis=1),
})
below["Below graduate level, total"] = below.sum(axis=1)
below.to_csv(CLEAN / "graduates_below_graduate_level.csv")
below.round(1)
| Clerical (group 4) | Service and sales (group 5) | Farming, crafts, operators, elementary (6–9) | Below graduate level, total | |
|---|---|---|---|---|
| country | ||||
| Ghana | 6.8 | 15.0 | 18.3 | 40.1 |
| Nigeria | 4.6 | 27.2 | 31.2 | 63.0 |
| Kenya | 3.1 | 2.8 | 18.0 | 23.9 |
| Egypt | 8.8 | 12.8 | 10.3 | 31.9 |
| South Africa | 13.4 | 8.2 | 7.8 | 29.4 |
by_sex_below = (nine[(nine["edu"] == "Advanced") & nine["group"].isin(list("456789"))]
.groupby(["country", "sex"])["share"].sum().unstack("sex")[["Female", "Male"]].reindex(OCC_COUNTRIES))
by_sex_below.round(1)
| sex | Female | Male |
|---|---|---|
| country | ||
| Ghana | 42.3 | 38.5 |
| Nigeria | 65.1 | 61.8 |
| Kenya | 17.2 | 28.4 |
| Egypt | 16.3 | 38.3 |
| South Africa | 31.6 | 26.6 |
fig, ax = plt.subplots(figsize=(8, 3.9))
fig.subplots_adjust(left=0.16, right=0.95, top=0.72, bottom=0.12)
b = below.sort_values("Below graduate level, total")
parts = [("Clerical (group 4)", BLUE, 1.0, "Clerical (high exposure)"),
("Service and sales (group 5)", BLUE, 0.35, "Service and sales"),
("Farming, crafts, operators, elementary (6–9)", GREY, 1.0, "Farming, crafts, operators, elementary")]
left = np.zeros(len(b))
for col, colour, alpha, name in parts:
ax.barh(b.index, b[col], left=left, color=colour, alpha=alpha, edgecolor=SURFACE, linewidth=1.2, height=0.65,
label=name)
left += b[col].to_numpy()
for y, v in enumerate(b["Below graduate level, total"]):
ax.text(v + 1, y, f"{half_up(v, 0):.0f}%", va="center", fontsize=9.5, fontweight="bold", color=INK)
ax.set_xlim(0, 75)
ax.set_xticks([])
ax.spines[["left", "bottom"]].set_visible(False)
ax.tick_params(axis="y", length=0, labelsize=10)
ax.legend(frameon=False, loc="lower left", bbox_to_anchor=(-0.2, 1.0), ncol=3, fontsize=8, handlelength=1.2)
frame(fig, "In Nigeria, nearly two in three employed graduates work below graduate level",
"Share of employed graduates in occupations that do not usually require tertiary education (%), by occupation",
"Source: ILOSTAT, employment by sex, occupation and education (Ghana 2025, Nigeria 2025, Kenya 2021, Egypt 2024,\n"
"South Africa 2025). All ages. Below graduate level = ISCO major groups 4–9 (skill levels 1–2), the ILO's normative measure.")
fig.savefig(FIG / "graduates_below_level.png", dpi=200)
plt.show()
6. Summary numbers for the brief¶
r25, r15 = rates.xs("25–34", level="age"), rates.xs("15–24", level="age")
summary = {
**{f"{c}: unemployment 25–34, graduates (%)": half_up(r25.loc[c, "Advanced"]) for c in COUNTRIES},
**{f"{c}: unemployment 25–34, all (%)": half_up(r25.loc[c, "Total"]) for c in COUNTRIES},
**{f"{c}: unemployment 15–24, graduates (%)": half_up(r15.loc[c, "Advanced"]) for c in COUNTRIES},
**{f"{c}: unemployment 15–24, all (%)": half_up(r15.loc[c, "Total"]) for c in COUNTRIES},
"Egypt: graduate women 25–34 unemployed (%)": half_up(by_sex.loc["Egypt", "Female"]),
"Egypt: graduate men 25–34 unemployed (%)": half_up(by_sex.loc["Egypt", "Male"]),
**{f"{c}: graduates' share of employed 25–34 (%)": half_up(grad_share.loc[c, "25–34"]) for c in COUNTRIES},
**{f"{c}: graduates in office-based jobs (%)": half_up(office.loc[c, "Graduates"]) for c in OCC_COUNTRIES},
**{f"{c}: all workers in office-based jobs (%)": half_up(office.loc[c, "All workers"]) for c in OCC_COUNTRIES},
**{f"{c}: graduates in clerical jobs (%)": half_up(grads.loc[c, "4"]) for c in OCC_COUNTRIES},
**{f"{c}: graduates below graduate level (%)": half_up(below.loc[c, "Below graduate level, total"]) for c in OCC_COUNTRIES},
}
summary = pd.Series(summary)
summary.to_csv(ROOT / "outputs" / "summary_numbers.csv", header=["value"])
summary
Ghana: unemployment 25–34, graduates (%) 10.7 Nigeria: unemployment 25–34, graduates (%) 16.9 Kenya: unemployment 25–34, graduates (%) 11.7 Egypt: unemployment 25–34, graduates (%) 17.7 Senegal: unemployment 25–34, graduates (%) 22.0 South Africa: unemployment 25–34, graduates (%) 21.4 Ghana: unemployment 25–34, all (%) 4.4 Nigeria: unemployment 25–34, all (%) 5.9 Kenya: unemployment 25–34, all (%) 8.1 Egypt: unemployment 25–34, all (%) 8.5 Senegal: unemployment 25–34, all (%) 7.8 South Africa: unemployment 25–34, all (%) 37.3 Ghana: unemployment 15–24, graduates (%) 13.2 Nigeria: unemployment 15–24, graduates (%) 18.5 Kenya: unemployment 15–24, graduates (%) 22.8 Egypt: unemployment 15–24, graduates (%) 42.8 Senegal: unemployment 15–24, graduates (%) 29.8 South Africa: unemployment 15–24, graduates (%) 35.8 Ghana: unemployment 15–24, all (%) 5.9 Nigeria: unemployment 15–24, all (%) 7.0 Kenya: unemployment 15–24, all (%) 12.3 Egypt: unemployment 15–24, all (%) 15.3 Senegal: unemployment 15–24, all (%) 5.0 South Africa: unemployment 15–24, all (%) 53.2 Egypt: graduate women 25–34 unemployed (%) 31.0 Egypt: graduate men 25–34 unemployed (%) 10.6 Ghana: graduates' share of employed 25–34 (%) 14.1 Nigeria: graduates' share of employed 25–34 (%) 11.7 Kenya: graduates' share of employed 25–34 (%) 7.9 Egypt: graduates' share of employed 25–34 (%) 25.6 Senegal: graduates' share of employed 25–34 (%) 8.7 South Africa: graduates' share of employed 25–34 (%) 18.1 Ghana: graduates in office-based jobs (%) 56.7 Nigeria: graduates in office-based jobs (%) 37.1 Kenya: graduates in office-based jobs (%) 55.4 Egypt: graduates in office-based jobs (%) 69.1 South Africa: graduates in office-based jobs (%) 63.0 Ghana: all workers in office-based jobs (%) 8.2 Nigeria: all workers in office-based jobs (%) 8.0 Kenya: all workers in office-based jobs (%) 10.1 Egypt: all workers in office-based jobs (%) 23.4 South Africa: all workers in office-based jobs (%) 25.5 Ghana: graduates in clerical jobs (%) 6.8 Nigeria: graduates in clerical jobs (%) 4.6 Kenya: graduates in clerical jobs (%) 3.1 Egypt: graduates in clerical jobs (%) 8.8 South Africa: graduates in clerical jobs (%) 13.4 Ghana: graduates below graduate level (%) 40.1 Nigeria: graduates below graduate level (%) 63.0 Kenya: graduates below graduate level (%) 23.9 Egypt: graduates below graduate level (%) 31.9 South Africa: graduates below graduate level (%) 29.4 dtype: float64
7. Limitations¶
- Occupation by education covers all ages. ILOSTAT does not publish it by age, so section 4 describes all employed graduates, not only young ones.
- Major groups only. Exposure varies within groups; many graduate professionals are teachers and health workers, whose jobs are not exposed. The office-based share is an upper bound.
- Exposure is not job loss. The tiers measure what generative AI could do to tasks, not what it will do.
- Unemployment is a narrow measure. It leaves out graduates who have stopped looking for work and those in informal or part-time work below their skills.
- Survey differences. Years, surveys and classifications differ (Kenya's data are from 2021; South Africa uses ISCO-88), so cross-country comparisons are approximate.
- Small samples. Few people aged 15–24 have tertiary education, so rates for that group, and especially by sex, are less precise.