Each focal country’s recent standing against the common target profile — how far, and in which direction. The country layer of the target-profile work.

Author

Net Zero Industrial Policy Lab (NZIPL) · Johns Hopkins SAIS

Published

July 7, 2026

Code
library(readr); library(dplyr); library(tidyr); library(stringr); library(forcats)
library(ggplot2); library(patchwork); library(knitr); library(showtext); library(tibble)
font_add_google("Archivo", "Archivo"); showtext_auto(); showtext_opts(dpi = 200)

NZ_DARK <- "#073309"; NZ_GREEN <- "#3cb54a"; NZ_TEAL <- "#0d9488"
theme_nr <- function(...) theme_minimal(base_family = "Archivo", base_size = 12) +
  theme(plot.title = element_text(size = 14, face = "bold", colour = NZ_DARK),
        plot.subtitle = element_text(size = 11, colour = "#475569"),
        panel.grid.minor = element_blank(), ...)
theme_set(theme_nr())
CAT_COL <- c(Chemicals="#2563eb", Electronics="#7c3aed", Metals="#ef4444",
             Machinery="#10b981", "Industrial Materials"="#f59e0b")
CATS <- c("Chemicals","Electronics","Industrial Materials","Machinery","Metals")
CATS_SHORT <- c(Chemicals="Chem", Electronics="Elec", "Industrial Materials"="Ind.M",
                Machinery="Mach", Metals="Met")
TECHS <- c("Solar","Wind","Batteries","EVs","Nuclear","Geothermal",
           "Heat Pumps","Electrolyzers","Transmission","Magnets","Biofuel")
FOCAL <- c("Brazil","India","Mexico","Japan","Vietnam","Spain")
# tech colour palette (matches the Atlas) — used to paint each tech's own "need" profile
TECH_COLORS <- c(Solar="#eab308", Nuclear="#0ea5e9", Batteries="#f59e0b", Transmission="#06b6d4",
                 Wind="#3b82f6", Biofuel="#84cc16", Electrolyzers="#a855f7", Geothermal="#ef4444",
                 "Heat Pumps"="#f97316", Magnets="#ec4899", EVs="#14b8a6")
CGREEN <- "#22c55e"   # one shiny green for "what the country actually holds"

# tidytext::reorder_within shim (avoid the dependency)
reorder_within <- function(x, by, within, sep="___") stats::reorder(paste(x, within, sep=sep), by)

gap <- read_csv(here::here("analysis","ml","shap_gap_by_country_cluster.csv"), show_col_types = FALSE) |>
  mutate(tech = factor(tech, TECHS), country = factor(country, FOCAL),
         cluster = factor(cluster, CATS),
         # % of target reached, capped. coalesce: when a tech has NO retained features in a
         # cluster its target weight is 0, so achieved is 0/0=NaN — treat that axis as 0 so the
         # pentagon closes cleanly instead of breaking into a sliver.
         ach = pmin(pmax(coalesce(achieved, 0), 0), 1))
front <- gap |> group_by(country, tech) |>
  summarise(frontier = sum(pmax(country_signed, 0)) / sum(target_weight), .groups = "drop")

Where this sits. target_profile.qmd established the target layer — a defensible common per-tech capability profile (the winners’ recipe). This is the gap layer: for each of the six focal countries, its own recent signed SHAP (option A: mean over the last 3 available years) read against that target, per capability cluster. The target is a shared shape, but countries differ enormously in level and direction — which is exactly what the gap surfaces. Built by scripts/ml/target_profile_prep.py from analysis/ml/raw_signed/; no model re-fit.

1 How to read the gap

The whole point of this note is to make one question concrete: if this country wanted to become competitive in this technology, what would it build? The target-profile note gave us the destination — a common per-tech “need” profile, the winners’ recipe across the five capability clusters. Here we lay each country’s own recent capability (its mean signed SHAP over the last three years, per cluster) against that destination. Everything below is two numbers per country × technology × cluster — what the technology needs and what the country currently has — shown two complementary ways.

The building block: an achievement fraction. For each cluster we form

\[\text{achieved} \;=\; \frac{\text{country's recent signed SHAP in the cluster}}{\text{target (winners') weight in the cluster}}\]

read as the share of the frontier the country has reached there. A value of 1.0 means “at the frontier” (the country already has the competitive producers’ level of that capability); below 1.0 is a gap — how far there is to build; above 1.0 is a surplus — capability beyond what the target needs, a candidate to leverage or redeploy.

Two radar views, because they answer different questions. We draw the same data both ways on purpose:

  • The relative radar normalises every cluster by its own target, so the outer pentagon is always the frontier (100%) and the green fill is the fraction reached. This view puts every technology on the same 0–100% footing, which is what you want when ranking where a country is close versus distant, and when reading build priorities off the short axes. Its cost: it hides how big the underlying need is — a near-full pentagon on a tiny-need technology and on a huge-need one look alike.
  • The absolute radar keeps the raw magnitudes. It overlays two shapes on a shared per-panel scale: the technology’s need profile, drawn in that technology’s own colour, and what the country actually holds, in green. This view restores the sense of scale the relative view drops — you see the shape of the need and how much of it the country fills — and it reads especially well for technologies where the country is far behind (a large coloured need with only a sliver of green inside).

Together they make the argument the audit set up: because the target is a shared shape (countries rank-agree at ρ≈0.89), what distinguishes countries is level and direction — how full the radar is, and which axes are short — precisely the magnitude and country-specific signal the shipped count-driven radar threw away.

2 Distance to the frontier — the whole board

Before the cluster-by-cluster detail, it helps to see the whole map at once. Collapsing all five clusters into a single number per country × technology — the total share of the target the country has reached — gives an executive summary: which technologies are within a country’s reach, and which are distant. This is the “where to look” view; the radars that follow are the “why.”

Code
hd <- front |> mutate(country = factor(country, rev(FOCAL)))
ggplot(hd, aes(tech, country, fill = pmin(frontier, 1.2))) +
  geom_tile(colour = "white", linewidth = .6) +
  geom_text(aes(label = sprintf("%.2f", frontier)), family = "Archivo", size = 2.6,
            colour = ifelse(hd$frontier > .6, "white", "#0b1a12")) +
  scale_fill_gradientn(colours = c("#f7f9f7", "#bfe6c4", NZ_GREEN, NZ_DARK),
                       values = c(0, .4, .8, 1), name = "share of\ntarget reached",
                       limits = c(0, 1.2)) +
  labs(x = NULL, y = NULL, title = "Distance to the frontier — focal country × technology",
       subtitle = "Overall share of the target capability each country has reached (recent 3 yrs). 1.0 = at the winners' level; >1 shaded darkest.") +
  theme(axis.text.x = element_text(angle = 25, hjust = 1), legend.position = "right")

Code
ggplot(front, aes(x = reorder(interaction(country, tech), frontier), y = pmin(frontier,1.3), fill = country)) +
  geom_col(width=.7, show.legend = FALSE) + coord_flip() +
  facet_wrap(~country, scales = "free_y", ncol = 3) +
  geom_hline(yintercept = 1, linetype = "22", colour = "#334155") +
  scale_x_discrete(labels = function(x) sub("\\..*","", sub("^[^.]*\\.","",x))) +
  labs(x = NULL, y = "share of target reached (capped 1.3; dashed = frontier)",
       title = "Each country's technologies, nearest-to-frontier first",
       subtitle = "Where a country is already competitive (≥1) vs where the build is largest (→0).") +
  theme(strip.text = element_text(face="bold", colour=NZ_DARK), axis.text.y = element_text(size=7))

Reading the board. In the heatmap, rows are countries and columns technologies; each cell is that country’s overall share of the technology’s target — dark green means at or beyond the frontier, pale means a long way to go. The companion panel simply re-sorts the same numbers within each country, so a country’s technologies line up from “already competitive” (bars past the dashed line) to “largest build ahead” (bars near zero). Read together, they turn a country’s industrial-policy question into a short list: the bars nearest the frontier are where an existing base can be defended and extended; the ones near zero are green-field bets. The radars in the next two sections open up any one of these cells to show which capabilities drive the number.

3 Gap radars — relative view (share of the frontier reached)

This is the “how close, and where” view. In every panel the dashed pentagon is the target frontier (100% on all five clusters), and the green fill is the fraction the country has reached on each — so a full pentagon means “already at the winners’ level,” and a short spoke is a build priority. Because every technology is normalised to its own frontier, panels are directly comparable across technologies: a nearly full Solar and a nearly empty Wind tell you, at a glance, where a country’s base is strong and where it starts from scratch. The trade-off, addressed by the absolute view that follows, is that this normalisation hides how large each technology’s underlying need is.

Code
ang <- tibble(cluster = factor(CATS, CATS), a = pi/2 - 2*pi*(0:4)/5)

# close a per-tech polygon back to its first vertex so the pentagon draws shut
close_poly <- function(d, grp) bind_rows(d, d |> filter(cluster == CATS[1]) |> mutate(o = 6L)) |>
  arrange(across(all_of(grp)), o)

# shared pentagon grid (rings + spokes + axis labels) for a set of techs
grid_layers <- function(techs) {
  rings <- expand_grid(tech = factor(techs, TECHS), ring = c(.33,.66,1), cluster = factor(CATS,CATS)) |>
    left_join(ang, by="cluster") |> mutate(x = ring*cos(a), y = ring*sin(a), o = as.integer(cluster))
  list(ringsc = bind_rows(rings, rings |> filter(cluster==CATS[1]) |> mutate(o=6L)) |> arrange(tech, ring, o),
       spokes = expand_grid(tech = factor(techs, TECHS), cluster = factor(CATS,CATS)) |>
         left_join(ang, by="cluster") |> mutate(x = cos(a), y = sin(a)),
       labs   = expand_grid(tech = factor(techs, TECHS), cluster = factor(CATS,CATS)) |>
         left_join(ang, by="cluster") |> mutate(x = 1.28*cos(a), y = 1.28*sin(a),
                                                 lab = CATS_SHORT[as.character(cluster)]))
}
base_grid <- function(g)
  list(geom_path(data = g$ringsc, aes(x,y,group=interaction(tech,ring)), colour="grey88", linewidth=.3),
       geom_segment(data = g$spokes, aes(0,0,xend=x,yend=y), colour="grey88", linewidth=.3),
       geom_text(data = g$labs, aes(x,y,label=lab), family="Archivo", size=2.1, colour="#475569"))
radar_theme <- function(ttl)
  list(facet_wrap(~tech, ncol=4), coord_equal(clip="off"), xlim(-1.5,1.5), ylim(-1.4,1.4),
       labs(title = ttl), theme_void(base_family="Archivo"),
       theme(strip.text = element_text(face="bold", colour=NZ_DARK, size=10),
             plot.title = element_text(face="bold", colour=NZ_DARK, size=12.5), legend.position="none"))

# RELATIVE view — outer pentagon = frontier (100%); green fill = share of it reached
rel_radar <- function(cty) {
  d <- gap |> filter(country == cty) |> left_join(ang, by = "cluster") |>
    mutate(x = ach*cos(a), y = ach*sin(a), o = as.integer(cluster))
  poly <- close_poly(d, "tech")
  g <- grid_layers(levels(droplevels(d$tech))); frame <- g$ringsc |> filter(ring == 1)
  ggplot() + base_grid(g) +
    geom_polygon(data = frame, aes(x,y,group=tech), fill=NA, colour="#94a3b8", linewidth=.7, linetype="22") +
    geom_polygon(data = poly, aes(x,y,group=tech), fill=NZ_GREEN, colour=NZ_TEAL, alpha=.42, linewidth=.7) +
    radar_theme(paste0(cty, " — RELATIVE: share of the frontier reached (dashed pentagon = 100% target)"))
}

# ABSOLUTE view — the need in each tech's own colour vs what the country holds in green,
# both on a shared per-panel scale so the shapes (and any surplus) are directly comparable
abs_radar <- function(cty) {
  d <- gap |> filter(country == cty) |>
    transmute(tech, cluster, need = target_weight, hold = pmax(country_signed, 0)) |>
    pivot_longer(c(need, hold), names_to = "series", values_to = "val") |>
    group_by(tech) |> mutate(r = coalesce(val / max(val, na.rm=TRUE), 0)) |> ungroup() |>
    left_join(ang, by = "cluster") |>
    mutate(x = r*cos(a), y = r*sin(a), o = as.integer(cluster),
           fillc = ifelse(series == "need", TECH_COLORS[as.character(tech)], CGREEN))
  polyN <- close_poly(d |> filter(series == "need"), "tech")
  polyH <- close_poly(d |> filter(series == "hold"), "tech")
  g <- grid_layers(levels(droplevels(factor(d$tech, TECHS))))
  ggplot() + base_grid(g) +
    geom_polygon(data = polyN, aes(x,y,group=tech, fill=fillc, colour=fillc), alpha=.16, linewidth=.9) +
    geom_polygon(data = polyH, aes(x,y,group=tech), fill=CGREEN, colour="#15803d", alpha=.5, linewidth=.6) +
    scale_fill_identity() + scale_colour_identity() +
    radar_theme(paste0(cty, " — ABSOLUTE: the need (tech colour) vs what the country holds (green)"))
}
Code
rel_radar("Brazil")

Code
rel_radar("India")

Code
rel_radar("Mexico")

Code
rel_radar("Japan")

Code
rel_radar("Vietnam")

Code
rel_radar("Spain")

4 Gap radars — absolute view (the need vs what the country holds)

The relative view above deliberately erased scale so technologies could be compared; this view puts it back. In each panel the coloured pentagon is the technology’s need profile — the winners’ recipe, drawn in that technology’s own Atlas colour — and the green shape is what the country actually holds (its recent positive signed SHAP per cluster), both on a shared per-panel scale. Now the reading is literal: the coloured boundary is the target shape, and the green shows how much of it is filled. Where green falls short of the colour, that is the build; where green pokes beyond it, a surplus. This view earns its keep exactly where the relative one struggles — a technology a country has barely entered shows as a full coloured need with only a sliver of green inside, which is far more legible than a near-empty pentagon. Read the two views as a pair: the relative one ranks how close, the absolute one shows the shape of what is missing.

Code
abs_radar("Brazil")

Code
abs_radar("India")

Code
abs_radar("Mexico")

Code
abs_radar("Japan")

Code
abs_radar("Vietnam")

Code
abs_radar("Spain")

5 Build priorities — where the deficit is

Positive = deficit (build); negative = surplus (leverage / rotate). Sorted by the largest single-cluster deficit per country.

Code
pb <- gap |> mutate(country = factor(country, FOCAL)) |>
  group_by(country) |> slice_max(order_by = gap, n = 8, with_ties = FALSE) |> ungroup() |>
  mutate(lab = paste0(tech, " · ", CATS_SHORT[as.character(cluster)]))
ggplot(pb, aes(x = reorder_within(lab, gap, country), y = gap, fill = cluster)) +
  geom_col(width=.72) + coord_flip() +
  facet_wrap(~country, scales="free_y", ncol=2) +
  scale_fill_manual(values = CAT_COL, name = NULL) +
  scale_x_discrete(labels = function(x) sub("___.*","",x)) +
  labs(x=NULL, y="target − country  (deficit to build)",
       title="Top build priorities per focal country",
       subtitle="The largest cluster-level gaps to the winners' recipe (recent 3 yrs).") +
  theme(legend.position="bottom", strip.text=element_text(face="bold",colour=NZ_DARK),
        axis.text.y=element_text(size=7.5))

Reading the bars. The radars show a country’s whole position at a glance; this chart distils it into an ordered to-do list. Each bar is one technology × cluster, its length the raw deficit target − country — how much capability separates the country from the winners’ recipe in that specific cluster — and the eight longest per country are kept. A tall bar is where the single biggest, most concrete build lies (e.g. “Solar · Electronics” for a machinery-heavy economy); a bar that dips below zero is a surplus the country could lean on. This is the step from diagnosis to agenda: the frontier board says which technologies to prioritise, the radars say why, and these bars name the specific capability clusters a policy would target first.

6 Reading the gaps — three worked cases

The cluster tables below zoom all the way in on three country × technology pairs, so the numbers behind the shapes are visible. Read each as: the Target column is the winners’ recipe (what the technology needs), Country (recent) is what the country holds, and Achieved is their ratio — the same achievement fraction the radars draw, now as a figure you can quote.

Code
show_case <- function(cty, tch) gap |> filter(country==cty, tech==tch) |>
  transmute(Cluster=cluster, `Country (recent)`=round(country_signed,4), `Target`=round(target_weight,4),
            `Achieved`=sprintf("%.0f%%", pmin(achieved,9)*100),
            Read = case_when(achieved>=1.2~"surplus — leverage", achieved>=.8~"at frontier",
                             achieved>=.4~"partial — build", TRUE~"deep gap — priority")) |>
  arrange(desc(`Target`))
kable(show_case("India","Solar"), caption="India · Solar — machinery-heavy economy, but the Solar target is Electronics-led: Electronics is the deep gap, Machinery a surplus.")
India · Solar — machinery-heavy economy, but the Solar target is Electronics-led: Electronics is the deep gap, Machinery a surplus.
Cluster Country (recent) Target Achieved Read
Electronics 0.0357 0.2108 17% deep gap — priority
Chemicals 0.0647 0.0796 81% at frontier
Metals 0.0290 0.0288 101% at frontier
Machinery 0.0544 0.0210 259% surplus — leverage
Industrial Materials 0.0001 0.0019 4% deep gap — priority
Code
kable(show_case("Brazil","Biofuel"), caption="Brazil · Biofuel.")
Brazil · Biofuel.
Cluster Country (recent) Target Achieved Read
Chemicals 0.0480 0.0908 53% partial — build
Machinery 0.0473 0.0679 70% partial — build
Industrial Materials 0.0407 0.0593 69% partial — build
Metals 0.0000 0.0051 0% deep gap — priority
Electronics 0.0000 0.0000 NA% deep gap — priority
Code
kable(show_case("Vietnam","Batteries"), caption="Vietnam · Batteries.")
Vietnam · Batteries.
Cluster Country (recent) Target Achieved Read
Chemicals -0.0056 0.1602 0% deep gap — priority
Machinery 0.0062 0.1175 5% deep gap — priority
Electronics 0.0182 0.0256 71% partial — build
Metals 0.0028 0.0125 23% deep gap — priority
Industrial Materials 0.0000 0.0000 NA% deep gap — priority

How to use this. For any focal country, the radar shows how competitive its capability base already is per technology (fullness) and where the model says the missing pieces are (short axes) — read against a target that is the same for everyone, so the comparison is apples-to-apples. The build-priority bars turn that into a ranked list; the frontier board shows which technologies are within reach vs distant. Unlike the shipped count-driven radar, this is magnitude-preserving, signed, and country-specific — the three things the audit flagged as missing, now supplied by the raw SHAP.

7 Reproducibility

scripts/ml/target_profile_prep.py writes analysis/ml/shap_gap_by_country_cluster.csv (per country × tech × cluster: country recent-3yr signed SHAP, target weight, achieved fraction, gap) from analysis/ml/raw_signed/ and the winners’-recipe target. Country value uses the last 3 available years (option A). No model re-fit.