What this is. A from-source replication of the Atlas SHAP “need” axis for all 11 shipped technologies, produced by scripts/ml/regen_shap.py off Ishana Ratan’s notebooks (~/Documents/R/CICE), with every hardcoded path repointed and the Random Forests pinned to the notebooks’ own hyperparameters and seed (random_state=28). For each tech the script re-fits the RF, computes SHAP, and writes both the raw signed per-country-year matrix (never persisted upstream) and a faithful reconstruction of the shipped absolute-valued, year-z-scored, 0.5-thresholded shap_mean_z. This note validates the reconstruction against the shipped data/pc/pc_features.csv, then uses the raw SHAP to show what the shipped axis discards: sign, magnitude, and country-year variation. Companion to capability_model_audit.qmd.
1 Did the reproduction work?
Before any critique lands, the reproduction has to earn trust: if our re-fit Random Forest doesn’t reproduce the shipped importances, nothing downstream is credible. The table below is that check, one row per technology. N countries / N predictors size each model’s panel. Class bal. % is the share of country-years that are actually competitive (RCA>1) — around 5%, which tells you the model is learning a rare event, a fact that matters greatly for the sign analysis later. Test F1 / AUC are out-of-sample fit on the held-out 25% split. HS matched (ours/shipped) is how many of our retained HS-code features coincide with the shipped pc_features; max / mean |diff| is how far our reconstructed shap_mean_z lands from the shipped number on those matched codes — zero means bit-for-bit identical.
Code
diag |>left_join(val, by="tech") |>transmute(Tech =factor(tech, TECHS), `N countries`=n_countries, `N predictors`=n_predictors,`Class bal. %`=class_balance_pct, `Test F1`=test_f1, `Test AUC`=test_auc,`HS matched (ours/shipped)`=paste0(n_matched,"/",n_shipped),`max |diff|`=max_abs_diff, `mean |diff|`=mean_abs_diff) |>arrange(Tech) |>kable(caption ="Reproduction fidelity. Every tech's retained HS-code SET matches the shipped set 1:1. Solar is bit-exact (max|diff|=0); the others drift modestly — their <tech>_trade.csv inputs were refreshed ('updated to 2024 models') after the shipped pc_features was built. F1/AUC all sit inside the appendix's stated ranges (F1 0.65-0.89, AUC 0.92-0.98).")
Reproduction fidelity. Every tech’s retained HS-code SET matches the shipped set 1:1. Solar is bit-exact (max|diff|=0); the others drift modestly — their _trade.csv inputs were refreshed (‘updated to 2024 models’) after the shipped pc_features was built. F1/AUC all sit inside the appendix’s stated ranges (F1 0.65-0.89, AUC 0.92-0.98).
Tech
N countries
N predictors
Class bal. %
Test F1
Test AUC
HS matched (ours/shipped)
max |diff|
mean |diff|
Solar
155
80
5.84
0.7816
0.9674
34/34
0.000000
0.000000
Wind
155
136
4.81
0.7037
0.9689
50/50
0.113412
0.037384
Batteries
155
93
5.01
0.6857
0.9305
31/31
0.133883
0.031845
EVs
158
197
6.88
0.6667
0.9737
53/53
0.283339
0.086272
Nuclear
155
51
6.25
0.7216
0.9695
22/22
0.154080
0.035296
Geothermal
155
102
13.17
0.8357
0.9720
54/54
0.226161
0.056213
Heat Pumps
155
34
13.96
0.8372
0.9634
26/26
0.122548
0.039322
Electrolyzers
155
76
8.01
0.5524
0.9382
42/42
0.191601
0.037242
Transmission
155
34
20.67
0.8089
0.9519
24/24
0.097247
0.014523
Magnets
155
24
5.16
0.8000
0.9729
8/8
0.083164
0.032510
Biofuel
155
86
21.23
0.7524
0.9343
66/66
0.188475
0.045374
Read: the selection step reproduces exactly everywhere — the same features clear the Cohen’s-d bar for every technology — so the pipeline is faithfully replicated. Solar reproduces the shipped shap_mean_z to six decimals, confirming the shipped artifact came from this exact code path. The small magnitude drift on the other ten is a data-vintage effect, not a method discrepancy; the directional and distributional findings below come from our freshly computed SHAP and don’t depend on it.
2 The two sets of SHAP weights
This table is the conceptual spine of the whole note, so it is worth reading slowly. There are two distinct objects. The raw SHAP (right column) is what the model actually produces: for every country, every year, and every feature, a signed number saying how much that capability pushed that country’s predicted competitiveness up or down. The shipped shap_mean_z (left column) is what the Atlas radar is built from — and it is not the raw SHAP, it is the raw SHAP after three deliberate reductions: take the absolute value (drop the sign), z-score within each year and average (drop the magnitude), and average over all country-years (drop the country dimension). Each row below is one of those reductions. The argument of this note is simply that all three discard information a “what to build” reading needs.
Shipped shap_mean_z (the radar axis)
Raw SHAP (what we regenerated)
Granularity
one number per feature
one signed value per country × year × feature
Sign
absolute-valued — unsigned
signed (+ toward competitive / − away)
Magnitude
z-scored per year → magnitude divided out
raw contribution retained
Country dimension
averaged over all country-years
preserved
Selection
thresholded at mean|z| ≥ 0.5
(same features, for comparability)
Three things are stripped between the two columns — sign, magnitude, and the country dimension. The rest of this note quantifies each.
3 EDA 1 — the two importances decouple
If the z-scored axis tracked genuine magnitude, the shipped mean|z| and the raw mean|SHAP| would rank features similarly. They do not.
Code
cor_hs <- kept |>filter(category %in% CATS)rr <-round(cor(cor_hs$mean_abs_z, cor_hs$raw_mean_abs_shap, method="spearman"), 2)ggplot(cor_hs, aes(raw_mean_abs_shap, mean_abs_z, colour = category)) +geom_point(alpha = .6, size =1.6) +scale_x_log10() +scale_colour_manual(values = CAT_COL, name =NULL) +labs(x ="raw mean|SHAP| (magnitude, log scale)", y ="shipped mean|z| (radar axis)",title ="Shipped importance vs raw magnitude — nearly flat on the y-axis",subtitle =paste0("Retained HS features, all 11 techs. Spearman rho = ", rr,". The z-score compresses every retained feature into a narrow 0.5-0.85 band,")) +theme(legend.position ="bottom")
Reading the plot. Every dot is one retained HS feature, pooled across all 11 technologies. The horizontal axis (log scale) is its raw average importance — the size of its SHAP contribution — spanning more than an order of magnitude from left to right. The vertical axis is the shipped importance the radar actually uses. If the two measured the same thing, the cloud would climb from lower-left to upper-right; instead it is almost perfectly flat, and the Spearman rank correlation of just 0.48 confirms the two barely order the features the same way. In words: a feature the model leans on ten times harder than another can end up with the same radar value.
The shipped axis lives in a compressed 0.5–0.85 band regardless of raw magnitude — the Cohen’s-d cut plus z-scoring flatten features that differ by an order of magnitude in raw contribution onto nearly the same value. So when clusters are formed by summing these near-equal values, the cluster totals track feature counts, not importance.
4 EDA 2 — the “most needed” cluster flips for 9 of 11 techs
Because the shipped axis sums near-equal values, the biggest cluster wins; because raw magnitude varies, a different cluster often wins once magnitude is preserved.
Code
topcl <- catt |>group_by(tech) |>summarise(`Shipped top (sum of z)`= category[which.max(pct_shipped_sum_z)],`Magnitude top (raw mean)`= category[which.max(pct_raw_mean)], .groups="drop") |>mutate(Flip =ifelse(`Shipped top (sum of z)`!=`Magnitude top (raw mean)`, "⚠ flips",""),tech =factor(tech, TECHS)) |>arrange(tech)nfl <-sum(topcl$Flip=="⚠ flips")kable(topcl, caption =sprintf("Top 'need' cluster under the shipped sum-of-z axis vs a magnitude-preserving raw mean|SHAP|. The top priority flips for %d of 11 technologies. The shipped axis makes Machinery the top cluster for 8 of 11 (it holds the most product codes); magnitude spreads the winners across Electronics, Chemicals, Metals, and Industrial Materials.", nfl))
Top ‘need’ cluster under the shipped sum-of-z axis vs a magnitude-preserving raw mean|SHAP|. The top priority flips for 9 of 11 technologies. The shipped axis makes Machinery the top cluster for 8 of 11 (it holds the most product codes); magnitude spreads the winners across Electronics, Chemicals, Metals, and Industrial Materials.
tech
Shipped top (sum of z)
Magnitude top (raw mean)
Flip
Solar
Chemicals
Electronics
⚠ flips
Wind
Machinery
Chemicals
⚠ flips
Batteries
Machinery
Electronics
⚠ flips
EVs
Machinery
Industrial Materials
⚠ flips
Nuclear
Metals
Chemicals
⚠ flips
Geothermal
Machinery
Machinery
Heat Pumps
Machinery
Machinery
Electrolyzers
Machinery
Metals
⚠ flips
Transmission
Machinery
Electronics
⚠ flips
Magnets
Metals
Chemicals
⚠ flips
Biofuel
Machinery
Chemicals
⚠ flips
Code
hd <- catt |>select(tech, category, pct_shipped_sum_z, pct_raw_mean) |>pivot_longer(c(pct_shipped_sum_z, pct_raw_mean), names_to="axis", values_to="pct") |>mutate(axis =recode(axis, pct_shipped_sum_z="Shipped (sum of z)", pct_raw_mean="Magnitude (raw mean)"),tech =factor(tech, rev(TECHS)), category =factor(category, CATS))ggplot(hd, aes(category, tech, fill = pct)) +geom_tile(colour="white", linewidth=.6) +geom_text(aes(label =ifelse(is.na(pct)|pct<1,"",sprintf("%.0f",pct))), family="Archivo", size=2.7, colour="#0b1a12") +facet_wrap(~axis) +scale_fill_gradient(low="#f0f7f0", high=NZ_GREEN, na.value="grey93", name="% of need") +labs(x=NULL, y=NULL, title="Cluster need under the two aggregations",subtitle="Left: shipped sum-of-z lights up the Machinery column (count-driven). Right: raw magnitude redistributes.") +theme(axis.text.x =element_text(angle=20, hjust=1), legend.position="bottom")
Reading the table and the heatmap. The table pairs, for each technology, the single top-priority cluster under the shipped axis (summed z-scores) against the top cluster once raw magnitude is preserved; the flag marks disagreement, and they disagree for 9 of 11 technologies. The heatmap shows the full field behind that headline: rows are technologies, the five columns are the capability clusters, and each cell is that cluster’s share of the technology’s total “need.” On the left (shipped) the Machinery column is uniformly bright — Machinery simply contains the most retained product codes, and because summing near-equal z-scores rewards whichever cluster has the most of them, Machinery wins almost everywhere. On the right (raw magnitude) the brightness redistributes toward whichever cluster actually carries the heaviest SHAP contributions, which is frequently a different one. The implication is pointed: the cluster the Atlas presents as a technology’s “most needed” capability is, for most technologies, an artifact of how many products happen to fall in that bucket — and once you correct for it, the headline priority flips.
5 EDA 3 — which weights are negative? (the sign the axis discards)
The shipped axis is |SHAP|, so it cannot tell whether a capability pushes a country toward or away from predicted competitiveness. The raw SHAP is signed, so we can ask directly.
Code
neg <- kept |>summarise(`retained features`=n(),`net-negative (mean SHAP < 0)`=sum(mean_shap_signed <0),`% net-negative`=round(mean(mean_shap_signed <0)*100, 1),`median % of country-years negative`=round(median(pct_negative), 1))kable(neg, caption ="Sign of the retained features. Nearly half carry a net-negative mean SHAP, and the median retained feature is negative in ~73% of country-years — all invisible under |SHAP|.")
Sign of the retained features. Nearly half carry a net-negative mean SHAP, and the median retained feature is negative in ~73% of country-years — all invisible under |SHAP|.
retained features
net-negative (mean SHAP < 0)
% net-negative
median % of country-years negative
448
209
46.7
73.3
This small table is the headline of the section: of the features the radar keeps, it counts how many have a net-negative average SHAP, and for the typical feature, in what share of country-years its contribution is negative. Both numbers are large — and both are erased by the |SHAP| step before anyone sees the radar.
Code
p1 <-ggplot(kept, aes(pct_negative, fill = mean_shap_signed <0)) +geom_histogram(bins =30, colour="white", linewidth=.2) +geom_vline(xintercept =50, linetype="22", colour="#334155") +scale_fill_manual(values =c(`TRUE`="#ef4444", `FALSE`="#3cb54a"),labels =c("net positive","net negative"), name=NULL) +labs(x="% of country-years with negative SHAP", y="retained features",title="Direction is pervasive, not incidental",subtitle="Most retained features are negative in the majority of country-years") +theme(legend.position ="bottom")p2 <-ggplot(kept, aes(x =fct_reorder(category, pct_negative), y = pct_negative, fill = category)) +geom_boxplot(alpha=.75, outlier.size=.6) +coord_flip() +scale_fill_manual(values = CAT_COL, guide="none") +labs(x=NULL, y="% country-years negative", title="…across every cluster", subtitle=" ")p1 | p2
Reading the panels. In the left panel each retained feature contributes one observation; the horizontal axis is the share of its country-years in which its SHAP is negative — i.e. in which having that capability was associated with lower predicted competitiveness — and the dashed line marks 50%. Most of the mass sits to the right of that line, meaning most features are negative more often than not; the bar colour flags whether the feature’s overall average is net-negative. The right panel breaks the same quantity out by cluster, showing the negativity is spread across all five capability groups rather than hiding in one. The takeaway is that direction is not a fringe phenomenon in a few odd features — it is the normal state of most of the axis.
Code
kept |>arrange(mean_shap_signed) |>slice_head(n =10) |>transmute(Tech = tech, Feature = feature, Cluster = category,`Mean SHAP (signed)`=round(mean_shap_signed, 4),`% country-yrs negative`=round(pct_negative, 0),`Shipped mean|z|`=round(mean_abs_z, 2)) |>kable(caption ="The ten most net-negative retained features. Each ships as a positive-magnitude 'need'; the sign that says the model reads them as pushing AWAY from competitiveness (on net) is discarded.")
The ten most net-negative retained features. Each ships as a positive-magnitude ‘need’; the sign that says the model reads them as pushing AWAY from competitiveness (on net) is discarded.
Tech
Feature
Cluster
Mean SHAP (signed)
% country-yrs negative
Shipped mean|z|
Transmission
t_854690
Electronics
-0.0031
71
0.73
Biofuel
nb_940390
Machinery
-0.0023
64
0.80
Biofuel
nb_844190
Machinery
-0.0022
63
0.79
Biofuel
b_381590
Chemicals
-0.0021
55
0.85
Biofuel
b_150790
Other
-0.0018
78
0.60
Biofuel
b_281121
Chemicals
-0.0016
74
0.72
Transmission
t_850490
Electronics
-0.0016
71
0.82
Geothermal
ng_340319
Chemicals
-0.0013
82
0.59
Electrolyzers
ne_390710
Chemicals
-0.0012
72
0.71
Nuclear
n_284410
Chemicals
-0.0011
88
0.51
Each row here is a concrete example of the reversal: a feature whose average signed contribution is among the most negative of all — the model, on net, reads it as pulling countries away from competitiveness — yet it enters the radar as a positive-magnitude “need,” because |SHAP| keeps only the size of the contribution and drops the minus sign. The last column shows the magnitude these features carry onto the radar despite their negative direction.
How to read the sign. A negative SHAP for a country-year means that country’s value on the feature pushed its predicted competitiveness down. That ~47% of retained features are net-negative — and the median is negative in ~73% of country-years — is partly structural: RCA>1 is rare (~5% base rate), so for the large non-competitive majority most inputs read as “not enough,” i.e. negative. That is exactly why the sign matters: a magnitude-only axis presents “capability the model finds predictive” without saying whether, for a given country, having more of it is associated with closing the gap or not. For a “what to build” reading, that direction is first-order — and it is thrown away.
6 EDA 4 — the mean collapses country-year variation
Each mean|SHAP| (and hence mean|z|) averages over ~3,199 country-years. How much variation does that single number hide?
Code
s <- disp |>summarise(`median CV`=round(median(cv),2),`median max/mean`=round(median(max_over_mean),1),`median top-2% share`=round(median(top2pct_share)*100,0),`median % below ½ mean`=round(median(frac_below_half_mean)*100,0))p1 <-ggplot(disp, aes(cv)) +geom_histogram(bins=30, fill=NZ_GREEN, colour="white", linewidth=.2) +geom_vline(xintercept=1, linetype="22", colour="#334155") +annotate("text", x=1.02, y=Inf, vjust=1.5, hjust=0, label="CV = 1\n(spread = mean)", family="Archivo", size=2.6, colour="#334155") +labs(x="CV of |SHAP| across country-years", y="retained features",title="Importance is not a stable per-feature number",subtitle=paste0("Median CV = ", s$`median CV`, " — the spread across countries exceeds the mean"))p2 <-ggplot(disp, aes(max_over_mean)) +geom_histogram(bins=30, fill="#0d9488", colour="white", linewidth=.2) +scale_x_log10() +labs(x="(max country-year |SHAP|) / (mean) — log scale", y="retained features",title="…and it peaks far above its own mean",subtitle=paste0("Median peak = ", s$`median max/mean`, "× the mean the pipeline reports"))p1 | p2
Reading the two histograms. Each retained feature contributes one observation to each panel. On the left is the coefficient of variation (CV) of a feature’s |SHAP| across all its country-years — its spread divided by its own mean. A CV above 1 (dashed line) means the feature varies more across countries than its average value is large, and the bulk of features sit there. On the right is how many times bigger the single most-important country-year is than the mean the pipeline reports; a median of roughly 17× says the reported number badly understates the feature’s importance for the country where it matters most. Both panels make the same point from two angles: the one averaged number the pipeline keeps is not a stable property of the feature — it is a smear over wildly unequal country-years.
Code
kable(s, caption ="The single mean|SHAP| collapses a heavy-tailed country-year distribution: spread larger than the center (CV~1.5), a peak ~17x the mean, ~17% of the mass in the top 2% of country-years, and ~40% of country-years below half the mean.")
The single mean|SHAP| collapses a heavy-tailed country-year distribution: spread larger than the center (CV~1.5), a peak ~17x the mean, ~17% of the mass in the top 2% of country-years, and ~40% of country-years below half the mean.
median CV
median max/mean
median top-2% share
median % below ½ mean
1.46
17.3
17
41
The distribution is heavy-tailed and country-specific. Concretely, for the top retained features of three technologies, the per-country-year |SHAP| fans out across two orders of magnitude while the pipeline reports only the diamond:
Code
exd <- ex |>mutate(lab =paste0(str_trunc(feature, 12), "\n", category))ggplot(exd, aes(x =reorder(lab, abs_shap, FUN=median), y = abs_shap +1e-6)) +geom_jitter(aes(colour = category), width=.18, alpha=.18, size=.5) +stat_summary(fun = mean, geom ="point", shape=18, size=4, colour = NZ_DARK) +scale_y_log10() +coord_flip() +scale_colour_manual(values = CAT_COL, guide="none") +facet_wrap(~tech, scales="free_y") +labs(x=NULL, y="|SHAP| per country-year (log) · diamond = the mean the axis keeps",title="What the mean throws away",subtitle="Each point is one country-year; the axis reports only the diamond. Importance is concentrated in a few countries.")
Implication. The radar’s “need” is a portfolio average that fits almost no individual country — it over-credits the ~40% of countries for which a feature is near-inert and under-credits ~17× the country for which it is decisive. Since a country’s gap analysis is inherently about that country, the need axis would be far more informative computed per country from its own SHAP row than from the pooled mean. (The concentration itself is expected — RCA>1 is rare so the model leans on the few threshold countries — which is the point: the country dimension carries the signal, and averaging discards it.)
7 EDA 5 — count-driven, confirmed from source
Closing the loop on the audit’s central claim, now on regenerated data: the shipped cluster axis is almost perfectly the product count.
Code
cc <- catt |>group_by(tech) |>mutate(count_share = n_features/sum(n_features, na.rm=TRUE)*100) |>ungroup() |>filter(!is.na(pct_shipped_sum_z))rc <-round(cor(cc$pct_shipped_sum_z, cc$count_share), 3)ggplot(cc, aes(count_share, pct_shipped_sum_z, colour = category)) +geom_abline(slope=1, intercept=0, linetype="22", colour="grey70") +geom_point(alpha=.8, size=2) +scale_colour_manual(values = CAT_COL, name=NULL) +labs(x="cluster's share of retained product COUNT (%)", y="shipped need share (sum of z, %)",title=paste0("The shipped need axis is the product count (r = ", rc, ")"),subtitle="Every point is a tech × cluster; the dashed line is exact equality") +theme(legend.position="bottom")
Reading the plot. Each point is one technology × cluster. The horizontal axis is that cluster’s share of the retained product count — literally the fraction of the technology’s kept HS codes that fall in it — and the vertical axis is its share of the shipped “need.” The dashed line is exact equality. The points sit almost on it (r = 0.999), which means the need a cluster is assigned is nearly a deterministic function of how many products it contains. This is the mechanical endpoint of EDA 1: once the year-z-score flattens every retained feature to nearly the same value, summing those values within a cluster is arithmetically almost the same as counting them. Implication: the shipped axis is, to a very good approximation, answering “which cluster holds the most HS codes” — a question about the composition of the green dictionary, not about which capability a technology most needs. That gap between the two questions is the entire reason this note exists.
8 Assessment — how the two weight sets differ
The reproduction settles the factual questions and sharpens the audit:
Selection is faithful; magnitudes are vintage-sensitive. The retained feature set reproduces 1:1 for all 11 techs (Solar bit-exact), so the model and its Cohen’s-d selection are exactly as documented. Only the precise z-scored magnitudes drift on the ten techs whose trade inputs were refreshed after shipping — a reason to rebuild pc_features from the current inputs, not a method flaw.
The shipped axis and a magnitude axis disagree about priorities. Shipped mean|z| decouples from raw mean|SHAP| (low rank correlation), so the sum-by-cluster axis tracks product counts (r ≈ 0.999) and the top-priority cluster flips for 9 of 11 technologies once magnitude is preserved. The shipped axis systematically favours Machinery, the largest-count cluster.
Sign is discarded. ~47% of retained features carry a net-negative mean SHAP (median feature negative in ~73% of country-years). The |SHAP| step erases whether a capability, for a given country, is associated with closing the competitiveness gap — first-order information for a “what to build” reading.
Country-year variation is collapsed. The mean hides a heavy-tailed, country-specific distribution (CV ≈ 1.5; peak ≈ 17× the mean; ~40% of country-years below half the mean). The country-invariant “standard profile” fits few actual countries.
Bottom line. The shipped shap_mean_z is a faithful feature-selection summary — but as a quantitative, country-facing need axis it has been stripped of the three things that reading needs: magnitude (→ it counts products), sign (→ it can’t say help vs. hurt), and the country dimension (→ it fits no one country). All three are recoverable from the raw signed SHAP regenerated here (analysis/ml/raw_signed/), which is exactly what a magnitude-preserving, signed, per-country need axis would be built from.
Constructive follow-on → target_profile.qmd. This note is diagnostic; the companion target_profile.qmd builds the fix. It shows (a) a common “standard profile” is defensible — countries rank-agree on the shape of each tech’s needs (median ρ ≈ 0.89), so EDA 4’s dispersion is a scale fact for the gap layer, not a refutation of the target; and (b) the negative signs above are a base-rate artifact, not “avoid” — split by the RCA>1 class, zero of the retained radar features point away among competitive producers. It then rebuilds the axis from the winners’ signed SHAP (magnitude- and direction-preserving), which moves the top-priority cluster for 5 of 11 techs.
9 Reproducibility
scripts/ml/regen_shap.py --tech <Tech> regenerates every artifact under analysis/ml/ from the CICE clone (~/Documents/R/CICE); requirements.txt pins the environment. The per-tech config (input file, target dv_*, excluded_cols, pinned n_estimators/max_depth) is transcribed verbatim from the notebooks; seed 28 is used throughout. Re-run against a refreshed clone to update the fidelity check.
Source Code
---title: "Replicating the SHAP Need-Axis from Source"subtitle: "An 11-technology reproduction of the shipped importances, and an EDA of what the pipeline discards — sign, magnitude, and country-year variation"author: "Net Zero Industrial Policy Lab (NZIPL) · Johns Hopkins SAIS"date: todayformat: html: toc: true toc-depth: 3 toc-location: left toc-title: "Contents" number-sections: true self-contained: true page-layout: full fig-width: 10 fig-height: 5.5 code-fold: true code-tools: true theme: [cosmo, ../nzipl.scss] grid: sidebar-width: 270px body-width: 980px docx: toc: true number-sections: trueexecute-dir: projectexecute: echo: true warning: false message: false---```{r setup}#| code-fold: truelibrary(readr); library(dplyr); library(tidyr); library(stringr); library(forcats)library(ggplot2); library(patchwork); library(knitr); library(showtext)font_add_google("Archivo", "Archivo"); showtext_auto(); showtext_opts(dpi =200)NZ_DARK <-"#073309"; NZ_GREEN <-"#3cb54a"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", Other="#6b7280", "Non-HS"="#94a3b8")CATS <-c("Chemicals","Electronics","Industrial Materials","Machinery","Metals")TECHS <-c("Solar","Wind","Batteries","EVs","Nuclear","Geothermal","Heat Pumps","Electrolyzers","Transmission","Magnets","Biofuel")ml <- here::here("analysis","ml")feat <-read_csv(file.path(ml,"shap_replication_features.csv"), show_col_types =FALSE) |>mutate(retained =as.logical(retained), tech =factor(tech, TECHS))catt <-read_csv(file.path(ml,"shap_replication_by_category.csv"), show_col_types =FALSE)diag <-read_csv(file.path(ml,"shap_replication_diagnostics.csv"), show_col_types =FALSE)val <-read_csv(file.path(ml,"shap_replication_validation.csv"), show_col_types =FALSE)disp <-read_csv(file.path(ml,"shap_replication_cy_dispersion.csv"), show_col_types =FALSE)ex <-read_csv(file.path(ml,"shap_replication_cy_examples.csv"), show_col_types =FALSE)kept <- feat |>filter(retained)```::: {.callout-note appearance="minimal"}**What this is.** A from-source replication of the Atlas SHAP "need" axis for **all 11 shipped technologies**, produced by `scripts/ml/regen_shap.py` off Ishana Ratan's notebooks (`~/Documents/R/CICE`), with every hardcoded path repointed and the Random Forests pinned to the notebooks' own hyperparameters and seed (`random_state=28`). For each tech the script re-fits the RF, computes SHAP, and writes **both** the raw signed per-country-year matrix (never persisted upstream) **and** a faithful reconstruction of the shipped absolute-valued, year-z-scored, 0.5-thresholded `shap_mean_z`. This note validates the reconstruction against the shipped `data/pc/pc_features.csv`, then uses the raw SHAP to show what the shipped axis discards: **sign, magnitude, and country-year variation.** Companion to `capability_model_audit.qmd`.:::# Did the reproduction work?Before any critique lands, the reproduction has to earn trust: if our re-fit Random Forest doesn't reproduce the shipped importances, nothing downstream is credible. The table below is that check, one row per technology. **N countries / N predictors** size each model's panel. **Class bal. %** is the share of country-years that are actually competitive (`RCA>1`) — around 5%, which tells you the model is learning a *rare* event, a fact that matters greatly for the sign analysis later. **Test F1 / AUC** are out-of-sample fit on the held-out 25% split. **HS matched (ours/shipped)** is how many of our retained HS-code features coincide with the shipped `pc_features`; **max / mean |diff|** is how far our reconstructed `shap_mean_z` lands from the shipped number on those matched codes — zero means bit-for-bit identical.```{r fidelity}#| code-fold: truediag |>left_join(val, by="tech") |>transmute(Tech =factor(tech, TECHS), `N countries`=n_countries, `N predictors`=n_predictors,`Class bal. %`=class_balance_pct, `Test F1`=test_f1, `Test AUC`=test_auc,`HS matched (ours/shipped)`=paste0(n_matched,"/",n_shipped),`max |diff|`=max_abs_diff, `mean |diff|`=mean_abs_diff) |>arrange(Tech) |>kable(caption ="Reproduction fidelity. Every tech's retained HS-code SET matches the shipped set 1:1. Solar is bit-exact (max|diff|=0); the others drift modestly — their <tech>_trade.csv inputs were refreshed ('updated to 2024 models') after the shipped pc_features was built. F1/AUC all sit inside the appendix's stated ranges (F1 0.65-0.89, AUC 0.92-0.98).")```**Read:** the selection step reproduces **exactly everywhere** — the same features clear the Cohen's-*d* bar for every technology — so the pipeline is faithfully replicated. Solar reproduces the shipped `shap_mean_z` to six decimals, confirming the shipped artifact came from this exact code path. The small magnitude drift on the other ten is a data-vintage effect, not a method discrepancy; the *directional* and *distributional* findings below come from our freshly computed SHAP and don't depend on it.# The two sets of SHAP weightsThis table is the conceptual spine of the whole note, so it is worth reading slowly. There are two distinct objects. The **raw SHAP** (right column) is what the model actually produces: for every country, every year, and every feature, a *signed* number saying how much that capability pushed that country's predicted competitiveness up or down. The **shipped `shap_mean_z`** (left column) is what the Atlas radar is built from — and it is *not* the raw SHAP, it is the raw SHAP after three deliberate reductions: take the absolute value (drop the sign), z-score within each year and average (drop the magnitude), and average over all country-years (drop the country dimension). Each row below is one of those reductions. The argument of this note is simply that all three discard information a "what to build" reading needs.|| **Shipped `shap_mean_z`** (the radar axis) | **Raw SHAP** (what we regenerated) ||---|---|---|| Granularity | one number per feature | one signed value per **country × year × feature** || Sign | absolute-valued — **unsigned** | **signed** (+ toward competitive / − away) || Magnitude | z-scored per year → magnitude divided out | raw contribution retained || Country dimension | averaged over all country-years | **preserved** || Selection | thresholded at `mean|z| ≥ 0.5`| (same features, for comparability) |Three things are stripped between the two columns — **sign, magnitude, and the country dimension.** The rest of this note quantifies each.# EDA 1 — the two importances decoupleIf the z-scored axis tracked genuine magnitude, the shipped `mean|z|` and the raw `mean|SHAP|` would rank features similarly. They do not.```{r decouple, fig.height=4.2}#| code-fold: truecor_hs <- kept |>filter(category %in% CATS)rr <-round(cor(cor_hs$mean_abs_z, cor_hs$raw_mean_abs_shap, method="spearman"), 2)ggplot(cor_hs, aes(raw_mean_abs_shap, mean_abs_z, colour = category)) +geom_point(alpha = .6, size =1.6) +scale_x_log10() +scale_colour_manual(values = CAT_COL, name =NULL) +labs(x ="raw mean|SHAP| (magnitude, log scale)", y ="shipped mean|z| (radar axis)",title ="Shipped importance vs raw magnitude — nearly flat on the y-axis",subtitle =paste0("Retained HS features, all 11 techs. Spearman rho = ", rr,". The z-score compresses every retained feature into a narrow 0.5-0.85 band,")) +theme(legend.position ="bottom")```**Reading the plot.** Every dot is one retained HS feature, pooled across all 11 technologies. The horizontal axis (log scale) is its *raw* average importance — the size of its SHAP contribution — spanning more than an order of magnitude from left to right. The vertical axis is the *shipped* importance the radar actually uses. If the two measured the same thing, the cloud would climb from lower-left to upper-right; instead it is almost perfectly flat, and the Spearman rank correlation of just `r rr` confirms the two barely order the features the same way. In words: a feature the model leans on ten times harder than another can end up with the *same* radar value.The shipped axis lives in a compressed **0.5–0.85** band regardless of raw magnitude — the Cohen's-*d* cut plus z-scoring flatten features that differ by an order of magnitude in raw contribution onto nearly the same value. So when clusters are formed by **summing** these near-equal values, the cluster totals track **feature counts**, not importance.# EDA 2 — the "most needed" cluster flips for 9 of 11 techsBecause the shipped axis sums near-equal values, the biggest cluster wins; because raw magnitude varies, a different cluster often wins once magnitude is preserved.```{r flip-table}#| code-fold: truetopcl <- catt |>group_by(tech) |>summarise(`Shipped top (sum of z)`= category[which.max(pct_shipped_sum_z)],`Magnitude top (raw mean)`= category[which.max(pct_raw_mean)], .groups="drop") |>mutate(Flip =ifelse(`Shipped top (sum of z)`!=`Magnitude top (raw mean)`, "⚠ flips",""),tech =factor(tech, TECHS)) |>arrange(tech)nfl <-sum(topcl$Flip=="⚠ flips")kable(topcl, caption =sprintf("Top 'need' cluster under the shipped sum-of-z axis vs a magnitude-preserving raw mean|SHAP|. The top priority flips for %d of 11 technologies. The shipped axis makes Machinery the top cluster for 8 of 11 (it holds the most product codes); magnitude spreads the winners across Electronics, Chemicals, Metals, and Industrial Materials.", nfl))``````{r flip-heat, fig.height=5.2}#| code-fold: truehd <- catt |>select(tech, category, pct_shipped_sum_z, pct_raw_mean) |>pivot_longer(c(pct_shipped_sum_z, pct_raw_mean), names_to="axis", values_to="pct") |>mutate(axis =recode(axis, pct_shipped_sum_z="Shipped (sum of z)", pct_raw_mean="Magnitude (raw mean)"),tech =factor(tech, rev(TECHS)), category =factor(category, CATS))ggplot(hd, aes(category, tech, fill = pct)) +geom_tile(colour="white", linewidth=.6) +geom_text(aes(label =ifelse(is.na(pct)|pct<1,"",sprintf("%.0f",pct))), family="Archivo", size=2.7, colour="#0b1a12") +facet_wrap(~axis) +scale_fill_gradient(low="#f0f7f0", high=NZ_GREEN, na.value="grey93", name="% of need") +labs(x=NULL, y=NULL, title="Cluster need under the two aggregations",subtitle="Left: shipped sum-of-z lights up the Machinery column (count-driven). Right: raw magnitude redistributes.") +theme(axis.text.x =element_text(angle=20, hjust=1), legend.position="bottom")```**Reading the table and the heatmap.** The table pairs, for each technology, the single top-priority cluster under the shipped axis (summed z-scores) against the top cluster once raw magnitude is preserved; the flag marks disagreement, and they disagree for **`r nfl` of 11** technologies. The heatmap shows the full field behind that headline: rows are technologies, the five columns are the capability clusters, and each cell is that cluster's share of the technology's total "need." On the **left (shipped)** the Machinery column is uniformly bright — Machinery simply contains the most retained product codes, and because summing near-equal z-scores rewards whichever cluster has the most of them, Machinery wins almost everywhere. On the **right (raw magnitude)** the brightness redistributes toward whichever cluster actually carries the heaviest SHAP contributions, which is frequently a different one. **The implication is pointed:** the cluster the Atlas presents as a technology's "most needed" capability is, for most technologies, an artifact of *how many products happen to fall in that bucket* — and once you correct for it, the headline priority flips.# EDA 3 — which weights are negative? (the sign the axis discards)The shipped axis is `|SHAP|`, so it cannot tell whether a capability pushes a country *toward* or *away* from predicted competitiveness. The raw SHAP is signed, so we can ask directly.```{r sign-overview}#| code-fold: trueneg <- kept |>summarise(`retained features`=n(),`net-negative (mean SHAP < 0)`=sum(mean_shap_signed <0),`% net-negative`=round(mean(mean_shap_signed <0)*100, 1),`median % of country-years negative`=round(median(pct_negative), 1))kable(neg, caption ="Sign of the retained features. Nearly half carry a net-negative mean SHAP, and the median retained feature is negative in ~73% of country-years — all invisible under |SHAP|.")```This small table is the headline of the section: of the features the radar keeps, it counts how many have a *net-negative* average SHAP, and for the typical feature, in what share of country-years its contribution is negative. Both numbers are large — and both are erased by the `|SHAP|` step before anyone sees the radar.```{r sign-dist, fig.height=4}#| code-fold: truep1 <-ggplot(kept, aes(pct_negative, fill = mean_shap_signed <0)) +geom_histogram(bins =30, colour="white", linewidth=.2) +geom_vline(xintercept =50, linetype="22", colour="#334155") +scale_fill_manual(values =c(`TRUE`="#ef4444", `FALSE`="#3cb54a"),labels =c("net positive","net negative"), name=NULL) +labs(x="% of country-years with negative SHAP", y="retained features",title="Direction is pervasive, not incidental",subtitle="Most retained features are negative in the majority of country-years") +theme(legend.position ="bottom")p2 <-ggplot(kept, aes(x =fct_reorder(category, pct_negative), y = pct_negative, fill = category)) +geom_boxplot(alpha=.75, outlier.size=.6) +coord_flip() +scale_fill_manual(values = CAT_COL, guide="none") +labs(x=NULL, y="% country-years negative", title="…across every cluster", subtitle=" ")p1 | p2```**Reading the panels.** In the left panel each retained feature contributes one observation; the horizontal axis is the share of its country-years in which its SHAP is negative — i.e. in which having that capability was associated with *lower* predicted competitiveness — and the dashed line marks 50%. Most of the mass sits to the *right* of that line, meaning most features are negative more often than not; the bar colour flags whether the feature's overall average is net-negative. The right panel breaks the same quantity out by cluster, showing the negativity is spread across all five capability groups rather than hiding in one. The takeaway is that direction is not a fringe phenomenon in a few odd features — it is the normal state of most of the axis.```{r top-neg}#| code-fold: truekept |>arrange(mean_shap_signed) |>slice_head(n =10) |>transmute(Tech = tech, Feature = feature, Cluster = category,`Mean SHAP (signed)`=round(mean_shap_signed, 4),`% country-yrs negative`=round(pct_negative, 0),`Shipped mean|z|`=round(mean_abs_z, 2)) |>kable(caption ="The ten most net-negative retained features. Each ships as a positive-magnitude 'need'; the sign that says the model reads them as pushing AWAY from competitiveness (on net) is discarded.")```Each row here is a concrete example of the reversal: a feature whose *average* signed contribution is among the most negative of all — the model, on net, reads it as pulling countries *away* from competitiveness — yet it enters the radar as a positive-magnitude "need," because `|SHAP|` keeps only the size of the contribution and drops the minus sign. The last column shows the magnitude these features carry onto the radar despite their negative direction.**How to read the sign.** A negative SHAP for a country-year means that country's value on the feature pushed its predicted competitiveness *down*. That ~47% of retained features are net-negative — and the median is negative in ~73% of country-years — is partly structural: `RCA>1` is rare (~5% base rate), so for the large non-competitive majority most inputs read as "not enough," i.e. negative. That is exactly why the sign matters: a magnitude-only axis presents "capability the model finds predictive" without saying whether, for a given country, having more of it is associated with *closing* the gap or not. For a "what to build" reading, that direction is first-order — and it is thrown away.# EDA 4 — the mean collapses country-year variationEach `mean|SHAP|` (and hence `mean|z|`) averages over ~`r format(round(mean(disp$n_cy)), big.mark=",")` country-years. How much variation does that single number hide?```{r cy-dispersion, fig.height=4}#| code-fold: trues <- disp |>summarise(`median CV`=round(median(cv),2),`median max/mean`=round(median(max_over_mean),1),`median top-2% share`=round(median(top2pct_share)*100,0),`median % below ½ mean`=round(median(frac_below_half_mean)*100,0))p1 <-ggplot(disp, aes(cv)) +geom_histogram(bins=30, fill=NZ_GREEN, colour="white", linewidth=.2) +geom_vline(xintercept=1, linetype="22", colour="#334155") +annotate("text", x=1.02, y=Inf, vjust=1.5, hjust=0, label="CV = 1\n(spread = mean)", family="Archivo", size=2.6, colour="#334155") +labs(x="CV of |SHAP| across country-years", y="retained features",title="Importance is not a stable per-feature number",subtitle=paste0("Median CV = ", s$`median CV`, " — the spread across countries exceeds the mean"))p2 <-ggplot(disp, aes(max_over_mean)) +geom_histogram(bins=30, fill="#0d9488", colour="white", linewidth=.2) +scale_x_log10() +labs(x="(max country-year |SHAP|) / (mean) — log scale", y="retained features",title="…and it peaks far above its own mean",subtitle=paste0("Median peak = ", s$`median max/mean`, "× the mean the pipeline reports"))p1 | p2```**Reading the two histograms.** Each retained feature contributes one observation to each panel. On the left is the coefficient of variation (CV) of a feature's `|SHAP|` across all its country-years — its spread divided by its own mean. A CV above 1 (dashed line) means the feature varies *more* across countries than its average value is large, and the bulk of features sit there. On the right is how many times bigger the single most-important country-year is than the mean the pipeline reports; a median of roughly 17× says the reported number badly *understates* the feature's importance for the country where it matters most. Both panels make the same point from two angles: the one averaged number the pipeline keeps is not a stable property of the feature — it is a smear over wildly unequal country-years.```{r cy-summary}#| code-fold: truekable(s, caption ="The single mean|SHAP| collapses a heavy-tailed country-year distribution: spread larger than the center (CV~1.5), a peak ~17x the mean, ~17% of the mass in the top 2% of country-years, and ~40% of country-years below half the mean.")```The distribution is heavy-tailed and country-specific. Concretely, for the top retained features of three technologies, the per-country-year `|SHAP|` fans out across two orders of magnitude while the pipeline reports only the diamond:```{r cy-violin, fig.height=5}#| code-fold: trueexd <- ex |>mutate(lab =paste0(str_trunc(feature, 12), "\n", category))ggplot(exd, aes(x =reorder(lab, abs_shap, FUN=median), y = abs_shap +1e-6)) +geom_jitter(aes(colour = category), width=.18, alpha=.18, size=.5) +stat_summary(fun = mean, geom ="point", shape=18, size=4, colour = NZ_DARK) +scale_y_log10() +coord_flip() +scale_colour_manual(values = CAT_COL, guide="none") +facet_wrap(~tech, scales="free_y") +labs(x=NULL, y="|SHAP| per country-year (log) · diamond = the mean the axis keeps",title="What the mean throws away",subtitle="Each point is one country-year; the axis reports only the diamond. Importance is concentrated in a few countries.")```**Implication.** The radar's "need" is a **portfolio average that fits almost no individual country** — it over-credits the ~40% of countries for which a feature is near-inert and under-credits ~17× the country for which it is decisive. Since a country's gap analysis is inherently about *that* country, the need axis would be far more informative computed **per country from its own SHAP row** than from the pooled mean. (The concentration itself is expected — `RCA>1` is rare so the model leans on the few threshold countries — which is the point: the country dimension carries the signal, and averaging discards it.)# EDA 5 — count-driven, confirmed from sourceClosing the loop on the audit's central claim, now on regenerated data: the shipped cluster axis is almost perfectly the product count.```{r count-conf, fig.height=3.6}#| code-fold: truecc <- catt |>group_by(tech) |>mutate(count_share = n_features/sum(n_features, na.rm=TRUE)*100) |>ungroup() |>filter(!is.na(pct_shipped_sum_z))rc <-round(cor(cc$pct_shipped_sum_z, cc$count_share), 3)ggplot(cc, aes(count_share, pct_shipped_sum_z, colour = category)) +geom_abline(slope=1, intercept=0, linetype="22", colour="grey70") +geom_point(alpha=.8, size=2) +scale_colour_manual(values = CAT_COL, name=NULL) +labs(x="cluster's share of retained product COUNT (%)", y="shipped need share (sum of z, %)",title=paste0("The shipped need axis is the product count (r = ", rc, ")"),subtitle="Every point is a tech × cluster; the dashed line is exact equality") +theme(legend.position="bottom")```**Reading the plot.** Each point is one technology × cluster. The horizontal axis is that cluster's share of the retained product *count* — literally the fraction of the technology's kept HS codes that fall in it — and the vertical axis is its share of the shipped "need." The dashed line is exact equality. The points sit almost on it (r = `r rc`), which means the need a cluster is assigned is nearly a deterministic function of how many products it contains. This is the mechanical endpoint of EDA 1: once the year-z-score flattens every retained feature to nearly the same value, summing those values within a cluster is arithmetically almost the same as *counting* them. **Implication:** the shipped axis is, to a very good approximation, answering "which cluster holds the most HS codes" — a question about the composition of the green dictionary, not about which capability a technology most needs. That gap between the two questions is the entire reason this note exists.# Assessment — how the two weight sets differThe reproduction settles the factual questions and sharpens the audit:1. **Selection is faithful; magnitudes are vintage-sensitive.** The retained feature *set* reproduces 1:1 for all 11 techs (Solar bit-exact), so the model and its Cohen's-*d* selection are exactly as documented. Only the precise z-scored magnitudes drift on the ten techs whose trade inputs were refreshed after shipping — a reason to rebuild `pc_features` from the current inputs, not a method flaw.2. **The shipped axis and a magnitude axis disagree about priorities.** Shipped `mean|z|` decouples from raw `mean|SHAP|` (low rank correlation), so the sum-by-cluster axis tracks **product counts** (r ≈ `r rc`) and the **top-priority cluster flips for 9 of 11 technologies** once magnitude is preserved. The shipped axis systematically favours **Machinery**, the largest-count cluster.3. **Sign is discarded.** ~47% of retained features carry a **net-negative** mean SHAP (median feature negative in ~73% of country-years). The `|SHAP|` step erases whether a capability, for a given country, is associated with closing the competitiveness gap — first-order information for a "what to build" reading.4. **Country-year variation is collapsed.** The mean hides a heavy-tailed, country-specific distribution (CV ≈ 1.5; peak ≈ 17× the mean; ~40% of country-years below half the mean). The country-invariant "standard profile" fits few actual countries.**Bottom line.** The shipped `shap_mean_z` is a faithful *feature-selection* summary — but as a *quantitative, country-facing need axis* it has been stripped of the three things that reading needs: **magnitude** (→ it counts products), **sign** (→ it can't say help vs. hurt), and the **country dimension** (→ it fits no one country). All three are recoverable from the raw signed SHAP regenerated here (`analysis/ml/raw_signed/`), which is exactly what a magnitude-preserving, signed, per-country need axis would be built from.::: {.callout-tip appearance="minimal"}**Constructive follow-on → `target_profile.qmd`.** This note is diagnostic; the companion `target_profile.qmd` builds the fix. It shows (a) a **common "standard profile" is defensible** — countries rank-agree on the *shape* of each tech's needs (median ρ ≈ 0.89), so EDA 4's dispersion is a *scale* fact for the gap layer, not a refutation of the target; and (b) the **negative signs above are a base-rate artifact, not "avoid"** — split by the RCA>1 class, *zero* of the retained radar features point away among competitive producers. It then rebuilds the axis from the winners' signed SHAP (magnitude- and direction-preserving), which moves the top-priority cluster for 5 of 11 techs.:::# Reproducibility`scripts/ml/regen_shap.py --tech <Tech>` regenerates every artifact under `analysis/ml/` from the CICE clone (`~/Documents/R/CICE`); `requirements.txt` pins the environment. The per-tech config (input file, target `dv_*`, `excluded_cols`, pinned `n_estimators`/`max_depth`) is transcribed verbatim from the notebooks; seed `28` is used throughout. Re-run against a refreshed clone to update the fidelity check.