File size: 11,042 Bytes
b9cdea1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
/* ImpactLab LATAM — pure, documented analytical functions. MIT license.
   Exploratory estimates are not causal identifications. */
(function(root,factory){const lib=factory();if(typeof module==='object'&&module.exports)module.exports=lib;else root.ImpactEngine=lib;})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
const finite=x=>typeof x==='number'&&Number.isFinite(x);
const avg=a=>{const v=a.filter(finite);return v.length?v.reduce((s,x)=>s+x,0)/v.length:null;};
const sum=a=>a.reduce((s,x)=>s+(finite(x)?x:0),0);
const sd=a=>{const m=avg(a);return m===null?null:Math.sqrt(avg(a.map(x=>(x-m)**2)));};
const round=(x,d=4)=>finite(x)?Number(x.toFixed(d)):null;
const clamp=(v,min,max)=>Math.min(max,Math.max(min,v));
const safeYear=y=>Number.isInteger(Number(y))?Number(y):null;
const encodeCSV=v=>{const str=String(v??'');const safe=typeof v==='string'&&/^[\s]*[=+@-]/.test(str)?"'"+str:str;return '"'+safe.replace(/"/g,'""')+'"';};
const byYear=(rows,country,indicator,start,end)=>new Map(rows.filter(r=>r.country===country&&r.indicator===indicator&&r.year>=start&&r.year<=end).map(r=>[r.year,finite(r.value)?r.value:null]));
const timeline=(rows,opts)=>{
 const {country,controls,indicator,start,end}=opts;
 const t=byYear(rows,country,indicator,start,end);
 const donors=(controls||[]).filter(c=>c!==country).map(c=>({country:c,values:byYear(rows,c,indicator,start,end)}));
 return Array.from({length:end-start+1},(_,i)=>{
  const year=start+i;const tv=t.get(year)??null;const cv=donors.map(d=>d.values.get(year)??null).filter(finite);
  return {year,treated:tv,controls:cv.length===donors.length&&cv.length?avg(cv):null,availableControls:cv.length};
 });
};
const checkOptions=opts=>{
 if(!opts||!opts.country||!opts.indicator)throw Error('Select an outcome and a treated country.');
 if(!Array.isArray(opts.controls)||!opts.controls.length)throw Error('Select at least one comparison country.');
 if(opts.controls.includes(opts.country))throw Error('The treated country cannot also be a control.');
 if(!(Number.isInteger(opts.start)&&Number.isInteger(opts.end)&&Number.isInteger(opts.breakYear)))throw Error('Years must be integers.');
 if(opts.end-opts.start<7)throw Error('Use at least eight calendar years.');
 if(opts.breakYear-opts.start<3||opts.end-opts.breakYear+1<3)throw Error('Need at least three years before and after the break.');
};
const estimateDid=(rows,opts)=>{
 checkOptions(opts);
 const points=timeline(rows,opts);
 const paired=points.filter(p=>finite(p.treated)&&finite(p.controls));
 const pre=paired.filter(p=>p.year<opts.breakYear),post=paired.filter(p=>p.year>=opts.breakYear);
 if(pre.length<3||post.length<3) return {ok:false,error:`Not enough paired observations: ${pre.length} pre / ${post.length} post; three per period required.`,points,preYears:pre.length,postYears:post.length};
 const tpre=avg(pre.map(p=>p.treated)),tpost=avg(post.map(p=>p.treated));
 const cpre=avg(pre.map(p=>p.controls)),cpost=avg(post.map(p=>p.controls));
 const effect=(tpost-tpre)-(cpost-cpre);
 return {ok:true,effect,treatedChange:tpost-tpre,controlChange:cpost-cpre,treatedPre:tpre,treatedPost:tpost,controlPre:cpre,controlPost:cpost,preYears:pre.length,postYears:post.length,points,paired,pre,post};
};
const trendSlope=arr=>{
 const a=arr.filter(p=>finite(p.value));if(a.length<3)return null;
 const mx=avg(a.map(p=>p.year)),my=avg(a.map(p=>p.value));
 const denom=sum(a.map(p=>(p.year-mx)**2));
 return denom>0?sum(a.map(p=>(p.year-mx)*(p.value-my)))/denom:null;
};
const pretrend=did=>{
 if(!did.ok)return {ok:false};
 const t=trendSlope(did.pre.map(p=>({year:p.year,value:p.treated})));
 const c=trendSlope(did.pre.map(p=>({year:p.year,value:p.controls})));
 return {ok:finite(t)&&finite(c),treatedSlope:t,controlSlope:c,slopeDifference:finite(t)&&finite(c)?t-c:null,preDifferences:did.pre.map(p=>({year:p.year,gap:p.treated-p.controls}))};
};
const seeded=(seed=24317)=>{let a=(Number(seed)||24317)>>>0;return ()=>{a=(a*1664525+1013904223)>>>0;return a/4294967296;};};
const bootstrapDid=(did,n=400,seed=24317)=>{
 if(!did.ok)return {ok:false,interval:null,draws:[]};
 const rnd=seeded(seed),out=[];
 for(let k=0;k<n;k++){
  const sample=a=>Array.from({length:a.length},()=>a[Math.floor(rnd()*a.length)]);
  const pre=sample(did.pre),post=sample(did.post);
  out.push((avg(post.map(p=>p.treated-p.controls)))-(avg(pre.map(p=>p.treated-p.controls))));
 }
 const s=out.sort((a,b)=>a-b),q=p=>{const pos=(s.length-1)*p,lo=Math.floor(pos),hi=Math.ceil(pos);return s[lo]+(s[hi]-s[lo])*(pos-lo);};
 return {ok:true,draws:out,interval:[q(.025),q(.975)],n,method:'Year-resampling descriptive interval; not causal inference uncertainty.'};
};
function projectSimplex(v){
 const u=[...v].sort((a,b)=>b-a);let sumU=0,rho=-1;
 for(let i=0;i<u.length;i++){sumU+=u[i];if(u[i]+(1-sumU)/(i+1)>0)rho=i;}
 if(rho===-1)return Array(v.length).fill(1/v.length);
 const theta=(sum(u.slice(0,rho+1))-1)/(rho+1);
 return v.map(x=>Math.max(0,x-theta));
}
function fitSimplex(X,y,lambda=.001,iter=1000){
 // minimize average squared residual in outcome units scaled by y RMS
 const n=X.length,m=X[0].length;let w=Array(m).fill(1/m);
 const scale=Math.max(1,Math.sqrt(avg(y.map(v=>v*v)))||1);
 const xs=X.map(row=>row.map(v=>v/scale)),ys=y.map(v=>v/scale);
 const L=(2/n)*sum(xs.map(row=>sum(row.map(v=>v*v))))+2*lambda;
 const step=1/Math.max(.001,L);
 for(let k=0;k<iter;k++){
  const grad=Array(m).fill(0);
  for(let i=0;i<n;i++){const err=sum(xs[i].map((v,j)=>v*w[j]))-ys[i];for(let j=0;j<m;j++)grad[j]+=2*err*xs[i][j]/n;}
  for(let j=0;j<m;j++)grad[j]+=2*lambda*w[j];
  const next=projectSimplex(w.map((v,j)=>v-step*grad[j]));
  if(sum(next.map((v,j)=>Math.abs(v-w[j])))<1e-10){w=next;break;}w=next;
 }
 return w;
}
const syntheticControl=(rows,opts)=>{
 checkOptions(opts);
 if(opts.controls.length<2)return {ok:false,error:'Synthetic control requires at least two distinct donor countries.'};
 const years=Array.from({length:opts.end-opts.start+1},(_,i)=>opts.start+i);
 const mapped=[opts.country,...opts.controls].map(c=>byYear(rows,c,opts.indicator,opts.start,opts.end));
 const complete=years.filter(year=>mapped.every(m=>finite(m.get(year))));
 const pre=complete.filter(y=>y<opts.breakYear),post=complete.filter(y=>y>=opts.breakYear);
 if(pre.length<5||post.length<2)return {ok:false,error:`Need at least five shared pre-break and two post-break years (${pre.length} pre / ${post.length} post available).`,preYears:pre.length,postYears:post.length};
 const X=pre.map(y=>mapped.slice(1).map(m=>m.get(y))),y=pre.map(y=>mapped[0].get(y));
 const w=fitSimplex(X,y);
 const points=complete.map(year=>{
  const treated=mapped[0].get(year),synthetic=sum(mapped.slice(1).map((m,j)=>m.get(year)*w[j]));
  return {year,treated,synthetic,gap:treated-synthetic};
 });
 const prePts=points.filter(p=>p.year<opts.breakYear),postPts=points.filter(p=>p.year>=opts.breakYear);
 const rmse=a=>Math.sqrt(avg(a.map(p=>p.gap*p.gap)));
 const preRmse=rmse(prePts),postRmse=rmse(postPts);
 return {ok:true,weights:opts.controls.map((country,i)=>({country,weight:w[i]})),points,preYears:pre.length,postYears:post.length,preRmse,postRmse,postMeanGap:avg(postPts.map(p=>p.gap)),postCumulativeGap:sum(postPts.map(p=>p.gap)),fitRatio:preRmse>1e-10?postRmse/preRmse:null};
};
const unitPlacebos=(rows,opts)=>{
 const candidates=[opts.country,...opts.controls],results=[];
 for(const fake of candidates){const donor=candidates.filter(c=>c!==fake);if(donor.length<2)continue;
  try{const r=syntheticControl(rows,{...opts,country:fake,controls:donor});if(r.ok)results.push({country:fake,treated:fake===opts.country,postMeanGap:r.postMeanGap,preRmse:r.preRmse,fitRatio:r.fitRatio});}catch(e){}
 }
 return results;
};
const timePlacebos=(rows,opts)=>{
 const years=[];for(let t=opts.start+3;t<=opts.breakYear-3;t++){
  try{const r=estimateDid(rows,{...opts,breakYear:t,end:opts.breakYear-1});if(r.ok)years.push({year:t,effect:r.effect});}catch(e){}
 }return years;
};
const coverage=(rows,opts)=>{
 const relevant=rows.filter(r=>r.indicator===opts.indicator&&[opts.country,...opts.controls].includes(r.country)&&r.year>=opts.start&&r.year<=opts.end);
 return [opts.country,...opts.controls].map(country=>{
  const arr=relevant.filter(r=>r.country===country&&finite(r.value));const years=arr.map(r=>r.year);
  return {country,present:arr.length,expected:opts.end-opts.start+1,latest:years.length?Math.max(...years):null,missing:opts.end-opts.start+1-arr.length};
 });
};
function parseWorldBank(payload,indicator){
 if(!Array.isArray(payload)||!Array.isArray(payload[1]))throw Error('World Bank API response has no observation array.');
 if(payload[0]&&payload[0].message)throw Error('World Bank returned an error.');
 return payload[1].map(r=>({country:r.countryiso3code||r.country?.id||'',year:safeYear(r.date),indicator,value:finite(r.value)?r.value:null,source:'worldbank'})).filter(r=>r.country&&r.year!==null);
}
function validateRows(input){
 if(!Array.isArray(input))throw Error('Rows must be an array.');
 const out=[],seen=new Set();for(const r of input){
  if(!r||!r.country||!r.indicator||!Number.isInteger(Number(r.year)))continue;
  const country=String(r.country).slice(0,20),indicator=String(r.indicator).slice(0,60),year=Number(r.year);
  if(year<1900||year>2100)continue;const value=r.value===null||r.value===''?null:Number(r.value);
  if(value!==null&&!finite(value))continue;
  const id=`${country}|${indicator}|${year}`;if(seen.has(id))continue;seen.add(id);
  out.push({country,indicator,year,value,source:String(r.source||'user').slice(0,60)});
 }return out;
}
function toCSV(rows,keys){return [keys.map(encodeCSV).join(','),...rows.map(r=>keys.map(k=>encodeCSV(r[k])).join(','))].join('\r\n')+'\r\n';}
const indicators={
 'SP.DYN.LE00.IN':{name:'Life expectancy at birth',unit:'years',decimals:2,domain:'Health'},
 'NY.GDP.PCAP.KD.ZG':{name:'GDP per capita growth',unit:'annual %',decimals:2,domain:'Economy'},
 'SL.UEM.TOTL.ZS':{name:'Unemployment (modeled ILO)',unit:'% of labor force',decimals:2,domain:'Labor'},
 'SE.SEC.ENRR':{name:'School enrollment, secondary (gross)',unit:'%',decimals:2,domain:'Education'},
 'IT.NET.USER.ZS':{name:'Individuals using the Internet',unit:'% of population',decimals:2,domain:'Digital'},
 'SH.XPD.CHEX.GD.ZS':{name:'Current health expenditure',unit:'% of GDP',decimals:2,domain:'Health'},
 'SE.XPD.TOTL.GD.ZS':{name:'Government expenditure on education',unit:'% of GDP',decimals:2,domain:'Education'},
 'SP.POP.TOTL':{name:'Population, total',unit:'people',decimals:0,domain:'Demography'}
};
const countries={ARG:'Argentina',BOL:'Bolivia',BRA:'Brazil',CHL:'Chile',COL:'Colombia',CRI:'Costa Rica',DOM:'Dominican Republic',ECU:'Ecuador',GTM:'Guatemala',MEX:'Mexico',PAN:'Panama',PER:'Peru',PRY:'Paraguay',URY:'Uruguay',SLV:'El Salvador'};
return {finite,avg,sum,sd,round,clamp,timeline,checkOptions,estimateDid,pretrend,bootstrapDid,projectSimplex,fitSimplex,syntheticControl,unitPlacebos,timePlacebos,coverage,parseWorldBank,validateRows,toCSV,indicators,countries};
});