Auditing the Capability-Gap Model

How it is built · what its axes measure · how the SHAP need-profile is filtered and aggregated · how to read the gap analysis honestly

Author

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

Published

July 3, 2026

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

NZ_DARK <- "#073309"; NZ_GREEN <- "#3cb54a"; NZ_LIGHT <- "#f0f7f0"
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())

CATS <- c("Chemicals","Electronics","Industrial Materials","Machinery","Metals")
feat <- read_csv(here::here("data/pc/pc_features.csv"), show_col_types = FALSE)
rca  <- read_parquet(here::here("data/pc/pc_rca.parquet"))
gd   <- read_csv(here::here("data/green_dict/green_dictionary.csv"), show_col_types = FALSE)
YR   <- max(rca$year, na.rm = TRUE)

# The two aggregations of the "need" axis, per tech × cluster.
agg <- feat |> filter(category %in% CATS) |>
  group_by(tech, category) |>
  summarise(s = sum(shap_mean_z), m = mean(shap_mean_z), n = n(), .groups = "drop") |>
  group_by(tech) |>
  mutate(pct_sum = s / sum(s) * 100,     # handbook: SUM of member importances
         pct_mean = m / sum(m) * 100) |>  # MEAN across members (magnitude-preserving)
  ungroup()
rc <- rca |> filter(category %in% CATS, year == YR) |> select(iso3, country, tech, category, rca)

classify <- function(sp, r) dplyr::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")
CELLCOL <- c(Leverage="#22c55e","Build-up"="#f97316","Critical gap"="#ef4444",
             Mature="#86efac", Gap="#f59e0b", Bonus="#38bdf8","Not priority"="#94a3b8")
cellord <- c("Leverage","Build-up","Critical gap","Mature","Gap","Bonus","Not priority")
NOTABLE <- c("BRA","IND","MEX","JPN","VNM","ESP","KOR","USA","CHN","DEU","CAN")

# 3-way feature bucket: supply-chain input (value-chain) / co-export neighbour / macro
pad6 <- function(x){ x <- suppressWarnings(as.integer(x)); ifelse(is.na(x), NA, sprintf("%06d", x)) }
gd_codes <- gd |> transmute(tech, code6 = pad6(code)) |> filter(!is.na(code6)) |> distinct() |> mutate(sc = TRUE)
feat_b <- feat |> mutate(code6 = ifelse(is.na(hs_code), NA, pad6(hs_code))) |>
  left_join(gd_codes, by = c("tech","code6")) |>
  mutate(bucket = case_when(category == "Non-HS" ~ "macro / policy",
                            !is.na(sc)            ~ "supply-chain input",
                            TRUE                  ~ "co-export neighbour"))

# --- sample-case radar machinery (always 5 axes; absent cluster = "no data") ---
prep_case <- function(iso_, tech_) {
  tibble(category = CATS) |>
    left_join(rc  |> filter(iso3 == iso_, tech == tech_) |> select(category, rca), by = "category") |>
    left_join(agg |> filter(tech == tech_) |> select(category, pct_sum, pct_mean, n), by = "category") |>
    mutate(present   = !is.na(pct_sum),
           cell_sum  = ifelse(present, classify(pct_sum,  rca), NA_character_),
           cell_mean = ifelse(present, classify(pct_mean, rca), NA_character_),
           category  = factor(category, levels = CATS)) |>
    arrange(category)
}
radar_plot <- function(d, which = c("sum","mean")) {
  which <- match.arg(which)
  pct  <- if (which == "sum") d$pct_sum  else d$pct_mean
  cell <- if (which == "sum") d$cell_sum else d$cell_mean
  base <- tibble(category = as.character(d$category),
                 shap_r = pmin(coalesce(pct, 0) / 50, 1),
                 rca_r  = pmin(coalesce(d$rca, 0) / 1.5, 1), cell = cell)
  n <- nrow(base); ang <- pi/2 - 2*pi*(seq_len(n) - 1)/n
  mk <- function(r, s) tibble(series = s, x = r*cos(ang), y = r*sin(ang))
  poly <- bind_rows(mk(base$shap_r, "SHAP need (aggregated)"), mk(base$rca_r, "Country RCA (have)"))
  grid <- tidyr::expand_grid(ring = c(0.25,0.5,0.75,1), i = seq_len(n)) |>
    mutate(a = pi/2 - 2*pi*(i - 1)/n, x = ring*cos(a), y = ring*sin(a))
  spokes <- tibble(x = cos(ang), y = sin(ang))
  labs_df <- base |> mutate(x = 1.36*cos(ang), y = 1.36*sin(ang),
                            celltxt = ifelse(is.na(cell), "no data", cell),
                            labcol = ifelse(is.na(cell), "#aab2b8", CELLCOL[cell]),
                            lab = paste0(category, "\n[", celltxt, "]"))
  ggplot() +
    geom_polygon(data = grid, aes(x, y, group = ring), fill = NA, colour = "grey86", linewidth = 0.3) +
    geom_segment(data = spokes, aes(x = 0, y = 0, xend = x, yend = y), colour = "grey86", linewidth = 0.3) +
    geom_polygon(data = poly, aes(x, y, group = series, fill = series, colour = series), alpha = 0.35, linewidth = 0.8) +
    geom_text(data = labs_df, aes(x, y, label = lab), family = "Archivo", fontface = "bold", size = 2.5,
              lineheight = 0.9, colour = labs_df$labcol) +
    scale_fill_manual(values = c("SHAP need (aggregated)"="#eab308","Country RCA (have)"="#3cb54a"), name=NULL) +
    scale_colour_manual(values = c("SHAP need (aggregated)"="#eab308","Country RCA (have)"="#3cb54a"), name=NULL) +
    coord_equal(xlim = c(-1.65,1.65), ylim = c(-1.5,1.5), clip = "off") +
    labs(title = if (which == "sum") "SUM aggregation (handbook axis)" else "MEAN aggregation") +
    theme_void(base_family = "Archivo") +
    theme(legend.position = "bottom", plot.title = element_text(size = 11, face = "bold", hjust = 0.5, colour = "#334155"))
}
case_panel <- function(iso_, tech_, title) {
  d <- prep_case(iso_, tech_)
  (radar_plot(d, "sum") | radar_plot(d, "mean")) +
    plot_annotation(title = title, theme = theme(plot.title = element_text(family="Archivo", face="bold", size=15, colour="#073309"))) +
    plot_layout(guides = "collect") & theme(legend.position = "bottom")
}
flip_table <- function(iso_, tech_) {
  prep_case(iso_, tech_) |>
    transmute(Category = as.character(category), RCA = round(rca, 2),
              `SHAP% (sum)` = round(pct_sum, 1), `Cell (sum)` = coalesce(cell_sum, "no data"),
              `SHAP% (mean)` = round(pct_mean, 1), `Cell (mean)` = coalesce(cell_mean, "no data"),
              Δ = ifelse(!is.na(cell_sum) & !is.na(cell_mean) & cell_sum != cell_mean, "→ moves", "")) |>
    arrange(desc(coalesce(`SHAP% (sum)`, -1)))
}

Purpose. A single, consolidated audit of the SHAP–RCA “capability-gap” model that underlies the Atlas of the Clean Industrial Base radar and the 3×3 gap grid. It covers (i) how the model is built and what its two outputs are, (ii) the feature-selection filtering the model uses and how the Atlas restricts it into a “standard tech profile” of what is needed, (iii) what the “need” axis actually measures, (iv) how sensitive the classification is to the sum-vs-mean aggregation, and (v) whether the design is circular. Every figure recomputes live from the shipped data (data/pc/, data/green_dict/), and the model design was verified against Ishana Ratan’s source notebooks (~/Documents/R/CICE/ML_vars/, a clone of github.com/ishanaratan/CICE). This is not a claim that the model is “wrong” — the design is a legitimate input→output one. It is an argument that the aggregation and presentation of the SHAP need-profile are undocumented and materially change the conclusions. Reference year 2024.

1 Executive summary

  1. Two outputs, often conflated. The Random Forest emits a Predicted Competitiveness score (a probability, per country) and SHAP importances (which the Atlas turns into the radar’s “need” axis). They are different objects linked by an exact identity — the SHAP values decompose the score — but only the aggregated, absolute-valued, thresholded SHAP ships. Formulas in Section 2.1.
  2. The need-profile is a feature-selection artifact, repurposed. The model’s |SHAP| → z-score → average → keep |z| ≥ 0.5 pipeline is a legitimate, principled feature-selection step (Cohen’s d moderate-effect cut) whose job is to answer “which capabilities clear a moderate-effect bar?”. The Atlas then sums the survivors by category into a quantitative “need” axis — a role the selection output was not built for (Section 3).
  3. So the “need” axis is mostly a product count. Summing cluster members makes the axis ≈99.9% correlated with how many product codes the dictionary put in the cluster; it inflates dispersion ~8×; switching to a mean flips the top driver for 9 of 11 techs (Section 4).
  4. The classification is aggregation-dependent. Across 8228 country×tech×cluster cases, ~56% change cell between sum and mean; two cells become unreachable under mean (Section 6).
  5. The largest predictors are hidden. Macro features (GDP, FDI, manufacturing) are ~14% of importance and rank #1 for Solar — but are dropped from the radar (Section 4).
  6. The gap analysis is not circular (verified against source). The target is the RCA of the final product (e.g. Solar = HS 854140), the target codes are excluded from the features, and the features are lagged one year. A genuine input→output design; the “60% supply-chain” share is the model correctly using the value chain, not circularity (Section 7). (An earlier draft argued the opposite; reading the code corrected it.)

Net: a sound, non-circular design, whose SHAP need-profile is filtered sensibly but then aggregated and presented in ways that mislead — count-driven, unsigned, country-averaged, macro-hidden.


2 How the model is built

Code
B <- tribble(
  ~id,~cx,~cy,~w,~h,~fill,~tx,~label,
  "baci",2.2,6.0,2.7,.9,"#e2e8f0","#1e293b","BACI bilateral trade\nHS6 · 1995–2024",
  "dict",6.0,6.0,3.3,.9,"#e2e8f0","#1e293b","Green Dictionary\nmanual HS6 → tech / role / category\n(external researcher)",
  "rca",2.2,4.6,3.0,.85,"#dcfce7","#14532d","HS6 Balassa RCA\nper country × year",
  "target",1.9,3.1,3.2,1.05,"#fde68a","#7c2d12","TARGET  y = 1[ RCA_final > 1 ]\nRCA of the final product(s)\n(e.g. Solar = HS 854140)\n— EXCLUDED from X",
  "feat",6.1,3.1,3.9,1.2,"#dbeafe","#1e3a8a","FEATURES  X   (lagged 1 year)\n· supply-chain input RCAs\n· co-export neighbour RCAs (P>.55)\n· macro (GDP·FDI·mfg·pop·tariffs)",
  "rf",3.8,1.6,3.7,.9,NZ_DARK,"white","RANDOM FOREST classifier\n(one per technology · 75/25 · CV)",
  "pc",1.9,0.1,3.2,1.0,"#0d9488","white","Predicted Competitiveness\n= P(RCA_tech > 1)\n[the score]",
  "shap",6.1,0.1,3.7,1.15,"#eab308","#3b2f00","SHAP explanation\nper country-year → z-score/yr\n→ avg over years → keep |z|>0.5\n(Cohen's d ≥ 0.5)\n= shap_mean_z  [the NEED]",
  "gap",3.8,-1.6,5.2,1.05,"#3cb54a","white","GAP ANALYSIS (radar / 3×3 grid)\nNEED = SHAP by cluster   vs   HAVE = country RCA by cluster"
) |> mutate(xmin=cx-w/2,xmax=cx+w/2,ymin=cy-h/2,ymax=cy+h/2)
g <- function(i) B[B$id==i,]
va <- function(f,t,dash=FALSE) tibble(x=g(f)$cx,y=g(f)$ymin,xend=g(t)$cx,yend=g(t)$ymax,dash=dash)
E <- bind_rows(va("baci","rca"),va("dict","rca",TRUE),va("rca","target"),va("rca","feat"),
               va("target","rf"),va("feat","rf"),va("rf","pc"),va("rf","shap"),va("shap","gap"))
mx <- -0.9
have <- tibble(x=c(g("rca")$xmin,mx,mx),y=c(g("rca")$cy,g("rca")$cy,g("gap")$cy),
               xend=c(mx,mx,g("gap")$xmin),yend=c(g("rca")$cy,g("gap")$cy,g("gap")$cy))
ggplot()+
  geom_segment(data=filter(E,!dash),aes(x,y,xend=xend,yend=yend),arrow=arrow(length=unit(.15,"cm"),type="closed"),colour=NZ_DARK,linewidth=.55)+
  geom_segment(data=filter(E,dash),aes(x,y,xend=xend,yend=yend),arrow=arrow(length=unit(.15,"cm"),type="closed"),colour="#64748b",linewidth=.5,linetype="22")+
  geom_segment(data=have[1:2,],aes(x,y,xend=xend,yend=yend),colour="#16a34a",linewidth=.5,linetype="22")+
  geom_segment(data=have[3,],aes(x,y,xend=xend,yend=yend),arrow=arrow(length=unit(.15,"cm"),type="closed"),colour="#16a34a",linewidth=.5,linetype="22")+
  annotate("text",x=mx-0.05,y=(g("rca")$cy+g("gap")$cy)/2,label="have\n(country RCA)",angle=90,family="Archivo",size=2.5,colour="#16a34a",lineheight=.9)+
  annotate("text",x=(g("dict")$cx+g("rca")$cx)/2+.3,y=(g("dict")$ymin+g("rca")$ymax)/2,label="tags codes",family="Archivo",size=2.3,colour="#64748b")+
  geom_rect(data=B,aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax,fill=fill),colour="grey80")+
  geom_text(data=B,aes(cx,cy,label=label,colour=tx),family="Archivo",fontface="bold",size=2.5,lineheight=.95)+
  scale_fill_identity()+scale_colour_identity()+coord_equal(xlim=c(-1.6,8.2),clip="off")+theme_void()

End-to-end construction (verified against Ishana’s notebooks). The RF has two outputs (score and SHAP). The TARGET is a final-product RCA that is EXCLUDED from the features, and the features are lagged one year — an input→output design, not a circular one (see the circularity section).

In words. Trade flows (BACI) plus a manual HS6→tech classification (the Green Dictionary, built by an external researcher) give an HS6 Balassa RCA per country-year. That splits into a target — is the country a revealed specialist in the final product, RCA_final > 1? — and a feature vector of other product RCAs plus co-export neighbours plus macro covariates, lagged one year. A Random Forest (one per technology) learns the target from the features.

2.1 The two outputs, precisely

The RF produces two distinct things that are easy to conflate. For a country-year with feature vector \(x\), the forest of \(T\) trees returns a probability that the country is competitive:

\[ \hat p(x) \;=\; \widehat{P}\big(\text{RCA}_{\text{final}} > 1 \mid x\big) \;=\; \frac{1}{T}\sum_{t=1}^{T} h_t(x) \;\in\; [0,1]. \]

This scalar is the Predicted Competitiveness score. SHAP does not produce a second prediction — it explains that same \(\hat p(x)\) by splitting it into signed per-feature contributions that obey an exact additive identity:

\[ \hat p(x) \;=\; \phi_0 + \sum_{j=1}^{M}\phi_j(x), \qquad\Longleftrightarrow\qquad \sum_{j=1}^{M}\phi_j(x) = \hat p(x) - \phi_0, \]

where \(\phi_0\) is the baseline (average output) and \(\phi_j(x)\) is the signed push feature \(j\) gives this prediction. Both statements hold at once, and there is no contradiction: the score is a scalar; the SHAP values are a length-\(M\) signed vector that decomposes it. So the Predicted Competitiveness score is not “the sum of the SHAP weights” — the SHAP values sum to the prediction minus baseline, per observation, and are signed.

Predicted Competitiveness (score) SHAP weights
Answers How competitive is this country? Why — which features drove it?
What it is probability \(\hat p(x)=P(\text{RCA}>1)\) per-feature attribution \(\phi_j(x)\) of that probability
Granularity (raw) one per country × tech × year one per country × tech × year × feature
Signed? raw SHAP is signed (+ toward / − away)
In the shipped data pc_scores shap_mean_z = |SHAP|, z-scored, averaged over years and countries, thresholded → one per tech × feature

The gap-analysis radar uses the shap_mean_z summary; it never uses the score. How that summary is built — and what it discards — is Section 3.

2.2 What a “feature” is

Code
bx <- tribble(
  ~id,~cx,~cy,~w,~fill,~txt,~label,
  "root",1.0,3.0,2.1,NZ_DARK,"white","A feature\n= one model input",
  "prod",4.7,4.1,3.6,NZ_GREEN,"white","Product-code features\ncountry's RCA in one HS6 code",
  "macro",4.7,1.6,3.6,"#f59e0b","white","Macro / policy features\nGDP · FDI · mfg · pop · trade · tariffs",
  "sc",9.0,4.8,3.9,NZ_LIGHT,NZ_DARK,"Supply-chain inputs\n(green-dictionary; predict the final-product target)",
  "coex",9.0,3.4,3.9,NZ_LIGHT,NZ_DARK,"Co-export neighbours\nP(export q | export SC) > 0.55"
) |> mutate(h=0.98,xmin=cx-w/2,xmax=cx+w/2,ymin=cy-h/2,ymax=cy+h/2)
edg <- tribble(~from,~to,"root","prod","root","macro","prod","sc","prod","coex") |>
  left_join(select(bx,from=id,fx=xmax,fy=cy),by="from") |>
  left_join(select(bx,to=id,tx=xmin,ty=cy),by="to")
ggplot()+
  geom_segment(data=edg,aes(fx,fy,xend=tx,yend=ty),colour="#9aa79c",linewidth=.7)+
  geom_rect(data=bx,aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax,fill=fill),colour=NA)+
  geom_text(data=bx,aes(cx,cy,label=label,colour=txt),family="Archivo",fontface="bold",size=2.85,lineheight=.95)+
  scale_fill_identity()+scale_colour_identity()+coord_cartesian(clip="off")+theme_void()

Two kinds of feature. Most are product-code features (a country’s RCA in one HS6 code), split into supply-chain products and co-export neighbours; a smaller block is macro / policy covariates.

This split — supply-chain vs co-export vs macro — is the hinge of the whole audit; it returns in the circularity check (Section 7).


3 Feature selection and the “standard tech profile”

This section is the crux of the audit and the part most relevant to the model’s authors: it separates what the model legitimately does (select features) from what the Atlas does with the result (build a quantitative, country-invariant “standard tech profile” of what a technology needs).

3.1 The model’s selection step is principled

For each technology the pipeline turns the raw per-feature SHAP into a retained feature list in four steps (verified in ~/Documents/R/CICE/ML_vars/Analysis/RCA <Tech> Analysis.ipynb):

  1. Global importance — average absolute SHAP over the \(N\) country-years: \(\;I_j = \tfrac{1}{N}\sum_i |\phi_j(x_i)|\).
  2. Year-specific standardization — within each year, z-score each feature’s SHAP across countries; average \(|z|\) over countries and years: \(\;\bar z_j = \tfrac{1}{N}\sum_i |z_{ij}|,\quad z_{ij}=\tfrac{\phi_{ij}-\bar\phi_j}{\sigma_j}\).
  3. Cohen’s-d selection — retain \(j \iff \bar z_j \ge 0.5\) (Cohen: 0.2 small / 0.5 medium / 0.8 large; the cut keeps at least moderate standardized importance). This is the shap_mean_z that ships, and the methods appendix documents exactly this — a list of retained features per tech (its SI Tables 21–30).
  4. Category aggregation (Atlas-side, not in the appendix) — sum the retained importances within each of the five SHAP categories: \(\;S_c = \sum_{j\in c,\ \bar z_j\ge 0.5}\bar z_j\).

Steps 1–3 are a standard, defensible feature-selection procedure. The whole audit is about step 4 and how the Atlas reads its output.

3.2 What the z-score does to the numbers

The z-score does two things to each feature’s SHAP: it centers it and — the important one — divides it by its own standard deviation. That division rescales every feature to the same spread, which removes the magnitude. Two features in one year, across a handful of countries:

Feature raw \(|\text{SHAP}|\) across countries raw mean\(|\text{SHAP}|\)
Feature A (swings predictions a lot) 0.40, 0.20, 0.08, 0.02, 0.01 0.14
Feature B (barely matters) 0.04, 0.02, 0.008, 0.002, 0.001 0.014

On the raw scale A is ~10× more important than B. Now z-score each feature (divide by its own SD): both distributions are squeezed to spread 1, so:

after z-score, mean\(|z|\)
Feature A 0.8
Feature B 0.8

They come out nearly identical. The z-score divided out the very thing that made A more important than B. This is not a bug — it is what standardization is for: the appendix’s stated rationale is to put features on a comparable scale across technologies with different class balance, so one threshold applies uniformly. The consequence, though, is that once features clear the bar their standardized importances are nearly interchangeable — which is what makes the category sum in step 4 behave like a count (Section 4).

3.3 From selected features to a “standard tech profile”

Here is precisely how NZIPL restricts the model output to build the Atlas need-profile:

  • Restriction 1 — drop the below-threshold tail. Only features with \(\bar z_j \ge 0.5\) enter. (Reasonable.)
  • Restriction 2 — drop the macro block from the radar. Only the five traded SHAP categories (Chemicals, Electronics, Industrial Materials, Machinery, Metals) become axes; GDP/FDI/manufacturing/tariffs — often the single most important feature — are set aside (Section 4).
  • Restriction 3 — collapse to one profile per technology. The retained features are summed within category across all countriesshap_mean_z is already averaged over country-years — producing one country-invariant 5-axis profile per tech. This is the “standard tech profile of what is needed”: a canonical statement of “to be competitive in technology \(T\), these capability clusters matter, in these proportions.”

The design intent of a standard profile is defensible — a technology’s capability requirements are, to first order, a property of the technology, not the country, so a single per-tech “what is needed” template is a reasonable object. The problem is the axis that template is drawn on. Because of the z-score (magnitude gone) and the sum (step 4), the proportions on that standard profile are driven by how many product codes the dictionary assigned to each cluster, not by how much each cluster actually moves competitiveness. The country enters only through the green “have” (RCA) overlay; the gold “need” template is the same for every country.

3.4 What the restriction keeps and discards

Step What it keeps What it discards
z-score (\(\div\) own SD) relative position within a feature magnitude — which capability matters more
\(\lvert\cdot\rvert\) absolute value strength direction — helps vs. hurts
mean over countries a common per-tech profile (“standard”) country-specificity
Cohen’s-d > 0.5 cut the set of moderate+ features (meaningful!) the below-threshold tail
drop macro from radar a clean 5-cluster picture the top-ranked non-value-chain predictors
sum by category a per-cluster total reduces to a count (magnitude already gone)

So what survives is real and useful: the set of capabilities the model found at least moderately important for each tech. What is lost — for a need-vs-have tool — is the ordering among them (z-score), the sign (absolute value), the country dimension (averaging), and the macro block (radar restriction); and then the category sum turns the survivors into a headcount.

One-sentence takeaway. Rather than “they manipulated SHAP and lost information,” the fair statement is: a principled feature-selection output was repurposed as a quantitative importance axis, and the standardization / absolute-value / averaging / sum strip out exactly the magnitude, direction, and country-specificity that a “need-vs-have” gap analysis depends on. The standard tech profile reads as if it ranks capability importance, but mostly it counts selected products.


4 The “need” axis is mostly a product count

Three facts show that summing shap_mean_z over a cluster’s members makes the axis track cluster size, not model-learned importance.

First, the raw picture: per-feature importances do vary, but the five cluster means are nearly identical — a direct consequence of the principled Cohen’s-d cut, which retains a homogeneous moderate-to-strong band (≈0.50–0.82), so every cluster mean lands in its middle.

Code
fc <- feat |> filter(category %in% CATS)
ggplot(fc, aes(reorder(category, shap_mean_z), shap_mean_z)) +
  geom_jitter(width = .15, alpha = .35, colour = "#64748b", size = 1) +
  stat_summary(fun = mean, geom = "point", shape = 18, size = 5, colour = NZ_DARK) +
  coord_flip() +
  labs(x = NULL, y = "shap_mean_z  =  mean |z| SHAP  (retained: Cohen's d ≥ 0.5)",
       title = "Per-feature spread is real; cluster means are nearly equal")

Distribution of retained importance by cluster (points) with cluster means (diamonds). Real feature-to-feature spread, but the five cluster means sit within ~0.03 — because the Cohen’s-d cut keeps a homogeneous set. Since the means cannot separate the clusters, only the product COUNTS can — which is what the sum does.
Code
cv <- function(x) sd(x)/mean(x)
d_feat <- fc |> group_by(tech) |> summarise(`per-feature importance` = cv(shap_mean_z), .groups="drop")
d_cat  <- fc |> group_by(tech,category) |> summarise(s=sum(shap_mean_z), n=n(), .groups="drop") |>
  group_by(tech) |> summarise(`cluster SHAP % (sum)` = cv(s/sum(s)),
                              `cluster share (count only)` = cv(n/sum(n)), .groups="drop")
disp <- left_join(d_feat,d_cat,by="tech") |> pivot_longer(-tech, names_to="stage", values_to="cv")
ggplot(disp, aes(cv, reorder(tech, cv))) +
  geom_line(aes(group=tech), colour="#d5dbd5", linewidth=.9) +
  geom_point(aes(colour=stage), size=2.6) +
  scale_colour_manual(values=c("per-feature importance"="#64748b",
    "cluster SHAP % (sum)"=NZ_GREEN,"cluster share (count only)"="#f59e0b"), name=NULL) +
  labs(x="coefficient of variation (dispersion)", y=NULL,
       title="Summation inflates dispersion ~8× — and the counts do it") +
  theme(legend.position="bottom") + guides(colour=guide_legend(nrow=2))

Per-feature importances are nearly flat (grey, CV~0.10). Summing them into cluster shares inflates dispersion ~8× (green) — and that sits almost exactly on the count-only share (orange). The spread that makes the axis look ‘decisive’ is produced by counting.
Code
shp <- fc |> group_by(tech,category) |> summarise(s=sum(shap_mean_z), m=mean(shap_mean_z), n=n(), .groups="drop") |>
  group_by(tech) |> mutate(pct_sum=s/sum(s)*100, pct_mean=m/sum(m)*100, pct_cnt=n/sum(n)*100) |> ungroup()
n_flip <- shp |> group_by(tech) |> summarise(f = category[which.max(pct_sum)] != category[which.max(pct_mean)]) |> pull(f) |> sum()
tibble(comparison = c("SHAP% (sum) vs product-count %","SHAP% (sum) vs SHAP% (mean-based)"),
       correlation = c(round(cor(shp$pct_sum, shp$pct_cnt),3), round(cor(shp$pct_sum, shp$pct_mean),3))) |>
  kable(caption = sprintf("The 'need' axis is 99.9%% the feature count and nearly unrelated to the magnitude-based reading. Aggregating by mean instead of sum flips the top driver for %d of 11 techs.", n_flip))
The ‘need’ axis is 99.9% the feature count and nearly unrelated to the magnitude-based reading. Aggregating by mean instead of sum flips the top driver for 9 of 11 techs.
comparison correlation
SHAP% (sum) vs product-count % 0.999
SHAP% (sum) vs SHAP% (mean-based) 0.243

The largest predictors are dropped. The radar shows only the five traded clusters, but the macro block often holds the single most important feature:

Code
feat |> group_by(tech) |>
  mutate(rk = rank(-shap_mean_z)) |>
  summarise(`best macro rank` = suppressWarnings(min(rk[category=="Non-HS"])),
            `macro % of importance` = round(sum(shap_mean_z[category=="Non-HS"])/sum(shap_mean_z)*100),
            .groups="drop") |> filter(is.finite(`best macro rank`)) |> arrange(`best macro rank`) |>
  kable(caption = "For Solar the #1 feature overall is FDI; macro is ~14% of importance on average — all invisible on the radar.")
For Solar the #1 feature overall is FDI; macro is ~14% of importance on average — all invisible on the radar.
tech best macro rank macro % of importance
Solar 1 16
Nuclear 2 13
Electrolyzers 3 11
Biofuel 4 8
Magnets 5 26
Heat Pumps 6 19
Wind 7 7
Batteries 12 14
Geothermal 13 4
Transmission 13 19

Confirmed from source — see the companion ml_replication.qmd. That note regenerates this need axis from Ishana’s notebooks for all 11 technologies (RFs pinned, seed 28) and finds, from scratch: the shipped cluster axis correlates r ≈ 0.99 with product count; the top-priority cluster flips for 9 of 11 techs once magnitude is preserved; ~47% of retained features are net-negative (the sign the axis discards); and each feature’s country-year |SHAP| has a median peak ~17× its own mean (the country variation the average collapses). Solar reproduces the shipped shap_mean_z bit-for-bit, confirming the pipeline is replicated exactly.

5 Sum vs mean, and the sample cases

Neither aggregation is “true”: sum answers “where is the tech’s model-relevant footprint concentrated?” (breadth, dominated by cluster size); mean answers “how important is a typical product here?” (depth, nearly flat). Because the quadrant’s SHAP thresholds (10%, 25%) are fixed, that reshaping pushes clusters across cell boundaries. The three sample cases make it concrete — the gold “need” polygon changes, the green “have” (RCA) polygon does not, and axis labels are coloured by the cell each cluster lands in under that aggregation.

Code
cases <- tribble(~iso, ~tech, ~title,
  "IND","Solar",     "India · Solar",
  "BRA","Biofuel",   "Brazil · Biofuel",
  "CAN","Batteries", "Canada · Batteries")
cases |>
  mutate(`clusters that move cell` = purrr::map2_int(iso, tech, function(i, t) {
           p <- prep_case(i, t)
           sum(!is.na(p$cell_sum) & !is.na(p$cell_mean) & p$cell_sum != p$cell_mean) }),
         `clusters with data` = purrr::map2_int(iso, tech, ~sum(prep_case(.x, .y)$present))) |>
  transmute(Case = title, `clusters with data`, `clusters that move cell`) |>
  kable(caption = "How many capability clusters change their quadrant cell when the aggregation switches from sum to mean.")
How many capability clusters change their quadrant cell when the aggregation switches from sum to mean.
Case clusters with data clusters that move cell
India · Solar 5 2
Brazil · Biofuel 4 0
Canada · Batteries 5 3

5.1 India · Solar

The flagship. Under sum, Chemicals is a Critical gap (the single top priority); under mean it is only a Gap, and low-count Industrial Materials rises from Not priority to Gap. The “deep upstream” headline is partly an artifact of counting chemistry products.

Code
case_panel("IND", "Solar", "India · Solar")

Code
kable(flip_table("IND", "Solar"), caption = "India · Solar — the top-priority cell (Chemicals) is aggregation-dependent.")
India · Solar — the top-priority cell (Chemicals) is aggregation-dependent.
Category RCA SHAP% (sum) Cell (sum) SHAP% (mean) Cell (mean) Δ
Chemicals 0.50 38.9 Critical gap 20.6 Gap → moves
Metals 0.91 23.7 Build-up 20.4 Build-up
Electronics 0.17 22.7 Gap 19.6 Gap
Machinery 0.72 12.0 Build-up 20.7 Build-up
Industrial Materials 0.24 2.7 Not priority 18.6 Gap → moves

5.2 Brazil · Biofuel

No cell changes, but the dispersion point is vivid: the sum polygon is spiky (Machinery/Chemicals dominate) while the mean rounds out across the clusters biofuel touches (all ≈22–26%). Electronics shows as no data: no electronics HS codes map to biofuel, so the model retains no electronics feature — an honest gap, not a zero.

Code
case_panel("BRA", "Biofuel", "Brazil · Biofuel")

Code
kable(flip_table("BRA", "Biofuel"), caption = "Brazil · Biofuel — cells are stable, but the SHAP% profile flattens dramatically under mean.")
Brazil · Biofuel — cells are stable, but the SHAP% profile flattens dramatically under mean.
Category RCA SHAP% (sum) Cell (sum) SHAP% (mean) Cell (mean) Δ
Machinery 0.47 33.5 Critical gap 26.5 Critical gap
Chemicals 0.19 30.3 Critical gap 25.4 Critical gap
Industrial Materials 0.92 25.1 Build-up 25.5 Build-up
Metals 0.55 11.1 Build-up 22.6 Build-up
Electronics NA NA no data NA no data

5.3 Canada · Batteries

The most volatile. Under sum, only Chemicals and Machinery clear the 10% bar; everything else is Not priority. Under mean, the three low-count clusters (Metals, Electronics, Industrial Materials) all clear 10% and become Gap — turning a two-priority picture into a five-priority one.

Code
case_panel("CAN", "Batteries", "Canada · Batteries")

Code
kable(flip_table("CAN", "Batteries"), caption = "Canada · Batteries — three clusters cross Not-priority → Gap purely from the aggregation switch.")
Canada · Batteries — three clusters cross Not-priority → Gap purely from the aggregation switch.
Category RCA SHAP% (sum) Cell (sum) SHAP% (mean) Cell (mean) Δ
Chemicals 0.94 48.6 Build-up 20.8 Build-up
Machinery 0.53 32.9 Build-up 21.1 Build-up
Metals 0.49 9.5 Not priority 20.3 Gap → moves
Electronics 0.37 6.0 Not priority 19.3 Gap → moves
Industrial Materials 0.00 2.9 Not priority 18.4 Gap → moves

5.4 All eleven technologies: the standard need-profile, both ways

Stripping out the country dimension, this is the pure “need” question for every technology — the standard tech profile from Section 3: gold = the handbook’s sum profile, teal = the mean profile, on a shared per-tech scale. Across all eleven the pattern is identical — sum spikes toward the fat clusters, mean rounds toward a regular pentagon. Clusters marked * are no data.

Code
ABBR <- c(Chemicals="Chem",Electronics="Elec","Industrial Materials"="Ind.Mat",Machinery="Mach",Metals="Met")
tech_radar <- function(tech_) {
  b <- tibble(category=CATS) |> left_join(agg|>filter(tech==tech_)|>select(category,pct_sum,pct_mean),by="category")
  denom <- max(c(b$pct_sum,b$pct_mean),na.rm=TRUE)
  b <- b |> mutate(sum_r=coalesce(pct_sum,0)/denom,mean_r=coalesce(pct_mean,0)/denom,present=!is.na(pct_sum))
  n<-nrow(b); ang<-pi/2-2*pi*(seq_len(n)-1)/n
  mk<-function(r,s) tibble(series=s,x=r*cos(ang),y=r*sin(ang))
  poly<-bind_rows(mk(b$sum_r,"Sum (handbook)"),mk(b$mean_r,"Mean"))
  grid<-tidyr::expand_grid(ring=c(.33,.66,1),i=seq_len(n))|>mutate(a=pi/2-2*pi*(i-1)/n,x=ring*cos(a),y=ring*sin(a))
  lab<-b|>mutate(x=1.32*cos(ang),y=1.32*sin(ang),lab=ifelse(present,ABBR[category],paste0(ABBR[category],"*")),col=ifelse(present,"#475569","#aab2b8"))
  ggplot()+geom_polygon(data=grid,aes(x,y,group=ring),fill=NA,colour="grey88",linewidth=.22)+
    geom_polygon(data=poly,aes(x,y,group=series,fill=series,colour=series),alpha=.30,linewidth=.6)+
    geom_text(data=lab,aes(x,y,label=lab),family="Archivo",size=2.2,colour=lab$col)+
    scale_fill_manual(values=c("Sum (handbook)"="#eab308","Mean"="#0d9488"),name=NULL)+
    scale_colour_manual(values=c("Sum (handbook)"="#eab308","Mean"="#0d9488"),name=NULL)+
    coord_equal(xlim=c(-1.5,1.5),ylim=c(-1.4,1.4),clip="off")+labs(title=tech_)+
    theme_void(base_family="Archivo")+theme(plot.title=element_text(size=10,face="bold",hjust=.5,colour=NZ_DARK),legend.position="bottom")
}
techs11<-c("Solar","Wind","Batteries","EVs","Nuclear","Geothermal","Heat Pumps","Electrolyzers","Transmission","Magnets","Biofuel")
patchwork::wrap_plots(lapply(techs11,tech_radar),ncol=4)+patchwork::plot_layout(guides="collect") & theme(legend.position="bottom")

SHAP-only standard need profile per technology: gold = sum (handbook), teal = mean. The mean profile is a near-regular pentagon almost everywhere — once you divide out the counts, the model sees each cluster as roughly equally important. All the inter-cluster structure in the handbook radar is count structure.

6 Population EDA — does the classification abide?

Applying the 7-cell scheme to all country×tech×cluster cases stress-tests whether the grid is exhaustive-and-populated and whether it is stable to the aggregation choice.

Code
pop <- rc |> inner_join(agg, by=c("tech","category")) |>
  mutate(cell_sum=classify(pct_sum,rca), cell_mean=classify(pct_mean,rca))
chg <- mean(pop$cell_sum != pop$cell_mean)

The population is 8228 observations across 226 economies. Every observation is classified both ways so the difference is explicit.

Code
dd <- bind_rows(pop|>count(cell=cell_sum)|>mutate(agg="Sum (handbook)"),
                pop|>count(cell=cell_mean)|>mutate(agg="Mean")) |>
  mutate(cell=factor(cell,levels=cellord), pct=n/nrow(pop)*100)
ggplot(dd, aes(cell, pct, fill=agg)) +
  geom_col(position=position_dodge(width=.75), width=.68) +
  scale_fill_manual(values=c("Sum (handbook)"="#eab308","Mean"="#0d9488"), name=NULL) +
  labs(x=NULL, y="% of all country×tech×cluster observations",
       title="The classification is highly sensitive to the aggregation") +
  theme(legend.position="bottom", axis.text.x=element_text(angle=20,hjust=1))

Share of all observations in each cell, sum vs mean. Under mean the two low-SHAP cells (Bonus, Not-priority) empty out entirely and Gap balloons to ~66%.

Under mean the two low-SHAP cells empty out (mean never falls below 10%, so Bonus and Not-priority are structurally unreachable) and Gap swells from ~26% to ~66%. The scheme’s reachable cells depend on the aggregation — a sign the thresholds are calibrated to the sum’s inflated dispersion.

Code
SL<-c("Low <10%","Med 10–25%","High ≥25%"); RL<-c("Weak <0.5","Partial 0.5–1","Strength ≥1")
b1<-function(v)cut(v,c(-1,10,25,1e9),labels=SL); b2<-function(v)cut(v,c(-1,.5,1,1e9),labels=RL)
gsum<-pop|>count(SHAP=b1(pct_sum),RCA=b2(rca),name="n_sum"); gmean<-pop|>count(SHAP=b1(pct_mean),RCA=b2(rca),name="n_mean")
nm<-c("High ≥25%.Strength ≥1"="Leverage","High ≥25%.Partial 0.5–1"="Build-up","High ≥25%.Weak <0.5"="Critical gap",
      "Med 10–25%.Strength ≥1"="Mature","Med 10–25%.Partial 0.5–1"="Build-up","Med 10–25%.Weak <0.5"="Gap",
      "Low <10%.Strength ≥1"="Bonus","Low <10%.Partial 0.5–1"="Not priority","Low <10%.Weak <0.5"="Not priority")
gd2<-tidyr::expand_grid(SHAP=factor(SL,SL),RCA=factor(RL,RL))|>left_join(gsum,by=c("SHAP","RCA"))|>left_join(gmean,by=c("SHAP","RCA"))|>
  mutate(across(c(n_sum,n_mean),~replace_na(.,0L)),cellname=nm[paste(SHAP,RCA,sep=".")])
ggplot(gd2,aes(RCA,SHAP,fill=n_sum))+geom_tile(colour="white",linewidth=1)+
  geom_text(aes(label=sprintf("%s\nsum %s\nmean %s",cellname,format(n_sum,big.mark=","),format(n_mean,big.mark=","))),family="Archivo",size=2.9,lineheight=.95,colour="#0b1a12")+
  scale_fill_gradient(low="#f0f7f0",high="#7bc47f",guide="none")+
  labs(x="Country RCA (have)",y="SHAP need band",title="Sum vs mean counts per grid position")+theme(panel.grid=element_blank())

Each tile shows the count under both aggregations; tile colour follows the sum count. All nine positions are used under sum; the whole Low-SHAP row is empty under mean.

Overall 56% of cases change cell between the two aggregations — the classification is a framing, not a stable rule. The heavy Weak-RCA (left-column) concentration is expected: RCA>1 is a relative measure, so most country-cluster pairs are genuinely “weak”; the instability is on the SHAP axis, not the RCA axis.

Code
tm <- pop |> count(cell_sum, cell_mean) |>
  mutate(cell_sum  = factor(cell_sum,  levels = rev(cellord)),
         cell_mean = factor(cell_mean, levels = cellord),
         moved = ifelse(as.character(cell_sum) == as.character(cell_mean), "unchanged", "flipped"))
ggplot(tm, aes(cell_mean, cell_sum)) +
  geom_tile(aes(fill = moved, alpha = n), colour = "white", linewidth = 0.9) +
  geom_text(aes(label = format(n, big.mark = ",")), family = "Archivo", size = 3, colour = "#0b1a12") +
  scale_fill_manual(values = c(unchanged = "#22c55e", flipped = "#f97316"), name = NULL) +
  scale_alpha(range = c(0.30, 1), guide = "none") +
  scale_x_discrete(drop = FALSE) + scale_y_discrete(drop = FALSE) +
  labs(x = "→ cell under MEAN", y = "cell under SUM",
       title = "Flips: diagonal = unchanged, off-diagonal = flipped") +
  theme(panel.grid = element_blank(), axis.text.x = element_text(angle = 20, hjust = 1), legend.position = "bottom")

Both axes use the same 7-cell order, so the DIAGONAL (green) is cases whose cell is unchanged and every OFF-diagonal (orange) tile is a flip. The two rightmost columns (Bonus, Not-priority) are empty — mean never produces them. Opacity ∝ count.

The flips are systematic, not noisy: mean compresses the SHAP axis into the middle band, so the two largest moves are Not-priority → Gap and Critical gap → Gap, and the strong-RCA cells slide Leverage → Mature and Bonus → Mature. Only ~44% sit on the diagonal. Whether a case is a top priority (Critical gap / Leverage) or a middling one (Gap / Mature) is, for most of the population, a choice of aggregation.

Concrete examples (notable economies, cases selected by their sum cell, top by RCA within it). The Sum cell and Mean cell columns sit side by side; rows are shaded green where they agree and orange where the classification flips:

Code
cell_examples <- function(cl, n = 5) {
  pop |> filter(iso3 %in% NOTABLE, cell_sum == cl) |> arrange(desc(rca)) |> slice_head(n = n) |>
    transmute(Country = country, Technology = tech, Cluster = as.character(category),
              RCA = round(rca, 2), `SHAP% (sum)` = round(pct_sum, 1),
              `Sum cell` = cl, `Mean cell` = cell_mean)
}
extab <- bind_rows(lapply(c("Leverage","Build-up","Critical gap","Mature","Bonus"), cell_examples))
same <- which(extab$`Sum cell` == extab$`Mean cell`); diff <- setdiff(seq_len(nrow(extab)), same)
if (knitr::is_html_output()) {
  kbl(extab, caption = "Representative cases per sum-cell, with the cell each lands in under mean. Green = unchanged, orange = flipped.") |>
    kable_styling(full_width = FALSE, font_size = 12, bootstrap_options = c("condensed")) |>
    column_spec(6:7, border_left = TRUE) |>
    row_spec(same, background = "#e8f7ee") |> row_spec(diff, background = "#fdecdf")
} else {
  extab |> mutate(` ` = ifelse(`Sum cell` == `Mean cell`, "= unchanged", "→ flipped")) |>
    kable(caption = "Representative cases per sum-cell, with the cell each lands in under mean (last column flags unchanged vs flipped).")
}
Representative cases per sum-cell, with the cell each lands in under mean. Green = unchanged, orange = flipped.
Country Technology Cluster RCA SHAP% (sum) Sum cell Mean cell
Brazil Nuclear Metals 7.88 49.6 Leverage Leverage
Brazil Transmission Metals 7.57 27.0 Leverage Mature
Brazil Geothermal Metals 7.56 25.2 Leverage Mature
Canada Biofuel Industrial Materials 5.05 25.1 Leverage Leverage
Japan Transmission Machinery 4.67 50.6 Leverage Mature
United States Electrolyzers Machinery 1.00 45.4 Build-up Build-up
United States Heat Pumps Machinery 0.99 48.4 Build-up Build-up
India Geothermal Chemicals 0.98 11.2 Build-up Build-up
Canada Electrolyzers Machinery 0.98 45.4 Build-up Build-up
Germany Solar Metals 0.97 23.7 Build-up Build-up
India Electrolyzers Chemicals 0.50 35.9 Critical gap Critical gap
India Solar Chemicals 0.50 38.9 Critical gap Gap
Mexico Transmission Metals 0.49 27.0 Critical gap Gap
Vietnam Heat Pumps Metals 0.49 29.4 Critical gap Gap
India Nuclear Metals 0.49 49.6 Critical gap Critical gap
Brazil EVs Metals 10.98 11.9 Mature Mature
Canada Nuclear Chemicals 5.71 21.7 Mature Mature
Japan Magnets Industrial Materials 3.51 12.5 Mature Mature
South Korea Magnets Industrial Materials 3.38 12.5 Mature Mature
South Korea Magnets Chemicals 3.07 12.7 Mature Mature
Brazil Heat Pumps Chemicals 11.71 3.8 Bonus Mature
Brazil Transmission Chemicals 11.71 4.0 Bonus Mature
Japan EVs Industrial Materials 4.67 4.5 Bonus Mature
Canada Transmission Industrial Materials 3.47 4.9 Bonus Mature
South Korea EVs Industrial Materials 3.41 4.5 Bonus Mature

Does it abide? Partly. It is exhaustive and interpretable (every case classifies; the named cells map onto intuitive postures) but not stable (56% flip; two cells vanish under mean) and only weakly discriminating at the population level (most of the world is Weak-RCA). One coherence quirk: cluster-level RCA is tech-agnostic, so Brazil’s high Metals RCA makes it a “Metals Leverage” case for every metals-weighted technology at once — the “have” side does not distinguish which tech the capability serves. The scheme is a sound communication device for case-by-case reasoning, not a stable, technology-specific ranking.


7 Is the gap analysis circular? (Checked against source code)

An earlier draft argued the gap analysis was substantially circular — that the target was an aggregate of the same supply-chain RCAs used as features. Reading Ishana’s notebooks shows that is not how the model is built; the concern is retracted.

Design element Verified in code
Target y = 1[ RCA of the final product > 1 ] — a single final-product HS code (Solar dv_854140; Wind dv_850231) or a small final-product aggregate (Batteries dv_battery over {850760, 850780}). Not an aggregate of the supply chain.
Target excluded from X excluded_cols drops the target column before fitting. Confirmed in the shipped features: 854140, 850231, 850760, 850780 are all absent from pc_features.
Features the other products’ RCAs — supply-chain inputs + co-export neighbours (prox_codes − supply-chain − target) + macro.
Temporal lag features are groupby('country_code').shift(1)last-year capabilities predict this-year competitiveness.
SHAP → need |SHAP|, z-scored within year, mean over country-years, threshold mean|z| ≥ 0.5 (Cohen’s d ≥ 0.5), then category need = .sum().

So the model predicts this-year final-product competitiveness from last-year upstream + neighbour + macro capabilities, with the target removed from the inputs — a genuine input→output design, not circular. The three-way feature split is a fact, but its meaning is value-chain-internal inputs vs external co-export neighbours vs macro:

Code
feat_b |> group_by(bucket) |>
  summarise(features = n(), `importance mass` = round(sum(shap_mean_z)), .groups="drop") |>
  mutate(`% of features` = round(features/sum(features)*100),
         `% of importance` = round(`importance mass`/sum(`importance mass`)*100)) |>
  arrange(desc(`% of importance`)) |> select(bucket, features, `% of features`, `% of importance`) |>
  kable(caption = "Three-way split of the features. ~60% are the tech's own value-chain inputs, ~30% co-export neighbours, ~10% macro. The model *using the value chain* to predict final-product competitiveness — not circularity.")
Three-way split of the features. ~60% are the tech’s own value-chain inputs, ~30% co-export neighbours, ~10% macro. The model using the value chain to predict final-product competitiveness — not circularity.
bucket features % of features % of importance
supply-chain input 271 60 60
co-export neighbour 139 31 30
macro / policy 45 10 10
Code
sp <- feat_b |> filter(category %in% CATS) |>
  group_by(tech, category) |>
  summarise(need = sum(shap_mean_z), need_in = sum(shap_mean_z[bucket=="supply-chain input"]), .groups="drop") |>
  mutate(in_share = need_in/need*100)
ggplot(sp, aes(factor(category, CATS), tech, fill = in_share)) +
  geom_tile(colour = "white", linewidth = 0.6) +
  geom_text(aes(label = ifelse(is.na(in_share), "—", paste0(round(in_share)))), family="Archivo", size=2.7, colour="#0b1a12") +
  scale_fill_gradient(low = "#eff6ff", high = "#2563eb", na.value = "grey92", name = "% inputs") +
  labs(x = NULL, y = NULL, title = "Value-chain-input share of each cluster's need") +
  theme(axis.text.x = element_text(angle = 20, hjust = 1))

Share of each cluster’s need coming from the tech’s own value-chain inputs (vs co-export neighbours). Descriptive of value-chain structure, not a circularity flag.

What retracting circularity leaves standing. The need-axis findings are untouched and were confirmed line-by-line: category need is a .sum() (count-driven); |SHAP| is unsigned; the mean over countries makes the profile country-invariant; the Cohen’s-d cut yields near-equal cluster magnitudes; macro is dropped from the radar. The RF’s genuine, non-circular contribution — lagged upstream/neighbour/macro capabilities predict final-product competitiveness — is real, but under-served by the aggregation.


8 How to read the capability map (with the caveats applied)

The 7-cell grid remains a useful communication device, provided it is read as diagnostic, not predictive. Read against the India · Solar panels above: under sum, Chemicals is a Critical gap (headline priority); under mean, only a Gap. The honest reading — given the source check (non-circular) and the need-axis critique (aggregation) — is: lagged Chemicals-input capability is among the predictors of final-module competitiveness, and India lacks it — a genuine input→output finding, but whose cluster ranking is aggregation-dependent and should be reported as “between cells,” not asserted as the single top priority.

Code
tribble(
  ~Cell, ~`Need (SHAP)`, ~`Have (RCA)`, ~`Read it as`,
  "Leverage","High","Strength","Strength meets need — anchor here (robust where sum & mean agree).",
  "Build-up","High/Med","Partial","One targeted step from competitiveness.",
  "Critical gap","High","Weak","A predictive upstream input the country lacks — a genuine (non-circular) gap, but read the SHAP level with the aggregation caveats.",
  "Mature","Med","Strength","A maintained specialisation; supporting, not decisive.",
  "Gap","Med","Weak","Second-order weakness; sequence after critical gaps.",
  "Bonus","Low","Strength","Capability the tech barely rewards — an asset for another tech (vanishes under mean).",
  "Not priority","Low","Partial/Weak","Low-salience (vanishes under mean)."
) |> kable(caption = "The seven cells, annotated with the audit caveats.")
The seven cells, annotated with the audit caveats.
Cell Need (SHAP) Have (RCA) Read it as
Leverage High Strength Strength meets need — anchor here (robust where sum & mean agree).
Build-up High/Med Partial One targeted step from competitiveness.
Critical gap High Weak A predictive upstream input the country lacks — a genuine (non-circular) gap, but read the SHAP level with the aggregation caveats.
Mature Med Strength A maintained specialisation; supporting, not decisive.
Gap Med Weak Second-order weakness; sequence after critical gaps.
Bonus Low Strength Capability the tech barely rewards — an asset for another tech (vanishes under mean).
Not priority Low Partial/Weak Low-salience (vanishes under mean).

9 Assessment, verdict and recommendations

Verdict. The model’s design is sound and — verified against source — not circular: it predicts final-product competitiveness from lagged upstream, co-export, and macro capabilities, with the target excluded from the inputs. The feature-selection step (z-score → Cohen’s-d cut) is legitimate. The problem is confined to the downstream restriction and aggregation that turn the selection output into the Atlas’s standard tech profile: summed into product counts, absolute-valued (unsigned), averaged over countries, and shorn of its macro block. The conclusions to revisit are about the radar and the 3×3 grid, not the underlying RF.

Recommendations.

  1. Fix the need axis, not the model. Build the category profile from the quantity that still carries magnitude — the raw mean|SHAP| (\(I_j\)), aggregated by category — rather than the z-scored, thresholded shap_mean_z. Within a single tech the SHAP values are already common-unit (one pooled forest), so no per-feature standardization is needed to compare them, and the raw values preserve the 10:1-type contrasts the z-score erases. Keep the Cohen’s-d list as the selection step; take magnitude from the raw importances. (Practical note — the raw values are recoverable but not saved: see Section 10.)
  2. Report NEED as mean as well as sum (sum ≈ counts), keep the sign of SHAP where possible, and restore the macro block — it holds top-ranked, non-value-chain predictors.
  3. Document the two outputs — score vs SHAP; the additive identity \(\hat p = \phi_0 + \sum_j \phi_j\); raw-signed vs aggregated-absolute; country-specific vs country-averaged; what can and cannot be summed.
  4. Flag aggregation sensitivity — cells near a boundary, or that move between sum and mean (~56% of the population), should be read as “between cells,” not asserted. A sum ⇄ mean toggle in the Atlas / Report-Builder radar would make the sensitivity visible interactively.
  5. State the design correctly in any external write-up: single/aggregate final-product target, excluded from features, one-year-lagged inputs. Document the category-aggregation rule and the macro exclusion — currently Atlas-only, absent from the methods appendix.

10 Reproducibility

Every figure and number recomputes from data/pc/pc_features.csv, data/pc/pc_rca.parquet, and data/green_dict/green_dictionary.csv. Re-render to audit a future model build; the correlations, flip rates, and value-chain-share figures update automatically. The model design (targets, exclusions, one-year lag, SHAP aggregation) was verified against Ishana Ratan’s source notebooks under ~/Documents/R/CICE/ML_vars/ (clone of github.com/ishanaratan/CICE) — re-check those if the upstream pipeline changes.

Where the raw SHAP lives (for recommendation 1). The CICE repo does not persist raw SHAP anywhere on disk. In each ML_vars/Analysis/RCA <Tech> Analysis.ipynb the fully raw, signed, per-(country, year, feature) matrix is built in-memory as the DataFrame shap_df (right after explainer.shap_values(X) selects the class-1 slice), but it is never written out — the only saved SHAP artifact is the processed threshold summary (|z|-averaged, ≥0.5), and that is exported to /Users/ishanaratan/Desktop/<tech>_hs_shap_above_threshold.csv, a path outside the clone. Everything upstream of SHAP is in the repo (the per-tech input RCA CSVs, the in-cell RF training, the X construction), so the magnitude-preserving/signed axis is recoverable by re-running: execute a notebook and dump shap_df (or the raw mean|SHAP|) before the z-score cell. Caveat: confirm the RF training is seeded/deterministic and the inputs match the shipped build, so the regenerated ranking reproduces the shipped shap_mean_z before trusting it. (EV and E-waste compute shap_df in-memory but export nothing at all.)

This has now been done. scripts/ml/regen_shap.py regenerates the raw signed SHAP for all 11 techs from the CICE clone (validated bit-exact for Solar against pc_features, and 1:1 on the retained HS set for every tech); the artifacts live in analysis/ml/ (per-tech raw_signed/ matrices + combined tables). The companion ml_replication.qmd reports the EDA and confirms, from source, every claim this audit makes about the need axis — that it is count-driven, unsigned, and country-averaged.