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.

In [1]:
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.

In [2]:
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
Out[2]:
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.

In [3]:
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
Out[3]:
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.

In [4]:
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
Out[4]:
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.

In [5]:
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
Out[5]:
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.

In [6]:
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()
No description has been provided for this image

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.
In [7]:
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()
Out[7]:
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.
In [8]:
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
Out[8]:
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¶

In [9]:
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
Out[9]:
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.

In [10]:
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
Out[10]:
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.

In [11]:
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()
No description has been provided for this image

3. Which young people are most exposed?¶

3a. Young women and young men¶

The ILO finds that women's jobs are more exposed than men's worldwide. Is the same true for young workers in these five countries?

In [12]:
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)
Out[12]:
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
In [13]:
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()
No description has been provided for this image

3b. How many young people?¶

Shares can hide scale. Here are the numbers of young workers (thousands) in the high and mixed tiers.

In [14]:
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)
Out[14]:
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.

In [15]:
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")
Out[15]:
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
In [16]:
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()
No description has been provided for this image

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.

In [17]:
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
Out[17]:
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.

In [18]:
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)
Out[18]:
% 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¶

In [19]:
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
Out[19]:
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¶

  1. 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.
  2. 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.
  3. Global task scores. The index scores tasks as performed in general; the same job title may involve different tasks in Accra, Lagos or Johannesburg.
  4. 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.
  5. Employment only. The analysis covers young people in work, not the unemployed or those not in employment, education or training (NEET).