Skip to content

Commit f94f6a4

Browse files
committed
Fix inequality residual semantics
1 parent a129a65 commit f94f6a4

File tree

7 files changed

+132
-45
lines changed

7 files changed

+132
-45
lines changed

web/src/charts.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ export function createScatterChart(canvas: HTMLCanvasElement, data: {
207207
}
208208

209209
export function createResidualHistogram(canvas: HTMLCanvasElement, residuals: number[]): Chart {
210+
return createResidualHistogramWithSemantics(canvas, residuals, true);
211+
}
212+
213+
export function createResidualHistogramWithSemantics(
214+
canvas: HTMLCanvasElement,
215+
residuals: number[],
216+
positiveIsGood: boolean,
217+
): Chart {
210218
const binCount = 20;
211219
const min = -0.5;
212220
const max = 0.5;
@@ -235,8 +243,12 @@ export function createResidualHistogram(canvas: HTMLCanvasElement, residuals: nu
235243
data: bins,
236244
backgroundColor: bins.map((_, i) => {
237245
const center = min + (i + 0.5) * binWidth;
238-
if (center >= 0) return "hsla(145, 55%, 42%, 0.7)";
239-
return "hsla(12, 65%, 55%, 0.7)";
246+
if (positiveIsGood) {
247+
if (center >= 0) return "hsla(145, 55%, 42%, 0.7)";
248+
return "hsla(12, 65%, 55%, 0.7)";
249+
}
250+
if (center >= 0) return "hsla(12, 65%, 55%, 0.7)";
251+
return "hsla(145, 55%, 42%, 0.7)";
240252
}),
241253
borderRadius: 3,
242254
},
@@ -284,7 +296,9 @@ export function createResidualHistogram(canvas: HTMLCanvasElement, residuals: nu
284296
export function createRegionalResidualChart(canvas: HTMLCanvasElement, data: {
285297
labels: string[];
286298
means: number[];
299+
positiveIsGood?: boolean;
287300
}): Chart {
301+
const positiveIsGood = data.positiveIsGood ?? true;
288302
return new Chart(canvas, {
289303
type: "bar",
290304
data: {
@@ -294,7 +308,9 @@ export function createRegionalResidualChart(canvas: HTMLCanvasElement, data: {
294308
label: "Mean residual",
295309
data: data.means,
296310
backgroundColor: data.means.map((v) =>
297-
v >= 0 ? "hsla(145, 55%, 42%, 0.75)" : "hsla(12, 65%, 55%, 0.75)",
311+
positiveIsGood
312+
? v >= 0 ? "hsla(145, 55%, 42%, 0.75)" : "hsla(12, 65%, 55%, 0.75)"
313+
: v >= 0 ? "hsla(12, 65%, 55%, 0.75)" : "hsla(145, 55%, 42%, 0.75)",
298314
),
299315
borderRadius: 6,
300316
},

web/src/main.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
createFeatureImportanceChart,
88
createModelComparisonChart,
99
createRegionalResidualChart,
10-
createResidualHistogram,
10+
createResidualHistogramWithSemantics,
1111
createScatterChart,
1212
} from "./charts";
1313
import type {
@@ -84,6 +84,10 @@ function tierLabel(tiers: Set<TierFlag>): string {
8484
return parts.length > 0 ? parts.join(" + ") : "None";
8585
}
8686

87+
function positiveResidualIsGood(target: TargetId): boolean {
88+
return target !== "inequality";
89+
}
90+
8791
async function bootstrap(): Promise<void> {
8892
const metadata = await loadMetadata();
8993
const geojson = await loadMapGeoJson(metadata.map_path);
@@ -256,7 +260,7 @@ async function bootstrap(): Promise<void> {
256260
decade: number,
257261
): MetricsPayload {
258262
return {
259-
metric: view,
263+
metric: view === "residual" ? `residual_${state.activeTarget}` : view,
260264
label:
261265
view === "actual" ? "Actual" : view === "predicted" ? "Predicted" : "Residual",
262266
description: "",
@@ -335,19 +339,21 @@ async function bootstrap(): Promise<void> {
335339
let overperformers: SpotlightCountry[] = [];
336340
let underperformers: SpotlightCountry[] = [];
337341
if (bundle) {
342+
const positiveIsGood = positiveResidualIsGood(state.activeTarget);
338343
const ranked = bundle.countries
339344
.filter((c) => c.target_value != null && c.prediction != null)
340345
.map((c) => ({
341346
iso3: c.iso3,
342347
name: c.country_name,
343348
residual: c.target_value! - c.prediction!,
344349
}))
345-
.sort((a, b) => b.residual - a.residual);
350+
.sort((a, b) => positiveIsGood ? b.residual - a.residual : a.residual - b.residual);
346351
overperformers = ranked.slice(0, 3);
347-
underperformers = ranked.slice(-3).sort((a, b) => a.residual - b.residual);
352+
underperformers = ranked.slice(-3).sort((a, b) => positiveIsGood ? a.residual - b.residual : b.residual - a.residual);
348353
}
349354

350355
tabContent = renderMapTab(metadata, activePayload, decade, mapProfile, state.activeMetricView, {
356+
target: state.activeTarget,
351357
targetLabel: TARGET_LABELS[state.activeTarget],
352358
tierLabel: tierLabel(state.activeTiers),
353359
r2,
@@ -698,7 +704,11 @@ async function bootstrap(): Promise<void> {
698704
// Residual histogram
699705
const histCanvas = document.querySelector<HTMLCanvasElement>("#residual-histogram");
700706
if (histCanvas) {
701-
createResidualHistogram(histCanvas, rows.map((r) => r.residual));
707+
createResidualHistogramWithSemantics(
708+
histCanvas,
709+
rows.map((r) => r.residual),
710+
positiveResidualIsGood(state.activeTarget),
711+
);
702712
}
703713

704714
// Regional residual chart
@@ -718,6 +728,7 @@ async function bootstrap(): Promise<void> {
718728
createRegionalResidualChart(regionCanvas, {
719729
labels: regionData.map((r) => r.label),
720730
means: regionData.map((r) => r.mean),
731+
positiveIsGood: positiveResidualIsGood(state.activeTarget),
721732
});
722733
}
723734
}

web/src/map.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ function colorForValue(metricId: string, value: number | null): string {
2020
if (value === null) {
2121
return NO_DATA_FILL;
2222
}
23-
if (metricId === "residual" || metricId === "residual_income_rank_pct") {
23+
if (metricId.startsWith("residual") || metricId === "residual_income_rank_pct") {
2424
const clipped = Math.max(-0.5, Math.min(0.5, value));
25-
if (clipped >= 0) {
26-
const lightness = 88 - (clipped / 0.5) * 36;
27-
return `hsl(145, 55%, ${lightness}%)`;
25+
const positiveIsGood = metricId !== "residual_inequality";
26+
if ((positiveIsGood && clipped >= 0) || (!positiveIsGood && clipped < 0)) {
27+
return `hsl(145, 55%, ${88 - (Math.abs(clipped) / 0.5) * 36}%)`;
2828
}
2929
const lightness = 88 - (Math.abs(clipped) / 0.5) * 36;
3030
return `hsl(12, 75%, ${lightness}%)`;
@@ -38,7 +38,7 @@ function formatMetricValue(metricId: string, value: number | null): string {
3838
if (value === null) {
3939
return "No data";
4040
}
41-
if (metricId === "residual" || metricId === "residual_income_rank_pct") {
41+
if (metricId.startsWith("residual") || metricId === "residual_income_rank_pct") {
4242
return value.toFixed(3);
4343
}
4444
return `${Math.round(value * 100)} pct`;

web/src/tab-about.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ export function renderAboutTab(metadata: MetadataPayload | null): string {
5858
</p>
5959
<p>
6060
The <strong>residual</strong> (actual rank minus predicted rank) reveals which countries
61-
outperform or underperform their endowments. This is explicitly about
61+
land above or below the model's expectation. For inequality, a positive residual means
62+
<em>more unequal than predicted</em>, while a negative residual means <em>less unequal than predicted</em>. This is explicitly about
6263
<em>predictive association</em>, not causality.
6364
</p>
6465
</div>
@@ -68,8 +69,9 @@ export function renderAboutTab(metadata: MetadataPayload | null): string {
6869
<h2>Interpretation and caveats</h2>
6970
<div class="about-content">
7071
<p>
71-
A positive residual means a country ranks higher than the model predicts given its features \u2014
72-
it does <em>not</em> mean the country is doing something "right." Geography is not destiny,
72+
For income, wealth, and life expectancy, a positive residual means a country ranks higher than the model predicts given its features.
73+
For inequality, the interpretation flips: a positive residual means the country is <em>more unequal</em> than predicted.
74+
It does <em>not</em> mean the country is doing something "right." Geography is not destiny,
7375
and many omitted variables (policy choices, historical accidents, cultural factors) drive outcomes.
7476
</p>
7577
<p>
@@ -115,7 +117,7 @@ export function renderAboutTab(metadata: MetadataPayload | null): string {
115117
</tr>
116118
<tr>
117119
<td>Inequality</td>
118-
<td>Disposable-income Gini coefficient, percentile rank (higher = more equal)</td>
120+
<td>Disposable-income Gini coefficient, percentile rank (higher = more unequal)</td>
119121
<td>Standardized World Income Inequality Database (SWIID)</td>
120122
</tr>
121123
</tbody>

web/src/tab-analytics.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,14 @@ type RankingsRow = {
4747
residual: number | null;
4848
};
4949

50+
function positiveResidualIsGood(target: string): boolean {
51+
return target !== "inequality";
52+
}
53+
5054
function buildRankingsRows(data: AnalyticsData): RankingsRow[] {
5155
const bundle = data.bundle;
5256
if (!bundle) return [];
57+
const positiveIsGood = positiveResidualIsGood(data.target);
5358

5459
return bundle.countries
5560
.map((c) => {
@@ -66,17 +71,28 @@ function buildRankingsRows(data: AnalyticsData): RankingsRow[] {
6671
residual,
6772
};
6873
})
69-
.sort((a, b) => (b.residual ?? -999) - (a.residual ?? -999));
74+
.sort((a, b) => {
75+
const ar = a.residual ?? -999;
76+
const br = b.residual ?? -999;
77+
return positiveIsGood ? br - ar : ar - br;
78+
});
7079
}
7180

72-
function rankingsTableHtml(rows: RankingsRow[]): string {
81+
function rankingsTableHtml(rows: RankingsRow[], target: string): string {
82+
const positiveIsGood = positiveResidualIsGood(target);
7383
return rows
7484
.map((r, i) => {
7585
const cls =
7686
r.residual !== null
77-
? r.residual > 0.05
78-
? "cell-positive"
87+
? positiveIsGood
88+
? r.residual > 0.05
89+
? "cell-positive"
90+
: r.residual < -0.05
91+
? "cell-negative"
92+
: ""
7993
: r.residual < -0.05
94+
? "cell-positive"
95+
: r.residual > 0.05
8096
? "cell-negative"
8197
: ""
8298
: "";
@@ -143,6 +159,19 @@ export function renderAnalyticsTab(data: AnalyticsData | null): string {
143159
: "";
144160

145161
const rankingsRows = buildRankingsRows(data);
162+
const inequalityTarget = data.target === "inequality";
163+
const scatterSubtitle = inequalityTarget
164+
? "Each dot is a country in 2020. Points below the diagonal are less unequal than predicted; points above are more unequal than predicted."
165+
: `Each dot is a country in ${decade}. Points above the diagonal beat their geography.`;
166+
const residualHistogramSubtitle = inequalityTarget
167+
? "Histogram of residuals (actual minus predicted). Green = less unequal than predicted, red = more unequal than predicted."
168+
: "Histogram of residuals (actual minus predicted). Green = outperforming, red = underperforming.";
169+
const regionalSubtitle = inequalityTarget
170+
? "Average residual by region. Negative = less unequal than predicted; positive = more unequal than predicted."
171+
: "Average residual by region. Positive = outperforms geography.";
172+
const rankingsSubtitle = inequalityTarget
173+
? "Click a column header to sort. Negative residual = less unequal than predicted."
174+
: "Click a column header to sort. Positive residual = beats geography.";
146175

147176
return `
148177
<section class="analytics-hero">
@@ -173,7 +202,7 @@ export function renderAnalyticsTab(data: AnalyticsData | null): string {
173202
${bundle ? `
174203
<section class="analytics-section">
175204
<h2>Actual vs. predicted</h2>
176-
<p class="section-subtitle">Each dot is a country in ${decade}. Points above the diagonal beat their geography.</p>
205+
<p class="section-subtitle">${scatterSubtitle}</p>
177206
<div class="chart-wrap chart-wrap-square">
178207
<canvas id="scatter-chart"></canvas>
179208
</div>
@@ -189,15 +218,15 @@ export function renderAnalyticsTab(data: AnalyticsData | null): string {
189218
190219
<section class="analytics-section">
191220
<h2>Distribution of luck</h2>
192-
<p class="section-subtitle">Histogram of residuals (actual minus predicted). Green = outperforming, red = underperforming.</p>
221+
<p class="section-subtitle">${residualHistogramSubtitle}</p>
193222
<div class="chart-wrap">
194223
<canvas id="residual-histogram"></canvas>
195224
</div>
196225
</section>
197226
198227
<section class="analytics-section">
199228
<h2>Regional breakdown</h2>
200-
<p class="section-subtitle">Average residual by region. Positive = outperforms geography.</p>
229+
<p class="section-subtitle">${regionalSubtitle}</p>
201230
<div class="chart-wrap chart-wrap-tall">
202231
<canvas id="regional-residual-chart"></canvas>
203232
</div>
@@ -207,7 +236,7 @@ export function renderAnalyticsTab(data: AnalyticsData | null): string {
207236
<div class="section-header-row">
208237
<div>
209238
<h2>Global rankings (${decade})</h2>
210-
<p class="section-subtitle">Click a column header to sort. Positive residual = beats geography.</p>
239+
<p class="section-subtitle">${rankingsSubtitle}</p>
211240
</div>
212241
<button class="export-btn" id="export-rankings-csv">Export CSV</button>
213242
</div>
@@ -223,7 +252,7 @@ export function renderAnalyticsTab(data: AnalyticsData | null): string {
223252
<th class="sortable-th active-sort" data-sort="residual">Residual \u25BE</th>
224253
</tr>
225254
</thead>
226-
<tbody>${rankingsTableHtml(rankingsRows)}</tbody>
255+
<tbody>${rankingsTableHtml(rankingsRows, data.target)}</tbody>
227256
</table>
228257
</div>
229258
</section>

web/src/tab-country.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ function fmtFeature(name: string): string {
5050
return name.replace(/_/g, " ");
5151
}
5252

53+
function positiveResidualIsGood(target: string): boolean {
54+
return target !== "inequality";
55+
}
56+
5357
const BLOCK_SOURCES: Record<string, string> = {
5458
deep_geo: "Natural Earth \u2014 latitude, land area, shape",
5559
hydro_terrain: "Natural Earth \u2014 coastline, rivers, elevation, terrain",
@@ -178,7 +182,11 @@ function renderCrossTargetTable(
178182
<thead><tr><th>Outcome</th><th>Actual</th><th>Predicted</th><th>Residual</th></tr></thead>
179183
<tbody>
180184
${rows.map((r) => {
181-
const cls = r.residual != null ? (r.residual > 0.05 ? "cell-positive" : r.residual < -0.05 ? "cell-negative" : "") : "";
185+
const cls = r.residual != null
186+
? positiveResidualIsGood(r.target)
187+
? (r.residual > 0.05 ? "cell-positive" : r.residual < -0.05 ? "cell-negative" : "")
188+
: (r.residual < -0.05 ? "cell-positive" : r.residual > 0.05 ? "cell-negative" : "")
189+
: "";
182190
return `<tr>
183191
<td>${TARGET_LABELS[r.target] ?? r.target}</td>
184192
<td>${fmtPct(r.actual)}</td>
@@ -228,8 +236,17 @@ function renderComparisonSection(
228236
</thead>
229237
<tbody>
230238
${h2hRows.map((r) => {
231-
const aResidCls = r.aResid != null ? (r.aResid > 0.05 ? "cell-positive" : r.aResid < -0.05 ? "cell-negative" : "") : "";
232-
const bResidCls = r.bResid != null ? (r.bResid > 0.05 ? "cell-positive" : r.bResid < -0.05 ? "cell-negative" : "") : "";
239+
const positiveIsGood = positiveResidualIsGood(r.target);
240+
const aResidCls = r.aResid != null
241+
? positiveIsGood
242+
? (r.aResid > 0.05 ? "cell-positive" : r.aResid < -0.05 ? "cell-negative" : "")
243+
: (r.aResid < -0.05 ? "cell-positive" : r.aResid > 0.05 ? "cell-negative" : "")
244+
: "";
245+
const bResidCls = r.bResid != null
246+
? positiveIsGood
247+
? (r.bResid > 0.05 ? "cell-positive" : r.bResid < -0.05 ? "cell-negative" : "")
248+
: (r.bResid < -0.05 ? "cell-positive" : r.bResid > 0.05 ? "cell-negative" : "")
249+
: "";
233250
return `<tr>
234251
<td>${TARGET_LABELS[r.target] ?? r.target}</td>
235252
<td>${fmtPct(r.aActual)} <span class="${aResidCls}" style="font-size:0.82em">(${fmtResidual(r.aResid)})</span></td>
@@ -365,7 +382,7 @@ export function renderCountryTab(data: CountryTabData): string {
365382
<h2>Feature contributions \u2014 ${targetLabel} (${TIER_LABELS[tk] ?? tk})</h2>
366383
<button class="export-btn" id="export-country-csv">Export CSV</button>
367384
</div>
368-
<p class="section-subtitle">Which features push this country's predicted rank up or down.</p>
385+
<p class="section-subtitle">Which features push this country's predicted ${targetLabel.toLowerCase()} up or down.</p>
369386
${contribCard}
370387
</section>
371388

0 commit comments

Comments
 (0)