Spaces:
Running
Running
Download engine.js from Hugdrigo/impactlab-latam: direct link, hf CLI and curl.
- Browser
- Download file 11 kB
-
https://huggingface.co/spaces/Hugdrigo/impactlab-latam/resolve/main/engine.js
- Command line
-
hf download hf://spaces/Hugdrigo/impactlab-latam/engine.js
-
curl -L -o engine.js https://huggingface.co/spaces/Hugdrigo/impactlab-latam/resolve/main/engine.js
11 kB
| /* 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(){ | |
| ; | |
| 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}; | |
| }); | |