Spaces:
Running
Running
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};
});
|