Two Definitions of Need

The legacy SHAP axis against the winners’-recipe target, across 10 technologies × 5 capability clusters

Author

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

Published

August 5, 2026

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

NZ_DARK <- "#073309"
CATS  <- c("Chemicals","Electronics","Industrial Materials","Machinery","Metals")
SHORT <- c(Chemicals="Chem", Electronics="Elec", "Industrial Materials"="Ind.M",
           Machinery="Mach", Metals="Met")
TECHS <- c("Solar","Wind","Batteries","Nuclear","Geothermal","Heat Pumps",
           "Electrolyzers","Transmission","Magnets","Biofuel")

# Two-series categorical palette. Validated with the dataviz six-checks script:
# all pass (CVD ΔE 28.7 deutan, normal-vision 33.9, no contrast warning). Indigo is
# used for the legacy axis rather than a blue, to avoid reading across to the
# Chemicals category colour (#2563eb) that appears on the x axis.
MODEL_COL <- c(`Legacy SHAP axis` = "#6366f1", `Winners' recipe` = "#16a34a")

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 = 10.5, colour = "#475569"),
        plot.title.position = "plot",
        panel.grid.minor   = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.grid.major.y = element_line(colour = "grey92", linewidth = .3),
        axis.ticks = element_blank(), ...)
theme_set(theme_nr())

The question. The project has two definitions of what a technology “needs”, built from different quantities, and both are still in the repo. This document puts them side by side for all 10 technologies and measures the gap.

Bottom line. They disagree by a median of 15.1 percentage points of the need profile and name a different dominant capability for 5 of 10 technologies. Solar is the widest — the legacy axis calls it a Chemicals problem, the winners’ recipe calls it Electronics.

1 What is being compared

Both definitions produce the same object: a five-number profile per technology, one number per capability cluster, normalised to sum to 100. They differ in what they average and over whom.

Legacy SHAP axis Winners’ recipe
Quantity z-scored mean absolute SHAP mean signed SHAP, positive part
Population all country-years in the panel only country-years with RCA > 1
Reads as “how much this code moves the model, anywhere” “what the countries that actually won have”
Ships in shap_category shares, PC scatter capability_gap.qmd’s target_weight
Source shap_replication_by_category.csv shap_replication_by_class.csv

The winners’ recipe replaced the legacy axis in July as the definition of need. The replacement was deliberate and documented; what was never measured is how far the two actually sit apart.

Data. Both come from the same from-source replication run (scripts/ml/regen_shap.py), so the comparison is like for like. data/pc/pc_features.csv is not used: it disagrees with that replication at r = 0.76 over what should be the identical quantity, with 45 rows having no counterpart at all. That staleness is a separate finding — see the register.

Code
# Winners' recipe — reproduces the shipped `target_weight` exactly (checked below)
win <- read_csv(here::here("analysis/ml/shap_replication_by_class.csv"), show_col_types = FALSE) |>
  filter(retained, category %in% CATS, tech %in% TECHS) |>
  group_by(tech, category) |>
  summarise(w = sum(pmax(mean_shap_comp, 0)), .groups = "drop") |>
  complete(tech, category = CATS, fill = list(w = 0)) |>
  group_by(tech) |> mutate(`Winners' recipe` = w/sum(w)*100) |> ungroup()

# Legacy z axis — as shipped
leg <- read_csv(here::here("analysis/ml/shap_replication_by_category.csv"), show_col_types = FALSE) |>
  filter(tech %in% TECHS) |> select(tech, category, `Legacy SHAP axis` = pct_shipped_sum_z)

need <- win |> select(tech, category, `Winners' recipe`) |>
  left_join(leg, by = c("tech","category")) |>
  mutate(`Legacy SHAP axis` = coalesce(`Legacy SHAP axis`, 0),
         tech = factor(tech, TECHS), category = factor(category, CATS))

# reconstruction check: does the winners' branch equal the shipped target_weight?
recon <- read_csv(here::here("analysis/ml/shap_gap_by_country_cluster.csv"), show_col_types = FALSE) |>
  distinct(tech, cluster, target_weight) |>
  inner_join(win |> transmute(tech, cluster = as.character(category), w), by = c("tech","cluster"))

The winners’ branch above reproduces the shipped target_weight for all 50 technology × cluster pairs to 2.2e-16 — floating-point identity, so this is the shipped target rather than a re-specification of it.

2 The two profiles, technology by technology

Code
lng <- need |>
  pivot_longer(c(`Legacy SHAP axis`, `Winners' recipe`),
               names_to = "model", values_to = "pct") |>
  mutate(model = factor(model, names(MODEL_COL)),
         cshort = factor(SHORT[as.character(category)], unname(SHORT[CATS])))

ggplot(lng, aes(cshort, pct, fill = model)) +
  geom_col(position = position_dodge(width = .74), width = .62) +
  scale_fill_manual(values = MODEL_COL, name = NULL) +
  scale_y_continuous(expand = expansion(mult = c(0, .08)), labels = \(x) paste0(x, "%")) +
  facet_wrap(~tech, ncol = 5) +
  labs(title = "Where the two definitions of need disagree",
       subtitle = "Share of each technology's need profile, by capability cluster · bars within one colour sum to 100%",
       x = NULL, y = NULL) +
  theme(legend.position = "top",
        legend.justification = "left",
        legend.margin = margin(b = 4),
        strip.text = element_text(face = "bold", colour = NZ_DARK, size = 10),
        axis.text.x = element_text(size = 8, colour = "#475569"),
        axis.text.y = element_text(size = 8, colour = "#64748b"),
        panel.spacing.x = unit(10, "pt"), panel.spacing.y = unit(14, "pt"))

Each technology’s need profile under both definitions. Bars are the share of that technology’s need attributed to each capability cluster; the five bars of one colour sum to 100 within each panel.
Code
tops <- need |> group_by(tech) |>
  summarise(leg = as.character(category[which.max(`Legacy SHAP axis`)]),
            win = as.character(category[which.max(`Winners' recipe`)]), .groups = "drop")
topcount <- tops |> pivot_longer(c(leg, win), names_to = "model", values_to = "top") |>
  count(model, top) |> pivot_wider(names_from = model, values_from = n, values_fill = 0)

Read the panels for shape, not level. Three patterns show up.

Machinery loses the top slot; Chemicals and Electronics take it. Which capability each definition calls the biggest need:

Code
kable(topcount |> transmute(Capability = top,
                            `Top under legacy` = leg, `Top under winners' recipe` = win) |>
        arrange(desc(`Top under legacy`)),
      caption = "Number of technologies for which each capability is the largest need.")
Number of technologies for which each capability is the largest need.
Capability Top under legacy Top under winners’ recipe
Machinery 7 4
Metals 2 1
Chemicals 1 3
Electronics 0 2

Machinery falls from 7 of 10 technologies to 4. Machinery holds the most HS codes of any cluster, and the legacy axis — a sum of z-scores — rewards a cluster for holding many codes.

Note that the movement does not average out to a systematic per-capability tilt: across technologies the gains and losses on any one capability roughly cancel (mean shifts are all under 2 pp). The disagreement is large within each technology and idiosyncratic across them, which is why it has to be read technology by technology and summarised with a distance rather than a mean.

Two technologies barely move at all. Wind and Electrolyzers agree on shape and on top axis under both definitions — there, the choice is immaterial.

3 One number per technology: TVD

To summarise a whole panel in a single figure, use total variation distance — half the sum of absolute differences across the five axes:

\[ \text{TVD} \;=\; \tfrac{1}{2}\sum_{c \in \text{clusters}} \bigl| \text{legacy}_c - \text{winners}_c \bigr| \]

Because both profiles sum to 100, TVD reads directly as “how many percentage points of the need profile would have to be moved to turn one into the other”. 0 means the two definitions agree exactly; 100 would mean they share no capability at all. It is the summary of exactly what the bars above show.

Code
tvd <- need |> group_by(tech) |>
  summarise(TVD = sum(abs(`Legacy SHAP axis` - `Winners' recipe`))/2,
            `Largest single-axis shift` = max(abs(`Legacy SHAP axis` - `Winners' recipe`)),
            `Top axis — legacy`  = as.character(category[which.max(`Legacy SHAP axis`)]),
            `Top axis — winners` = as.character(category[which.max(`Winners' recipe`)]),
            .groups = "drop") |>
  mutate(Flip = ifelse(`Top axis — legacy` != `Top axis — winners`, "yes", "—")) |>
  arrange(desc(TVD))
kable(tvd, digits = 1, col.names = c("Technology","TVD (pp)","Largest single-axis shift (pp)",
                                     "Top axis — legacy","Top axis — winners","Top axis flips"),
      caption = "How far apart the two definitions of need sit, per technology.")
How far apart the two definitions of need sit, per technology.
Technology TVD (pp) Largest single-axis shift (pp) Top axis — legacy Top axis — winners Top axis flips
Solar 38.9 38.9 Chemicals Electronics yes
Transmission 35.1 35.1 Machinery Electronics yes
Heat Pumps 26.1 26.1 Machinery Machinery
Geothermal 16.2 15.8 Machinery Machinery
Magnets 15.5 12.9 Metals Metals
Nuclear 14.8 13.6 Metals Chemicals yes
Batteries 12.3 7.0 Machinery Chemicals yes
Biofuel 8.4 8.4 Machinery Chemicals yes
Electrolyzers 6.7 5.1 Machinery Machinery
Wind 6.3 4.5 Machinery Machinery
Code
ggplot(tvd |> mutate(tech = fct_reorder(tech, TVD)), aes(TVD, tech)) +
  geom_segment(aes(x = 0, xend = TVD, yend = tech), colour = "grey88", linewidth = 2) +
  geom_point(aes(fill = Flip == "yes"), shape = 21, size = 4, stroke = 1,
             colour = "white") +
  scale_fill_manual(values = c(`TRUE` = "#6366f1", `FALSE` = "#cbd5e1"),
                    labels = c(`TRUE` = "top capability changes", `FALSE` = "top capability holds"),
                    name = NULL) +
  geom_text(aes(label = sprintf("%.1f", TVD)), hjust = -0.75,
            family = "Archivo", size = 3.1, colour = "#475569") +
  scale_x_continuous(expand = expansion(mult = c(0, .14)), labels = \(x) paste0(x, "pp")) +
  labs(title = "Median disagreement: 15.1 percentage points of the need profile",
       subtitle = "Total variation distance between the legacy SHAP axis and the winners'-recipe target",
       x = NULL, y = NULL) +
  theme(legend.position = "top", legend.justification = "left",
        panel.grid.major.y = element_blank(),
        panel.grid.major.x = element_line(colour = "grey92", linewidth = .3))

The same TVD figures ranked. Filled markers are technologies where the two definitions also name a different dominant capability.

Median 15.1 pp, range 6.3–38.9 pp, and the dominant capability changes for 5 of 10 technologies. Solar and Transmission are the two technologies where a reader would draw a materially different conclusion about what to build.

4 Does it change the recommendation?

TVD measures the profiles. Whether that matters depends on whether the movement crosses a decision boundary — so here it is run through the seven-cell priority grid, which combines the need share with the country’s own RCA. Only the need axis changes between the two runs; the RCA is identical.

Code
classify <- function(sp, r) case_when(
  sp >= 25 & r >= 1 ~ "Leverage",  sp >= 25 & r >= .5 ~ "Build-up", sp >= 25 ~ "Critical gap",
  sp >= 10 & r >= 1 ~ "Mature",    sp >= 10 & r >= .5 ~ "Build-up", sp >= 10 ~ "Gap",
  r >= 1 ~ "Bonus", TRUE ~ "Not priority")

rcaP <- read_parquet(here::here("data/pc/pc_rca.parquet")); YRP <- max(rcaP$year, na.rm = TRUE)
reg  <- read_csv(here::here("data/case_registry.csv"), show_col_types = FALSE) |> filter(kind == "country")

cells <- rcaP |> filter(category %in% CATS, tech %in% TECHS, year == YRP) |>
  select(iso3, country, tech, category, rca) |>
  inner_join(need |> mutate(tech = as.character(tech), category = as.character(category)),
             by = c("tech","category")) |>
  mutate(cell_leg = classify(`Legacy SHAP axis`, rca),
         cell_win = classify(`Winners' recipe`, rca),
         moved    = cell_leg != cell_win)
cellsR <- cells |> filter(iso3 %in% reg$iso3)

kable(cellsR |> group_by(Technology = tech) |>
        summarise(Cells = n(), Changed = sum(moved), .groups = "drop") |>
        mutate(`% changed` = Changed/Cells*100) |> arrange(desc(`% changed`)),
      digits = 1,
      caption = sprintf("Country × capability cells that change priority classification when the need definition is swapped — %d CVCE-roster countries, RCA year %d.",
                        n_distinct(cellsR$iso3), YRP))
Country × capability cells that change priority classification when the need definition is swapped — 27 CVCE-roster countries, RCA year 2024.
Technology Cells Changed % changed
Solar 135 96 71.1
Magnets 135 81 60.0
Heat Pumps 135 51 37.8
Transmission 135 46 34.1
Geothermal 135 27 20.0
Nuclear 108 21 19.4
Wind 135 18 13.3
Batteries 135 0 0.0
Biofuel 108 0 0.0
Electrolyzers 108 0 0.0

26.8% of roster cells change their priority classification on identical trade data. But the effect is a threshold effect, not a smooth one, and that is the important part: Solar changes 71% of its cells and Magnets 60%, while Batteries, Biofuel and Electrolyzers change none at all despite moving 12.3, 8.4 and 6.7 pp. Their movement never crosses the grid’s 10% or 25% cut points.

So TVD does not predict impact. It measures how far the definitions sit apart; whether that distance costs anything depends on where the cut points fall. Both numbers are needed.

Code
kable(cellsR |> filter(moved) |> count(`Under legacy` = cell_leg, `Under winners' recipe` = cell_win,
                                       sort = TRUE, name = "Cells") |> head(6),
      caption = "The most common reclassifications.")
The most common reclassifications.
Under legacy Under winners’ recipe Cells
Gap Not priority 81
Critical gap Gap 49
Mature Bonus 48
Gap Critical gap 44
Build-up Not priority 37
Leverage Mature 37

The clearest single case is Geothermal · Chemicals — 14.0% of the need under the legacy axis, 6.4% under the winners’ recipe — which turns “Build-up” into “Not priority” for China, India, Mexico, Malaysia, Singapore and Vietnam at once.

5 Sensitivity: how much of either is an artifact?

The comparison above is definition vs definition. A separate question is how robust either definition is to an arbitrary arithmetic choice inside it — the step that collapses a cluster’s features into one number, currently a sum, which could equally be a mean. Swapping it is a sensitivity test, not an alternative model.

Code
bcz <- read_csv(here::here("analysis/ml/shap_replication_by_category.csv"), show_col_types = FALSE) |>
  filter(tech %in% TECHS) |>
  mutate(across(c(n_features, pct_shipped_sum_z, pct_mean_z), \(x) coalesce(x, 0)))
sens_leg <- bcz |> group_by(tech) |>
  summarise(t = sum(abs(pct_shipped_sum_z - pct_mean_z))/2,
            f = category[which.max(pct_shipped_sum_z)] != category[which.max(pct_mean_z)], .groups="drop")

wsum <- read_csv(here::here("analysis/ml/shap_replication_by_class.csv"), show_col_types = FALSE) |>
  filter(retained, category %in% CATS, tech %in% TECHS) |>
  group_by(tech, category) |>
  summarise(s = sum(pmax(mean_shap_comp,0)), m = mean(pmax(mean_shap_comp,0)), n = n(), .groups="drop") |>
  complete(tech, category = CATS, fill = list(s=0,m=0,n=0)) |>
  group_by(tech) |> mutate(ps = s/sum(s)*100, pm = m/sum(m)*100) |> ungroup()
sens_win <- wsum |> group_by(tech) |>
  summarise(t = sum(abs(ps - pm))/2,
            f = category[which.max(ps)] != category[which.max(pm)], .groups="drop")

kable(tibble(
  `Need definition` = c("Legacy SHAP axis", "Winners' recipe"),
  `Median TVD, sum vs mean` = c(median(sens_leg$t), median(sens_win$t)),
  `Top axis flips` = sprintf("%d of 10", c(sum(sens_leg$f), sum(sens_win$f))),
  `Correlation with cluster headcount` = sprintf("%.3f", c(
    median(sapply(split(bcz, bcz$tech), \(x) if (sd(x$n_features)>0) cor(x$n_features, x$pct_shipped_sum_z) else NA_real_), na.rm=TRUE),
    median(sapply(split(wsum, wsum$tech), \(x) if (sd(x$n)>0) cor(x$n, x$ps) else NA_real_), na.rm=TRUE)))),
  digits = 1,
  caption = "Sensitivity of each need definition to the sum-vs-mean choice inside it.")
Sensitivity of each need definition to the sum-vs-mean choice inside it.
Need definition Median TVD, sum vs mean Top axis flips Correlation with cluster headcount
Legacy SHAP axis 27.2 7 of 10 0.999
Winners’ recipe 22.8 4 of 10 0.945

Both definitions are more sensitive to the arithmetic than they are different from each other — median 27.2 pp and 22.8 pp against the 15.1 pp that separates them. The mechanism is cluster size: within every technology the legacy axis tracks HS-code headcount at ρ = 0.999, because z-scoring flattens every feature to roughly the same magnitude so the sum recovers little else. The winners’ recipe sums genuinely heterogeneous signed magnitudes and falls to ρ = 0.945 — a real improvement, and still mostly a headcount.

shap_zscore_aggregation_dispute.qmd covers why the sum has no theoretical cover; the short version is that the theorem licensing it needs raw values and independent features, and neither holds here.

6 What to do

  1. Name one definition as canonical. Both are live, and nothing at the point of use records which surface reads which. This is the cheap fix and it should happen first.
  2. The winners’ recipe is the better of the two — signed direction rather than magnitude, read off countries that actually won — and it should be it.
  3. Do not quote a need share as a precise number. On either definition it is substantially a headcount, and it moves more under an arithmetic choice than between the two models.
  4. Re-check anything downstream of a threshold. The 10% / 25% cut points are where a modest profile shift becomes a changed recommendation, in 27% of roster cells.
  5. Regenerate or retire data/pc/pc_features.csv — it disagrees with the from-source replication at r = 0.76 and is what the shipped atlases and PC scatter read.

7 Register

Finding Status
Two need definitions coexist, differing by a median 15.1 pp and flipping the top capability for 5 of 10 techs; neither is marked canonical new — decide
Swapping the definition changes 27% of roster priority cells; the effect is threshold-driven, so TVD does not predict it new
data/pc/pc_features.csv diverges from analysis/ml/shap_replication_features.csv (r = 0.76, 45 unmatched rows) new — raise before further PC work
Both definitions remain substantially cluster-headcount driven (ρ = 0.999 legacy, 0.945 winners) quantifies shap_zscore_aggregation_dispute.qmd

8 Reproducibility

Every figure recomputes at render time from analysis/ml/shap_replication_by_class.csv, shap_replication_by_category.csv, shap_gap_by_country_cluster.csv and data/pc/pc_rca.parquet — all products of the same scripts/ml/regen_shap.py and target_profile_prep.py run. Retention is the pipeline’s own rule (mean_abs_z ≥ 0.5); the winners’ branch is verified against the shipped target_weight in §2. data/pc/pc_features.csv is not read.