How exposed are young people's jobs in Africa to generative AI?¶
A short analysis of Ghana, Nigeria, Kenya, Egypt and South Africa
Emmanuel Edudzie, October 2026. Analysis code written with AI assistance (Claude).
The question. Are young workers (aged 15–24) more or less likely than older workers (25+) to hold jobs that generative AI could change, and which young workers are most exposed?
The method in one line. Combine (1) ILOSTAT survey data on how many young and older people work in each occupation group with (2) the ILO's 2025 index of how exposed each occupation is to generative AI.
How to read this notebook. Each code cell is preceded by a note saying what it does and why. Run the cells from top to bottom (in Jupyter: Run > Run All Cells).
Data sources¶
| File | What it is | Source |
|---|---|---|
EMP_TEMP_SEX_AGE_OCU_NB_A.csv |
Employment by sex, age and occupation (thousands), annual | ILOSTAT |
EMP_NIFL_SEX_AGE_OCU_NB_A.csv |
Informal employment by sex, age and occupation (thousands), annual | ILOSTAT |
WP140_web.pdf |
Gmyrek et al. (2025), Generative AI and Jobs: A Refined Global Index of Occupational Exposure, ILO Working Paper 140, Annex Table A1 | ILO |
The two ILOSTAT files were downloaded on 30 October 2026 from
rplumber.ilo.org/data/indicator/?id=<indicator>&ref_area=GHA+NGA+KEN+SEN+ZAF+EGY&timefrom=2015&type=label&format=.csv.
0. Set-up¶
We use three libraries:
- pandas for tables (a "DataFrame" is a spreadsheet-like table in Python),
- matplotlib for charts,
- re (regular expressions, built into Python) to read rows of text from the PDF.
Path builds file paths that work on any computer. ROOT is the project folder.
import re
import subprocess
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
ROOT = Path.cwd()
RAW = ROOT / "data" / "raw"
CLEAN = ROOT / "data" / "clean"
FIG = ROOT / "outputs" / "figures"
for folder in (CLEAN, FIG):
folder.mkdir(parents=True, exist_ok=True)
pd.set_option("display.width", 160)
pd.set_option("display.max_columns", 20)
# Chart colours (checked for colour-blind safety): one blue ramp for "more exposed",
# neutral greys for "minimal" and "not exposed", and blue/orange for two-way comparisons.
SURFACE, INK, INK_2, GRID = "#fcfcfb", "#0b0b0b", "#52514e", "#e4e3df"
BLUE_RAMP = {"Gradient 4": "#0d366b", "Gradient 3": "#1c5cab", "Gradient 2": "#3987e5", "Gradient 1": "#86b6ef"}
GREY_MIN, GREY_NONE = "#c3c2b7", "#e4e3df"
BLUE, ORANGE = "#2a78d6", "#eb6834"
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,
"axes.titleweight": "bold", "axes.titlesize": 12, "axes.titlelocation": "left",
})
1. The ILO's 2025 exposure index¶
The ILO scored almost 30,000 work tasks for how far generative AI could automate them, then averaged the task scores for each of 427 four-digit ISCO-08 occupations. Each occupation has a mean score (μ, 0–1) and a spread (σ). From these, the ILO places it in one of six categories (Working Paper 140, Table 5):
| Category | Rule | Meaning |
|---|---|---|
| Gradient 4 | μ ≥ 0.6 and μ − σ ≥ 0.5 | highest exposure, across most tasks |
| Gradient 3 | 0.5 ≤ μ < 0.6 and μ + σ ≥ 0.5 | significant exposure |
| Gradient 2 | 0.4 ≤ μ < 0.5 and μ + σ ≥ 0.5 | moderate exposure |
| Gradient 1 | μ < 0.4 and μ + σ ≥ 0.5 | low exposure, but some tasks highly exposed |
| Minimal exposure | μ < 0.5 and μ + σ > 0.4 | little exposure |
| Not exposed | none of the above |
The ILO stresses that exposure is potential, not a forecast of job losses: most exposed jobs are more likely to be transformed than eliminated.
1a. Read Annex Table A1 from the PDF¶
We first turn the PDF into plain text with the pdftotext tool (part of Poppler;
on a Mac: brew install poppler). Each row of the table then looks like:
Gradient 4 4132 Data Entry Clerks 0.7 0.03
The regular expression below captures the five parts of such a row. Two quirks of the PDF layout need handling: two-word categories ("Minimal Exposure", "Not Exposed") start on the line above, and long occupation names wrap onto the line above.
txt_file = RAW / "WP140.txt"
if not txt_file.exists():
subprocess.run(["pdftotext", "-layout", str(RAW / "WP140_web.pdf"), str(txt_file)], check=True)
lines = txt_file.read_text(encoding="utf-8").splitlines()
# The annex starts at the last occurrence of the table title (the first is in the contents list)
start = max(i for i, line in enumerate(lines) if "Table A1. ISCO-08 occupations by exposure gradient" in line)
row_pattern = re.compile(
r"^\s*(?P<cat>.*?)\s+(?P<code>\d{4})\s+(?P<name>.+?)\s+(?P<mean>\d?\.\d+|\d)\s+(?P<sd>\d?\.\d+|\d)\s*$"
)
records, cat_carry, name_carry = [], "", ""
for line in lines[start:]:
if line.strip().startswith("References"): # the table ends before the references
break
match = row_pattern.match(line)
if match:
records.append({
"category_raw": (cat_carry + " " + match["cat"]).strip(),
"isco4": match["code"],
"occupation": (name_carry + " " + match["name"].strip()).strip(),
"mu": float(match["mean"]),
"sigma": float(match["sd"]),
})
cat_carry, name_carry = "", ""
else:
words = line.split()
indent = len(line) - len(line.lstrip())
if words and words[0] in {"Minimal", "Not"}: # first word of a two-word category
cat_carry, name_carry = words[0], " ".join(words[1:])
elif words and indent >= 25 and not any(ch.isdigit() for ch in line):
name_carry = " ".join(words) # a long name wrapped onto the line above
elif words:
cat_carry, name_carry = "", "" # headers, footers and page numbers reset
index = pd.DataFrame(records)
def tidy_category(raw):
raw = " ".join(raw.split())
if raw.startswith("Gradient"):
return " ".join(raw.split()[:2])
return "Minimal Exposure" if "Minimal" in raw else "Not Exposed"
index["category"] = index["category_raw"].map(tidy_category)
index["isco1"] = index["isco4"].str[0] # the first digit is the major occupation group
index = index.drop(columns="category_raw")
index.to_csv(CLEAN / "ilo_genai_index_isco4.csv", index=False)
print(len(index), "occupations read")
index["category"].value_counts()
427 occupations read
category Not Exposed 231 Minimal Exposure 84 Gradient 2 44 Gradient 3 38 Gradient 1 17 Gradient 4 13 Name: count, dtype: int64
1b. Check the parsing¶
Two checks: (1) the paper says 112 occupations are exposed (Gradients 1–4), and (2) re-applying the Table 5 rules to μ and σ should reproduce the printed categories, apart from cases where μ and σ are rounded in the PDF.
CATEGORIES = ["Gradient 4", "Gradient 3", "Gradient 2", "Gradient 1", "Minimal Exposure", "Not Exposed"]
EXPOSED = CATEGORIES[:4]
HIGH = CATEGORIES[:2]
def table5_rule(mu, sd):
if mu >= 0.6 and mu - sd >= 0.5:
return "Gradient 4"
if 0.5 <= mu < 0.6 and mu + sd >= 0.5:
return "Gradient 3"
if 0.4 <= mu < 0.5 and mu + sd >= 0.5:
return "Gradient 2"
if mu < 0.4 and mu + sd >= 0.5:
return "Gradient 1"
if mu < 0.5 and mu + sd > 0.4:
return "Minimal Exposure"
return "Not Exposed"
n_exposed = index["category"].isin(EXPOSED).sum()
assert n_exposed == 112, n_exposed
index["rule_check"] = [table5_rule(m, s) for m, s in zip(index["mu"], index["sigma"])]
print("Exposed occupations:", n_exposed, "(the paper reports 112)")
print("Duplicate codes:", index["isco4"].duplicated().sum())
index.loc[index["category"] != index["rule_check"], ["isco4", "occupation", "mu", "sigma", "category", "rule_check"]]
Exposed occupations: 112 (the paper reports 112) Duplicate codes: 0
| isco4 | occupation | mu | sigma | category | rule_check | |
|---|---|---|---|---|---|---|
| 159 | 1323 | Construction Managers | 0.34 | 0.06 | Minimal Exposure | Not Exposed |
| 173 | 3154 | Air Traffic Controllers | 0.31 | 0.09 | Minimal Exposure | Not Exposed |
| 187 | 1311 | Agricultural and Forestry Production Managers | 0.27 | 0.13 | Minimal Exposure | Not Exposed |
| 193 | 5245 | Service Station Attendants | 0.24 | 0.16 | Minimal Exposure | Not Exposed |
The four mismatches all sit exactly on a rounding boundary (μ + σ = 0.40 as printed), so we keep the ILO's own categories.
1c. Exposure profile of each major occupation group¶
Survey data on young workers are only available by major group (the first digit of ISCO-08), so we summarise the index at that level: how many of each group's occupations fall in each category.
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",
}
profile = pd.crosstab(index["isco1"], index["category"])[CATEGORIES]
profile["Occupations"] = profile.sum(axis=1)
profile["% high (G3–4)"] = (profile[HIGH].sum(axis=1) / profile["Occupations"] * 100).round(0)
profile["% any (G1–4)"] = (profile[EXPOSED].sum(axis=1) / profile["Occupations"] * 100).round(0)
profile.index = [f"{g}. {GROUP_NAMES[g]}" for g in profile.index]
profile.to_csv(CLEAN / "exposure_profile_by_major_group.csv")
profile
| category | Gradient 4 | Gradient 3 | Gradient 2 | Gradient 1 | Minimal Exposure | Not Exposed | Occupations | % high (G3–4) | % any (G1–4) |
|---|---|---|---|---|---|---|---|---|---|
| 1. Managers | 0 | 0 | 5 | 1 | 20 | 4 | 30 | 0.0 | 20.0 |
| 2. Professionals | 2 | 18 | 16 | 3 | 26 | 27 | 92 | 22.0 | 42.0 |
| 3. Technicians and associate professionals | 2 | 8 | 13 | 5 | 20 | 34 | 82 | 12.0 | 34.0 |
| 4. Clerical support workers | 8 | 12 | 7 | 1 | 1 | 0 | 29 | 69.0 | 97.0 |
| 5. Service and sales workers | 1 | 0 | 3 | 3 | 8 | 24 | 39 | 3.0 | 18.0 |
| 6. Skilled agricultural, forestry and fishery workers | 0 | 0 | 0 | 0 | 0 | 18 | 18 | 0.0 | 0.0 |
| 7. Craft and related trades workers | 0 | 0 | 0 | 1 | 2 | 62 | 65 | 0.0 | 2.0 |
| 8. Plant and machine operators, and assemblers | 0 | 0 | 0 | 1 | 6 | 32 | 39 | 0.0 | 3.0 |
| 9. Elementary occupations | 0 | 0 | 0 | 2 | 1 | 30 | 33 | 0.0 | 6.0 |
What this shows. Exposure is concentrated in office work:
- Clerical support is the most exposed group: 20 of its 29 occupations are in the two highest gradients, and almost all are exposed to some degree.
- Professionals and technicians are mixed: finance, ICT, media and administrative roles are highly exposed, but teaching and health occupations are not.
- Service and sales has one highly exposed occupation (contact-centre sales), but the trade jobs that employ most young Africans sit in the lower gradients (below).
- Farming, crafts, machine operation and elementary work are almost entirely unexposed.
In fact, 50 of the 51 occupations in Gradients 3–4 are professionals, technicians or clerical workers.
high = index[index["category"].isin(HIGH)]
print("Occupations in Gradients 3–4:", len(high))
print(high["isco1"].map(GROUP_NAMES).value_counts().to_string())
# Common occupations for young workers in West and East Africa, and where they sit
examples = ["5211", "5212", "5221", "5223", "5230", "4110", "4120", "4226", "4311", "2330",
"2341", "2221", "7531", "7231", "6111", "9211", "8322", "9621"]
index.set_index("isco4").loc[examples, ["occupation", "mu", "category"]]
Occupations in Gradients 3–4: 51 isco1 Clerical support workers 20 Professionals 20 Technicians and associate professionals 10 Service and sales workers 1
| occupation | mu | category | |
|---|---|---|---|
| isco4 | |||
| 5211 | Stall and Market Salespersons | 0.35 | Gradient 1 |
| 5212 | Street Food Salespersons | 0.22 | Not Exposed |
| 5221 | Shopkeepers | 0.43 | Gradient 2 |
| 5223 | Shop Sales Assistants | 0.38 | Gradient 1 |
| 5230 | Cashiers and Ticket Clerks | 0.39 | Gradient 1 |
| 4110 | General Office Clerks | 0.60 | Gradient 4 |
| 4120 | Secretaries (general) | 0.58 | Gradient 3 |
| 4226 | Receptionists (general) | 0.57 | Gradient 3 |
| 4311 | Accounting and Bookkeeping Clerks | 0.64 | Gradient 4 |
| 2330 | Secondary Education Teachers | 0.30 | Not Exposed |
| 2341 | Primary School Teachers | 0.26 | Not Exposed |
| 2221 | Nursing Professionals | 0.25 | Not Exposed |
| 7531 | Tailors, Dressmakers, Furriers and Hatters | 0.15 | Not Exposed |
| 7231 | Motor Vehicle Mechanics and Repairers | 0.18 | Not Exposed |
| 6111 | Field Crop and Vegetable Growers | 0.18 | Not Exposed |
| 9211 | Crop Farm Labourers | 0.09 | Not Exposed |
| 8322 | Car, Taxi and Van Drivers | 0.28 | Gradient 1 |
| 9621 | Messengers, Package Deliverers and Luggage Por... | 0.37 | Gradient 1 |
Figure 1 shows the same profile as a chart.
fig1_data = (pd.crosstab(index["isco1"], index["category"], normalize="index") * 100)[CATEGORIES]
fig1_data = fig1_data.loc[fig1_data[EXPOSED].sum(axis=1).sort_values().index] # most exposed at the top
colours = [BLUE_RAMP[c] for c in EXPOSED] + [GREY_MIN, GREY_NONE]
fig, ax = plt.subplots(figsize=(9, 4.6))
left = pd.Series(0.0, index=fig1_data.index)
for cat, col in zip(CATEGORIES, colours):
ax.barh([GROUP_NAMES[g] for g in fig1_data.index], fig1_data[cat], left=left, color=col,
edgecolor=SURFACE, linewidth=1.5, height=0.62, label=cat.replace("Exposure", "exposure").replace("Exposed", "exposed"))
left += fig1_data[cat]
# A label column to the right of the bars: share of the group's occupations in Gradients 3-4
for i, g in enumerate(fig1_data.index):
share = fig1_data.loc[g, HIGH].sum()
ax.text(102, i, f"{share:.0f}%", va="center", ha="left", fontsize=9, color=INK,
fontweight="bold" if share >= 10 else "normal")
ax.set_xlim(0, 100)
ax.set_xlabel("Share of the group's occupations (%)")
ax.grid(axis="x", color=GRID, linewidth=0.8)
ax.set_axisbelow(True)
ax.set_title("Figure 1. Generative AI exposure is concentrated in office work")
ax.legend(ncol=3, frameon=False, loc="upper center", bbox_to_anchor=(0.42, -0.13), fontsize=8.5)
fig.text(0.01, -0.09, "Source: ILO Working Paper 140 (Gmyrek et al., 2025), Annex Table A1; 427 ISCO-08 occupations. "
"Shares count occupations, not workers.\nFigures on the right: share of the group's occupations in the two highest gradients (G3–4).", fontsize=7.5, color=INK_2)
fig.tight_layout()
fig.savefig(FIG / "fig1_exposure_profile.png", dpi=200, bbox_inches="tight")
plt.show()
2. Where young and older people work¶
2a. Load the ILOSTAT employment data¶
The file has one row per country, survey year, sex, age band and occupation group. We keep:
- the two age bands 15–24 ("youth") and 25+ ("adults"), which together cover everyone;
- the most recent survey year with a full occupation breakdown for each country.
Choices and why.
- Ghana 2025 (Labour Force Survey) rather than the 2024 household survey, where 18% of young workers' occupations are unclassified.
- Kenya 2021 (Continuous Household Survey), the latest available.
- South Africa 2025 reports the older ISCO-88 classification; its nine major groups line up closely with ISCO-08's, so we use them with that caveat.
- Senegal is excluded: 44% of young workers' and 70% of adults' occupations are "not elsewhere classified" in every recent year, too many for a reliable breakdown.
emp_raw = pd.read_csv(RAW / "EMP_TEMP_SEX_AGE_OCU_NB_A.csv")
inf_raw = pd.read_csv(RAW / "EMP_NIFL_SEX_AGE_OCU_NB_A.csv")
SELECTED = {"Ghana": 2025, "Nigeria": 2025, "Kenya": 2021, "Egypt": 2024, "South Africa": 2025}
AGES = {"Age (Youth, adults): 15-24": "Youth (15–24)", "Age (Youth, adults): 25+": "Adults (25+)"}
COUNTRIES = list(SELECTED)
def tidy_ilostat(raw, selected):
"""Keep the chosen country-years and age bands, and label each row with its major group."""
d = raw[raw["classif1.label"].isin(AGES)].copy()
d = d[[selected.get(c) == y for c, y in zip(d["ref_area.label"], d["time"])]]
d["scheme"] = d["classif2.label"].str.extract(r"\((ISCO-08|ISCO-88)\)")[0]
d = d.dropna(subset=["scheme"])
# Use ISCO-08 where a country reports it, otherwise ISCO-88 (South Africa)
preferred = d.groupby("ref_area.label")["scheme"].agg(lambda s: "ISCO-08" if "ISCO-08" in set(s) else "ISCO-88")
d = d[d["scheme"] == d["ref_area.label"].map(preferred)]
d["group"] = d["classif2.label"].str.split(": ").str[1].str.split(".").str[0]
d["age"] = d["classif1.label"].map(AGES)
d = d.rename(columns={"ref_area.label": "country", "sex.label": "sex", "obs_value": "thousands",
"obs_status.label": "status", "source.label": "source"})
return d[["country", "time", "source", "scheme", "sex", "age", "group", "thousands", "status"]]
emp = tidy_ilostat(emp_raw, SELECTED)
emp.groupby(["country", "time", "source", "scheme"]).size()
country time source scheme Egypt 2024 LFS - Labour Force Sample Survey ISCO-08 66 Ghana 2025 LFS - Labour Force Survey ISCO-08 63 Kenya 2021 HS - Continuous household survey ISCO-08 66 Nigeria 2025 LFS - Unemployment, Under-employment Watch ISCO-08 72 South Africa 2025 LFS - Quarterly Labour Force Survey ISCO-88 69 dtype: int64
2b. Data quality checks¶
- Unclassified ("X") and armed forces ("0") are left out of the denominators, so shares are of workers whose occupation is known. The table shows how much that removes.
- ILOSTAT flags some cells as "Unreliable" (small samples). We list those that touch our country-years so we can avoid leaning on them.
total = emp[emp["group"] == "Total"].set_index(["country", "sex", "age"])["thousands"]
unclassified = emp[emp["group"] == "X"].set_index(["country", "sex", "age"])["thousands"]
check = pd.DataFrame({"employed (000s)": total, "unclassified (000s)": unclassified}).fillna(0)
check["% unclassified"] = (check["unclassified (000s)"] / check["employed (000s)"] * 100).round(1)
display(check.xs("Total", level="sex"))
flagged = emp[emp["status"] == "Unreliable"]
flagged[["country", "sex", "age", "group", "thousands"]].sort_values(["country", "sex"])
| employed (000s) | unclassified (000s) | % unclassified | ||
|---|---|---|---|---|
| country | age | |||
| Egypt | Adults (25+) | 27872.873 | 2207.510 | 7.9 |
| Youth (15–24) | 4804.885 | 542.689 | 11.3 | |
| Ghana | Adults (25+) | 11686.378 | 0.000 | 0.0 |
| Youth (15–24) | 2252.754 | 0.000 | 0.0 | |
| Kenya | Adults (25+) | 16501.715 | 189.892 | 1.2 |
| Youth (15–24) | 3073.553 | 30.786 | 1.0 | |
| Nigeria | Adults (25+) | 70952.179 | 2182.767 | 3.1 |
| Youth (15–24) | 24455.974 | 531.572 | 2.2 | |
| South Africa | Adults (25+) | 16870.938 | 918.126 | 5.4 |
| Youth (15–24) | 1352.238 | 202.992 | 15.0 |
| country | sex | age | group | thousands | |
|---|---|---|---|---|---|
| 171 | Egypt | Female | Youth (15–24) | 1 | 2.477 |
| 5400 | Ghana | Female | Youth (15–24) | 8 | 5.019 |
| 5351 | Ghana | Male | Youth (15–24) | 1 | 5.890 |
| 9174 | Kenya | Female | Youth (15–24) | 8 | 6.842 |
| 9126 | Kenya | Male | Youth (15–24) | 4 | 7.458 |
| 9132 | Kenya | Male | Youth (15–24) | X | 4.583 |
| 10278 | Nigeria | Female | Youth (15–24) | 0 | NaN |
| 10290 | Nigeria | Female | Adults (25+) | 0 | NaN |
| 10221 | Nigeria | Male | Youth (15–24) | 1 | 22.766 |
| 10230 | Nigeria | Male | Youth (15–24) | 0 | NaN |
| 10182 | Nigeria | Total | Youth (15–24) | 0 | NaN |
| 15324 | South Africa | Female | Youth (15–24) | 6 | NaN |
| 15326 | South Africa | Female | Youth (15–24) | 8 | 4.085 |
| 15339 | South Africa | Female | Adults (25+) | 0 | 4.623 |
The flagged cells are small sub-groups (for example young male managers in Ghana); none is a headline figure below, but the gender split for clerical work in Kenya (young men) rests on one flagged cell.
2c. Shares of employment by occupation group¶
nine = emp[emp["group"].isin(list("123456789"))].copy()
nine["share"] = nine["thousands"] / nine.groupby(["country", "sex", "age"])["thousands"].transform("sum") * 100
nine.to_csv(CLEAN / "employment_shares_by_group.csv", index=False)
by_group = nine[nine["sex"] == "Total"].pivot_table(index="group", columns=["country", "age"], values="share").round(1)
by_group.index = [f"{g}. {GROUP_NAMES[g]}" for g in by_group.index]
by_group
| country | Egypt | Ghana | Kenya | Nigeria | South Africa | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| age | Adults (25+) | Youth (15–24) | Adults (25+) | Youth (15–24) | Adults (25+) | Youth (15–24) | Adults (25+) | Youth (15–24) | Adults (25+) | Youth (15–24) |
| 1. Managers | 4.2 | 0.6 | 2.4 | 0.5 | 9.7 | 4.1 | 1.1 | 0.2 | 9.2 | 2.7 |
| 2. Professionals | 13.1 | 5.2 | 5.6 | 3.2 | 3.9 | 1.3 | 5.9 | 2.1 | 5.8 | 2.3 |
| 3. Technicians and associate professionals | 7.1 | 4.4 | 1.6 | 0.8 | 6.2 | 2.9 | 2.7 | 0.8 | 9.2 | 7.8 |
| 4. Clerical support workers | 5.1 | 2.5 | 1.4 | 2.2 | 1.0 | 0.6 | 1.0 | 0.7 | 10.5 | 15.3 |
| 5. Service and sales workers | 18.0 | 19.9 | 26.2 | 21.4 | 7.8 | 10.3 | 33.1 | 29.0 | 16.7 | 25.4 |
| 6. Skilled agricultural, forestry and fishery workers | 16.9 | 18.6 | 38.5 | 33.1 | 36.1 | 38.4 | 30.0 | 35.0 | 0.5 | 0.7 |
| 7. Craft and related trades workers | 17.7 | 28.5 | 14.6 | 25.5 | 5.3 | 4.3 | 12.5 | 17.1 | 11.7 | 11.0 |
| 8. Plant and machine operators, and assemblers | 12.5 | 13.3 | 4.6 | 4.6 | 4.7 | 3.9 | 7.4 | 4.3 | 8.6 | 5.1 |
| 9. Elementary occupations | 5.5 | 6.8 | 5.3 | 8.6 | 25.3 | 34.2 | 6.4 | 10.8 | 27.9 | 29.8 |
2d. Exposure tiers¶
To keep the story readable, we place the nine groups in four tiers based on Section 1:
| Tier | Groups | Why |
|---|---|---|
| High | 4 Clerical support | most occupations in Gradients 3–4 |
| Mixed | 2 Professionals, 3 Technicians | finance, ICT, media and admin highly exposed; teaching and health not |
| Partial | 5 Service and sales | main trade jobs in Gradients 1–2: some tasks exposed |
| Low | 1, 6, 7, 8, 9 | mostly minimal or no exposure |
Managers (group 1) are mostly in "Minimal exposure", so they go in the low tier.
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", "age", "tier"])["share"].sum().unstack("tier")[TIER_ORDER]
tiers.round(2).to_csv(CLEAN / "employment_shares_by_tier.csv")
tiers.xs("Total", level="sex").round(1) # round only for display; keep full precision for sums
| tier | High: clerical support | Mixed: professionals and technicians | Partial: service and sales | Low: farming, crafts, operators, elementary, managers | |
|---|---|---|---|---|---|
| country | age | ||||
| Egypt | Adults (25+) | 5.1 | 20.2 | 18.0 | 56.8 |
| Youth (15–24) | 2.5 | 9.6 | 19.9 | 67.9 | |
| Ghana | Adults (25+) | 1.4 | 7.2 | 26.2 | 65.2 |
| Youth (15–24) | 2.2 | 4.0 | 21.4 | 72.4 | |
| Kenya | Adults (25+) | 1.0 | 10.1 | 7.8 | 81.1 |
| Youth (15–24) | 0.6 | 4.1 | 10.3 | 84.9 | |
| Nigeria | Adults (25+) | 1.0 | 8.6 | 33.1 | 57.4 |
| Youth (15–24) | 0.7 | 2.9 | 29.0 | 67.3 | |
| South Africa | Adults (25+) | 10.5 | 15.0 | 16.7 | 57.8 |
| Youth (15–24) | 15.3 | 10.1 | 25.4 | 49.2 |
Figure 2 compares young and older workers in each country.
TIER_COLOURS = ["#0d366b", "#3987e5", "#86b6ef", "#d9d8d3"]
fig2_data = tiers.xs("Total", level="sex").reindex(
[(c, a) for c in COUNTRIES for a in ["Youth (15–24)", "Adults (25+)"]])
labels = [f"{c} " + ("15–24" if a.startswith("Youth") else "25+") for c, a in fig2_data.index]
fig, ax = plt.subplots(figsize=(9, 5.4))
ypos = [i * 1.0 + (i // 2) * 0.45 for i in range(len(labels))] # small gap between countries
left = pd.Series(0.0, index=fig2_data.index)
for tier, col in zip(TIER_ORDER, TIER_COLOURS):
vals = fig2_data[tier]
ax.barh(ypos, vals, left=left, color=col, edgecolor=SURFACE, linewidth=1.5, height=0.8, label=tier)
for y, v, l in zip(ypos, vals, left):
if tier != TIER_ORDER[3] and v >= 4:
ax.text(l + v / 2, y, f"{v:.0f}", ha="center", va="center", fontsize=8,
color="white" if col != "#86b6ef" else INK)
left += vals
ax.set_yticks(ypos, labels)
ax.invert_yaxis()
ax.set_xlim(0, 100)
ax.set_xlabel("Share of employed people with a known occupation (%)")
ax.grid(axis="x", color=GRID, linewidth=0.8)
ax.set_axisbelow(True)
ax.set_title("Figure 2. Most young workers are in jobs with little exposure")
ax.legend(ncol=2, frameon=False, loc="upper center", bbox_to_anchor=(0.45, -0.1), fontsize=8.5)
fig.text(0.01, -0.07, "Source: ILOSTAT, employment by sex, age and occupation (Ghana 2025, Nigeria 2025, Kenya 2021, "
"Egypt 2024, South Africa 2025);\nexposure tiers from ILO Working Paper 140. South Africa uses ISCO-88 major groups.",
fontsize=7.5, color=INK_2)
fig.tight_layout()
fig.savefig(FIG / "fig2_tiers_youth_adults.png", dpi=200, bbox_inches="tight")
plt.show()
youth = tiers.xs("Youth (15–24)", level="age")
gender = youth.loc[(slice(None), ["Female", "Male"]), TIER_ORDER[:2]].copy()
gender["High + mixed"] = gender.sum(axis=1)
gender.round(1)
| tier | High: clerical support | Mixed: professionals and technicians | High + mixed | |
|---|---|---|---|---|
| country | sex | |||
| Egypt | Female | 9.6 | 34.2 | 43.8 |
| Male | 1.5 | 6.1 | 7.6 | |
| Ghana | Female | 3.2 | 4.9 | 8.0 |
| Male | 1.3 | 3.2 | 4.4 | |
| Kenya | Female | 0.8 | 4.8 | 5.6 |
| Male | 0.4 | 3.6 | 4.0 | |
| Nigeria | Female | 0.8 | 3.4 | 4.2 |
| Male | 0.6 | 2.5 | 3.1 | |
| South Africa | Female | 27.4 | 14.0 | 41.3 |
| Male | 7.2 | 7.4 | 14.6 |
clerical = youth[TIER_ORDER[0]].unstack("sex")[["Female", "Male"]].reindex(COUNTRIES)
fig, ax = plt.subplots(figsize=(8, 4.2))
x = range(len(COUNTRIES))
w = 0.36
b1 = ax.bar([i - w / 2 - 0.01 for i in x], clerical["Female"], width=w, color=BLUE, label="Young women (15–24)")
b2 = ax.bar([i + w / 2 + 0.01 for i in x], clerical["Male"], width=w, color=ORANGE, label="Young men (15–24)")
for bars in (b1, b2):
for bar in bars:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.4, f"{bar.get_height():.1f}",
ha="center", va="bottom", fontsize=8.5, color=INK)
ax.set_xticks(list(x), COUNTRIES)
ax.set_ylabel("Share in clerical support jobs (%)")
ax.grid(axis="y", color=GRID, linewidth=0.8)
ax.set_axisbelow(True)
ax.set_title("Figure 3. Young women are more likely than young men to hold the most exposed jobs")
ax.legend(frameon=False, loc="upper left")
fig.text(0.01, -0.04, "Source: ILOSTAT, employment by sex, age and occupation; latest year as in Figure 2. "
"Kenya's young-men figure is flagged by ILOSTAT as unreliable.", fontsize=7.5, color=INK_2)
fig.tight_layout()
fig.savefig(FIG / "fig3_clerical_by_sex.png", dpi=200, bbox_inches="tight")
plt.show()
3b. How many young people?¶
Shares can hide scale. Here are the numbers of young workers (thousands) in the high and mixed tiers.
youth_numbers = (nine[(nine["sex"] == "Total") & (nine["age"] == "Youth (15–24)")]
.groupby(["country", "tier"])["thousands"].sum().unstack("tier")[TIER_ORDER].round(0))
youth_numbers["All known occupations"] = youth_numbers.sum(axis=1)
youth_numbers.reindex(COUNTRIES)
| tier | High: clerical support | Mixed: professionals and technicians | Partial: service and sales | Low: farming, crafts, operators, elementary, managers | All known occupations |
|---|---|---|---|---|---|
| country | |||||
| Ghana | 50.0 | 91.0 | 481.0 | 1630.0 | 2252.0 |
| Nigeria | 169.0 | 696.0 | 6946.0 | 16111.0 | 23922.0 |
| Kenya | 18.0 | 126.0 | 315.0 | 2584.0 | 3043.0 |
| Egypt | 106.0 | 411.0 | 850.0 | 2896.0 | 4263.0 |
| South Africa | 176.0 | 116.0 | 292.0 | 566.0 | 1150.0 |
4. Are exposed young workers protected?¶
If AI changes a job, the impact depends partly on whether the worker has a formal contract and social protection. ILOSTAT's informal-employment file lets us compare the informality rate (informal employment ÷ total employment) by tier.
Kenya's informality data stop in 2019, so for this step Kenya uses 2019 for both files.
SELECTED_INF = {**SELECTED, "Kenya": 2019}
emp_i = tidy_ilostat(emp_raw, SELECTED_INF)
inf_i = tidy_ilostat(inf_raw, SELECTED_INF)
for d in (emp_i, inf_i):
d["tier"] = d["group"].map(TIERS).fillna(TIER_ORDER[3])
keys = ["country", "sex", "age", "tier"]
known = list("123456789")
E = emp_i[emp_i["group"].isin(known)].groupby(keys)["thousands"].sum()
I = inf_i[inf_i["group"].isin(known)].groupby(keys)["thousands"].sum()
informality = (I / E * 100).unstack("tier")[TIER_ORDER]
E_all = emp_i[emp_i["group"].isin(known)].groupby(keys[:3])["thousands"].sum()
I_all = inf_i[inf_i["group"].isin(known)].groupby(keys[:3])["thousands"].sum()
informality["All jobs"] = I_all / E_all * 100
informality = informality.round(0)
informality.to_csv(CLEAN / "informality_by_tier.csv")
informality.xs("Total", level="sex")
| tier | High: clerical support | Mixed: professionals and technicians | Partial: service and sales | Low: farming, crafts, operators, elementary, managers | All jobs | |
|---|---|---|---|---|---|---|
| country | age | |||||
| Egypt | Adults (25+) | 21.0 | 23.0 | 69.0 | 76.0 | 61.0 |
| Youth (15–24) | 72.0 | 65.0 | 93.0 | 95.0 | 91.0 | |
| Ghana | Adults (25+) | 56.0 | 34.0 | 92.0 | 94.0 | 89.0 |
| Youth (15–24) | 85.0 | 72.0 | 95.0 | 99.0 | 97.0 | |
| Kenya | Adults (25+) | 15.0 | 44.0 | 78.0 | 93.0 | 85.0 |
| Youth (15–24) | 36.0 | 69.0 | 84.0 | 97.0 | 93.0 | |
| Nigeria | Adults (25+) | 54.0 | 52.0 | 95.0 | 95.0 | 91.0 |
| Youth (15–24) | 93.0 | 93.0 | 99.0 | 99.0 | 99.0 | |
| South Africa | Adults (25+) | 15.0 | 13.0 | 37.0 | 49.0 | 38.0 |
| Youth (15–24) | 32.0 | 33.0 | 54.0 | 65.0 | 54.0 |
inf_clerical = informality.xs("Total", level="sex")[TIER_ORDER[0]].unstack("age").reindex(COUNTRIES)
fig, ax = plt.subplots(figsize=(8, 4.2))
b1 = ax.bar([i - w / 2 - 0.01 for i in x], inf_clerical["Youth (15–24)"], width=w, color=BLUE, label="Young clerical workers (15–24)")
b2 = ax.bar([i + w / 2 + 0.01 for i in x], inf_clerical["Adults (25+)"], width=w, color=ORANGE, label="Adult clerical workers (25+)")
for bars in (b1, b2):
for bar in bars:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 1, f"{bar.get_height():.0f}%",
ha="center", va="bottom", fontsize=8.5, color=INK)
ax.set_xticks(list(x), COUNTRIES)
ax.set_ylim(0, 105)
ax.set_ylabel("Share in informal employment (%)")
ax.grid(axis="y", color=GRID, linewidth=0.8)
ax.set_axisbelow(True)
ax.set_title("Figure 4. Young clerical workers are far more likely than older ones to be informal")
ax.legend(frameon=False, loc="upper center", bbox_to_anchor=(0.5, -0.1), ncol=2)
fig.text(0.01, -0.1, "Source: ILOSTAT, informal and total employment by sex, age and occupation "
"(Kenya 2019; other countries as in Figure 2).", fontsize=7.5, color=INK_2)
fig.tight_layout()
fig.savefig(FIG / "fig4_informality_clerical.png", dpi=200, bbox_inches="tight")
plt.show()
5. Robustness checks¶
(a) Ghana: does the survey matter? The 2024 household survey (AHIES) gives a different picture of youth occupations from the 2025 Labour Force Survey, partly because 18% of young workers' occupations are unclassified in 2024.
gh = tidy_ilostat(emp_raw, {"Ghana": 2024})
gh = gh[gh["group"].isin(list("123456789")) & (gh["sex"] == "Total")].copy()
gh["share"] = gh["thousands"] / gh.groupby("age")["thousands"].transform("sum") * 100
gh["tier"] = gh["group"].map(TIERS).fillna(TIER_ORDER[3])
compare = pd.concat({
"Ghana 2024 (AHIES)": gh.groupby(["age", "tier"])["share"].sum().unstack("tier")[TIER_ORDER],
"Ghana 2025 (LFS)": tiers.loc[("Ghana", "Total")][TIER_ORDER],
}).round(1)
compare
| tier | High: clerical support | Mixed: professionals and technicians | Partial: service and sales | Low: farming, crafts, operators, elementary, managers | |
|---|---|---|---|---|---|
| age | |||||
| Ghana 2024 (AHIES) | Adults (25+) | 1.5 | 7.9 | 28.4 | 62.2 |
| Youth (15–24) | 1.7 | 3.7 | 32.1 | 62.5 | |
| Ghana 2025 (LFS) | Adults (25+) | 1.4 | 7.2 | 26.2 | 65.2 |
| Youth (15–24) | 2.2 | 4.0 | 21.4 | 72.4 |
(b) An alternative single index. If exposure were spread evenly across the occupations within each major group, what share of each age group's jobs would fall in exposed occupations? This is crude (it ignores which occupations are most common), but it gives a single number per country to set beside the ILO's global figures: 11% of employment exposed (any gradient) in low-income countries and 34% in high-income countries.
group_any = (index.groupby("isco1")["category"].apply(lambda s: s.isin(EXPOSED).mean()))
group_high = (index.groupby("isco1")["category"].apply(lambda s: s.isin(HIGH).mean()))
tmp = nine[nine["sex"] == "Total"].copy()
tmp["any"] = tmp["share"] * tmp["group"].map(group_any)
tmp["high"] = tmp["share"] * tmp["group"].map(group_high)
even_spread = tmp.groupby(["country", "age"])[["any", "high"]].sum().round(1)
even_spread.columns = ["% exposed, any gradient", "% exposed, Gradients 3–4"]
even_spread.unstack("age").reindex(COUNTRIES)
| % exposed, any gradient | % exposed, Gradients 3–4 | |||
|---|---|---|---|---|
| age | Adults (25+) | Youth (15–24) | Adults (25+) | Youth (15–24) |
| country | ||||
| Ghana | 10.1 | 8.8 | 3.1 | 2.9 |
| Nigeria | 11.3 | 8.1 | 3.1 | 1.8 |
| Kenya | 9.8 | 7.0 | 2.5 | 1.3 |
| Egypt | 17.8 | 11.0 | 7.7 | 3.9 |
| South Africa | 22.6 | 25.6 | 10.0 | 12.7 |
6. Summary numbers for the brief¶
t = tiers.xs("Total", level="sex")
summary = pd.DataFrame({
"Youth: high (clerical) %": t.xs("Youth (15–24)", level="age")[TIER_ORDER[0]],
"Adults: high (clerical) %": t.xs("Adults (25+)", level="age")[TIER_ORDER[0]],
"Youth: high + mixed %": t.xs("Youth (15–24)", level="age")[TIER_ORDER[:2]].sum(axis=1),
"Adults: high + mixed %": t.xs("Adults (25+)", level="age")[TIER_ORDER[:2]].sum(axis=1),
"Youth: low tier %": t.xs("Youth (15–24)", level="age")[TIER_ORDER[3]],
"Young women: clerical %": youth.xs("Female", level="sex")[TIER_ORDER[0]],
"Young men: clerical %": youth.xs("Male", level="sex")[TIER_ORDER[0]],
"Young women: service & sales %": youth.xs("Female", level="sex")[TIER_ORDER[2]],
"Young clerical workers informal %": inf_clerical["Youth (15–24)"],
"Adult clerical workers informal %": inf_clerical["Adults (25+)"],
}).reindex(COUNTRIES).round(1)
summary.to_csv(ROOT / "outputs" / "summary_numbers.csv")
summary.T
| country | Ghana | Nigeria | Kenya | Egypt | South Africa |
|---|---|---|---|---|---|
| Youth: high (clerical) % | 2.2 | 0.7 | 0.6 | 2.5 | 15.3 |
| Adults: high (clerical) % | 1.4 | 1.0 | 1.0 | 5.1 | 10.5 |
| Youth: high + mixed % | 6.3 | 3.6 | 4.7 | 12.1 | 25.4 |
| Adults: high + mixed % | 8.6 | 9.6 | 11.1 | 25.2 | 25.5 |
| Youth: low tier % | 72.4 | 67.3 | 84.9 | 67.9 | 49.2 |
| Young women: clerical % | 3.2 | 0.8 | 0.8 | 9.6 | 27.4 |
| Young men: clerical % | 1.3 | 0.6 | 0.4 | 1.5 | 7.2 |
| Young women: service & sales % | 33.3 | 44.1 | 16.0 | 22.7 | 30.7 |
| Young clerical workers informal % | 85.0 | 93.0 | 36.0 | 72.0 | 32.0 |
| Adult clerical workers informal % | 56.0 | 54.0 | 15.0 | 21.0 | 15.0 |
7. Limitations¶
- Major groups only. Survey data for young workers are published by major occupation group, while exposure varies a lot within groups (a young accountant and a young primary teacher are both "professionals"). The tiers are therefore indicative.
- Exposure is not impact. The ILO index measures what generative AI could do to tasks. Actual effects depend on adoption, which is held back by electricity, broadband, cost and skills, all of which are tighter in these countries.
- Global task scores. The index scores tasks as performed in general; the same job title may involve different tasks in Accra, Lagos or Johannesburg.
- Survey differences. Years, surveys and classifications differ between countries (South Africa uses ISCO-88; Kenya's latest is 2021), so cross-country comparisons are approximate. Senegal was excluded because most occupations are unclassified.
- Employment only. The analysis covers young people in work, not the unemployed or those not in employment, education or training (NEET).