Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 153 additions & 58 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
.scene .text{flex:1.15;}
.scene .panel{flex:1;}
.scene .mk{font-size:13px;letter-spacing:.3em;text-transform:uppercase;color:var(--red);}
.scene .eq{margin:18px 0 22px;border-left:2px solid var(--red);padding:5px 0 5px 15px;color:#3a3f4a;font-size:19px;}
.scene .eq{margin:18px 0 22px;border-left:2px solid var(--red);padding:5px 0 5px 15px;color:var(--ink);font-size:19px;}
.scene h2{font-family:var(--serif);font-weight:400;font-size:clamp(26px,3.6vw,40px);line-height:1.08;letter-spacing:-.015em;margin:0;}
.scene h2 .ll{color:var(--red);font-style:italic;}
.scene .supp{color:var(--muted);font-size:14px;line-height:1.6;margin-top:20px;max-width:46ch;}
Expand Down Expand Up @@ -60,7 +60,7 @@
</style>
</head>
<body>
<canvas id="hero" aria-label="Animation: two subgroup distributions over a moderator M, reweighted until they coincide."></canvas>
<canvas id="hero" aria-label="Animation: two subgroups' moderator M is reweighted until balanced; the subgroup effect gap grows from 4 to 9 percentage points once M is held constant."></canvas>
<header class="site">
<div class="kicker">Weighted Subgroup Analysis</div>
<h1>Two subgroups,<br>pulled into <em>balance</em>.</h1>
Expand All @@ -69,46 +69,47 @@ <h1>Two subgroups,<br>pulled into <em>balance</em>.</h1>

<main id="track">

<section class="scene" data-scene="1" data-t="0">
<section class="scene" data-scene="1">
<div class="text">
<div class="mk">01 · the setup</div>
<h2>Many studies ask how an effect differs <span class="ll">by subgroup</span>.</h2>
<div class="supp">Split the sample into G = 0 and G = 1, estimate the treatment effect in each, and compare.</div>
<div class="eq" data-tex="\Delta \;=\; \tau(G{=}1) \;-\; \tau(G{=}0)"></div>
<h2>Comparing an effect <span class="ll">across subgroups</span>.</h2>
<div class="supp">Estimate the treatment effect within subgroup G = 0 and within subgroup G = 1, then compare them. A gap of Δ = 4 pp. Should we trust it?</div>
</div>
<div class="panel"><div class="plotframe"></div></div>
</section>

<section class="scene" data-scene="2" data-t="0">
<section class="scene" data-scene="2">
<div class="text">
<div class="mk">02 · the problem</div>
<div class="eq" data-tex="\Delta \;=\; \tau(G{=}1) \;-\; \tau(G{=}0)"></div>
<h2>But G is tangled with other moderators M.</h2>
<div class="supp">Is the effect different by G or by what G is <em>correlated with</em>? The subgroups don't share the same distribution of M.</div>
<div class="eq" data-tex="\mathbb{E}[M \mid G{=}1] \;\neq\; \mathbb{E}[M \mid G{=}0]"></div>
<h2><span data-tex="G"></span> is tangled with other moderators <span data-tex="M"></span>.</h2>
<div class="supp">Is the effect different by G, or by what G is <em>correlated with</em>? The subgroups don't share the same distribution of M.</div>
</div>
<div class="panel"><div class="plotframe"></div></div>
</section>

<section class="scene" data-scene="3" data-t="0.6">
<section class="scene" data-scene="3">
<div class="text">
<div class="mk">03 · the idea</div>
<div class="eq" data-tex="w_i \;=\; \hat f_{\text{pool}}(M_i) \,/\, \hat f_{G(i)}(M_i)"></div>
<h2>Up-weight the units that <span class="ll">look like</span> the other group.</h2>
<div class="supp">— and down-weight the ones that look like their own. Estimate it from the propensity to belong to G = 1.</div>
<div class="supp">Conversely, down-weight the ones that look like their own. Estimate it from the propensity to belong to G = 1.</div>
</div>
<div class="panel"><div class="plotframe"></div></div>
</section>

<section class="scene" data-scene="4" data-t="1">
<section class="scene" data-scene="4">
<div class="text">
<div class="mk">04 · balance</div>
<div class="eq" data-tex="\text{std.\,diff}(M) \;\longrightarrow\; 0"></div>
<h2>Now M is distributed <span class="ll">identically</span> across subgroups.</h2>
<div class="supp">The remaining difference in effects is attributable to G, holding M constant.</div>
<h2>Hold <span data-tex="M"></span> constant, and the <span class="ll">true gap</span> emerges.</h2>
<div class="supp">With M balanced across subgroups, the effect gap is no longer masked: Δ&prime; = 9 pp, attributable to G.</div>
</div>
<div class="panel"><div class="plotframe"></div></div>
</section>

<section class="scene" data-scene="5" data-t="1">
<section class="scene" data-scene="5">
<div class="text">
<div class="mk">05 · the honest caveat</div>
<h2>Observable balance is necessary, not <span class="ll">sufficient</span>.</h2>
Expand All @@ -120,7 +121,7 @@ <h2>Observable balance is necessary, not <span class="ll">sufficient</span>.</h2
</main>

<footer class="site">
<h2>Use it.</h2>
<h2>Try it!</h2>
<div class="links">
<a href="https://github.com/acarril/wsga">GitHub repository</a>
<a href="simulation-report.html">Monte Carlo simulation report</a>
Expand Down Expand Up @@ -151,71 +152,165 @@ <h2>Use it.</h2>
function wmean(arr,t){let s=0,sw=0;for(const p of arr){let wv=w(p,t);s+=wv*p.m;sw+=wv;}return s/sw;}
function wsd(arr,t){let mu=wmean(arr,t),s=0,sw=0;for(const p of arr){let wv=w(p,t);s+=wv*(p.m-mu)**2;sw+=wv;}return Math.sqrt(s/sw);}
function stdDiff(t){return Math.abs(wmean(G1,t)-wmean(G0,t))/Math.sqrt((wsd(G0,t)**2+wsd(G1,t)**2)/2);}
return {G0,G1,w,kde,wmean,wsd,stdDiff};
// synthetic per-unit treatment effects: tau_i = ALPHA + BETA*G_i + GAMMA*M_i.
// GAMMA<0 => high-M units have smaller effects, so M-imbalance MASKS the true gap.
// Subgroup effects are weighted means over the same IPW weights -> the gap recomputes honestly.
const ALPHA=0.1895, BETA=0.0924, GAMMA=-0.3340;
function tau(p,g){return ALPHA+BETA*g+GAMMA*p.m;}
function tauMean(arr,g,t){let s=0,sw=0;for(const p of arr){let wv=w(p,t);s+=wv*tau(p,g);sw+=wv;}return s/sw;}
function effectGap(t){return tauMean(G1,1,t)-tauMean(G0,0,t);}
return {G0,G1,w,kde,wmean,wsd,stdDiff,ALPHA,BETA,GAMMA,tau,tauMean,effectGap};
})();
</script>
<script>
// ---- WSGA hero canvas rendering ----
// ---- WSGA hero canvas rendering (v2: phased timeline, dots-forward, effect panel) ----
(function(){
const cv=document.getElementById('hero'), ctx=cv.getContext('2d');
const REDUCE = window.matchMedia && window.matchMedia('(prefers-reduced-motion:reduce)').matches;
const INK='#1d2433', RED='#b03a2e';
const INK='#1d2433', RED='#b03a2e', MUTED='#9a958a';
let W,H,DPR;
function resize(){DPR=Math.min(2,devicePixelRatio||1);W=cv.clientWidth;H=cv.clientHeight;cv.width=W*DPR;cv.height=H*DPR;ctx.setTransform(DPR,0,0,DPR,0,0);}
addEventListener('resize',()=>{resize();drawHero(window.__t||0);});

// plot occupies the right ~42% of the viewport, vertically centered band
// stable 2D "cloud" start positions per unit (normalized 0..1), deterministic
(function seedClouds(){
function mb(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
const r=mb(7);
// two horizontal bands at effect heights: G1 (bigger effect) upper, G0 lower
for(const [arr,cy] of [[WSGA.G0,0.60],[WSGA.G1,0.42]]){
for(const p of arr){ p.cxn=0.61+r()*0.30; p.cyn=cy+(r()-0.5)*0.09; }
}
})();

const xs=[];for(let i=0;i<=160;i++)xs.push(i/160);
function X(m){
if(W<=760){const left=0.10*W,right=0.90*W;return left+m*(right-left);}
const left=0.56*W,right=0.95*W;return left+m*(right-left);
}
const clamp=(x)=>Math.max(0,Math.min(1,x));
function ease(x){return x<.5?2*x*x:1-Math.pow(-2*x+2,2)/2;}
function lerp(a,b,u){return a+(b-a)*u;}

window.drawHero=function(raw){
const t=ease(Math.max(0,Math.min(1,raw)));
window.drawHero=function(p){
p=p||{}; const align=clamp(p.align||0), te=ease(clamp(p.t||0)), hidden=clamp(p.hidden||0), dock=ease(clamp(p.dock||0));
ctx.clearRect(0,0,W,H);
const baseY = (W<=760 ? H*0.84 : H*0.66), amp=H*0.30;
const d0=WSGA.kde(WSGA.G0,t,xs,0.05), d1=WSGA.kde(WSGA.G1,t,xs,0.05);
ctx.save();
ctx.translate(0, -dock*H*0.30); // dock the figure flush near the top during scene 5
const baseY=(W<=760?H*0.78:H*0.70), amp=H*0.18;

// curves (fade in with align; reweight with te)
const d0=WSGA.kde(WSGA.G0,te,xs,0.05), d1=WSGA.kde(WSGA.G1,te,xs,0.05);
const mx=Math.max(Math.max(...d0),Math.max(...d1))*1.05;
function curve(d,color,fill){
ctx.beginPath();ctx.moveTo(X(0),baseY);
for(let i=0;i<xs.length;i++)ctx.lineTo(X(xs[i]),baseY-(d[i]/mx)*amp);
ctx.lineTo(X(1),baseY);ctx.fillStyle=fill;ctx.fill();
ctx.beginPath();
for(let i=0;i<xs.length;i++){let yy=baseY-(d[i]/mx)*amp;i?ctx.lineTo(X(xs[i]),yy):ctx.moveTo(X(xs[i]),yy);}
ctx.strokeStyle=color;ctx.lineWidth=1.2;ctx.stroke();
if(align>0.01){
ctx.globalAlpha=align;
const curve=(d,color,fill)=>{
ctx.beginPath();ctx.moveTo(X(0),baseY);
for(let i=0;i<xs.length;i++)ctx.lineTo(X(xs[i]),baseY-(d[i]/mx)*amp);
ctx.lineTo(X(1),baseY);ctx.fillStyle=fill;ctx.fill();
ctx.beginPath();
for(let i=0;i<xs.length;i++){let yy=baseY-(d[i]/mx)*amp;i?ctx.lineTo(X(xs[i]),yy):ctx.moveTo(X(xs[i]),yy);}
ctx.strokeStyle=color;ctx.lineWidth=1.2;ctx.stroke();
};
curve(d0,INK,'rgba(29,36,51,0.07)');
curve(d1,RED,'rgba(176,58,46,0.07)');
ctx.globalAlpha=1;
}
curve(d0,INK,'rgba(29,36,51,0.07)');
curve(d1,RED,'rgba(176,58,46,0.07)');
function dots(arr,color,grp){for(const p of arr){let wv=WSGA.w(p,t);let r=1.4+Math.sqrt(Math.max(0.05,wv))*1.7;ctx.globalAlpha=0.5;ctx.beginPath();ctx.arc(X(p.m),baseY+10+(grp?9:0),r,0,7);ctx.fillStyle=color;ctx.fill();ctx.globalAlpha=1;}}

// dots: 2D cloud (align=0) -> M-axis lane (align=1); radius by weight (te), exaggerated
const dots=(arr,color,grp)=>{
for(const p2 of arr){
const x=lerp(p2.cxn*W,X(p2.m),align), y=lerp(p2.cyn*H,baseY+10+(grp?24:0),align);
const wv=WSGA.w(p2,te); const r=2.2+Math.pow(Math.max(0.05,wv),0.9)*2.7;
ctx.globalAlpha=0.5;ctx.beginPath();ctx.arc(x,y,r,0,7);ctx.fillStyle=color;ctx.fill();ctx.globalAlpha=1;
}
};
dots(WSGA.G0,INK,0);dots(WSGA.G1,RED,1);
function tick(arr,color){let mu=WSGA.wmean(arr,t);ctx.setLineDash([3,3]);ctx.beginPath();ctx.moveTo(X(mu),baseY-amp-6);ctx.lineTo(X(mu),baseY+6);ctx.strokeStyle=color;ctx.lineWidth=1;ctx.stroke();ctx.setLineDash([]);}
tick(WSGA.G0,INK);tick(WSGA.G1,RED);
ctx.beginPath();ctx.moveTo(X(0),baseY);ctx.lineTo(X(1),baseY);ctx.strokeStyle='rgba(29,36,51,0.25)';ctx.lineWidth=1;ctx.stroke();
// readout
ctx.fillStyle='#9a958a';ctx.font="11px 'IBM Plex Mono',monospace";ctx.textAlign='left';
ctx.fillText('STD. DIFF IN M',X(0),baseY-amp-26);
ctx.fillStyle=INK;ctx.font="26px 'IBM Plex Mono',monospace";
ctx.fillText(WSGA.stdDiff(t).toFixed(2),X(0),baseY-amp-2);
ctx.fillStyle='#9a958a';ctx.font="11px 'IBM Plex Mono',monospace";ctx.textAlign='right';
ctx.fillText('M →',X(1),baseY+22);ctx.textAlign='left';

// baseline, mean ticks, std-diff readout (fade with align)
if(align>0.01){
ctx.globalAlpha=align;
const tick=(arr,color)=>{let mu=WSGA.wmean(arr,te);ctx.setLineDash([3,3]);ctx.beginPath();ctx.moveTo(X(mu),baseY-amp-6);ctx.lineTo(X(mu),baseY+6);ctx.strokeStyle=color;ctx.lineWidth=1;ctx.stroke();ctx.setLineDash([]);};
tick(WSGA.G0,INK);tick(WSGA.G1,RED);
ctx.beginPath();ctx.moveTo(X(0),baseY);ctx.lineTo(X(1),baseY);ctx.strokeStyle='rgba(29,36,51,0.25)';ctx.lineWidth=1;ctx.stroke();
ctx.fillStyle=MUTED;ctx.font="11px 'IBM Plex Mono',monospace";ctx.textAlign='left';
ctx.fillText('STD. DIFF IN M',X(0),baseY+58);
ctx.fillStyle=INK;ctx.font="24px 'IBM Plex Mono',monospace";
ctx.fillText(WSGA.stdDiff(te).toFixed(2),X(0),baseY+84);
ctx.fillStyle=MUTED;ctx.font="11px 'IBM Plex Mono',monospace";ctx.textAlign='right';
ctx.fillText('M →',X(1),baseY+22);ctx.textAlign='left';
ctx.globalAlpha=1;
}

// effect-gap display: ONE bracket that MORPHS from the scene-1 band gap into the
// compact panel (endpoints interpolate by align); panel chrome (axis, tau markers,
// value labels, sub-label) fades in; the scene-1 G-band labels fade out.
{
const tau0=WSGA.tauMean(WSGA.G0,0,te), tau1=WSGA.tauMean(WSGA.G1,1,te), gap=WSGA.effectGap(te);
const eTop=H*0.36, eBot=H*0.50, eX=X(0), eMark=X(0)+16;
const yOf=(tau)=>eBot-(Math.max(0,Math.min(0.14,tau))/0.14)*(eBot-eTop);
// scene-1 anchor: bracket spans the two dot-band centers; scene-2 anchor: panel markers
const xB=lerp(W*0.585,eMark,align), yTop=lerp(H*0.42,yOf(tau1),align), yBot=lerp(H*0.60,yOf(tau0),align);

// the morphing bracket + Delta label (always present)
ctx.strokeStyle='rgba(29,36,51,0.45)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(xB+10,yTop);ctx.lineTo(xB,yTop);ctx.lineTo(xB,yBot);ctx.lineTo(xB+10,yBot);ctx.stroke();
ctx.fillStyle=INK;ctx.font=Math.round(lerp(16,15,align))+"px 'IBM Plex Mono',monospace";ctx.textAlign='right';
ctx.fillText('Δ '+(gap*100).toFixed(0)+'pp',xB-8,(yTop+yBot)/2+5);ctx.textAlign='left';

// EFFECT GAP header (always), just above the bracket top
ctx.fillStyle=MUTED;ctx.font="10px 'IBM Plex Mono',monospace";ctx.textAlign='left';
ctx.fillText('EFFECT GAP',xB-2,yTop-lerp(H*0.065,16,align));

// tau markers + value labels + sub-label (panel chrome) fade in
if(align>0.01){
ctx.globalAlpha=align;
const marker=(tau,color,lab)=>{const y=yOf(tau);ctx.fillStyle=color;ctx.beginPath();ctx.arc(eMark,y,5,0,7);ctx.fill();
ctx.font="10px 'IBM Plex Mono',monospace";ctx.textAlign='left';ctx.fillStyle=color;ctx.fillText(lab+' '+(tau*100).toFixed(1)+'pp',eMark+12,y+3);};
marker(tau0,INK,'τ(G0)');marker(tau1,RED,'τ(G1)');
ctx.fillStyle=MUTED;ctx.font="10px 'IBM Plex Mono',monospace";ctx.textAlign='left';
ctx.fillText(te<0.05?'but is the gap G, or M?':te<0.95?'holding M constant…':'holding M constant',eX,eBot+18);
ctx.globalAlpha=1;
}

// scene-1 G-band labels fade out
if(align<0.99){
ctx.globalAlpha=1-align;ctx.font="11px 'IBM Plex Mono',monospace";ctx.textAlign='left';
ctx.fillStyle=RED;ctx.fillText('G = 1',W*0.925,H*0.42+4);
ctx.fillStyle=INK;ctx.fillText('G = 0',W*0.925,H*0.60+4);
ctx.globalAlpha=1;
}
}

// scene-05 hidden-dimension cue
if(hidden>0.01){
ctx.globalAlpha=0.6*hidden;
ctx.fillStyle=MUTED;ctx.font="11px 'IBM Plex Mono',monospace";ctx.textAlign='left';
ctx.fillText('unobserved U — weighting can’t see this',X(0),baseY+114);
ctx.strokeStyle='rgba(115,118,126,0.4)';ctx.setLineDash([2,4]);ctx.beginPath();ctx.moveTo(X(0),baseY+130);ctx.lineTo(X(1),baseY+130);ctx.stroke();ctx.setLineDash([]);
ctx.fillStyle='rgba(115,118,126,0.55)';
for(let i=0;i<24;i++){const gx=X(0.05+0.9*((i*0.137)%1));ctx.beginPath();ctx.arc(gx,baseY+130,2.5,0,7);ctx.fill();}
ctx.globalAlpha=1;
}
ctx.restore();
};

const track=document.getElementById('track');
let ticking=false;
// ---- phased scroll timeline ----
const secs=[...document.querySelectorAll('.scene')];
function progress(){
const r=track.getBoundingClientRect();
const total=r.height-window.innerHeight; // scrollable distance within the track
const scrolled=Math.min(Math.max(-r.top,0),total);
return total>0 ? scrolled/total : 0;
const y=window.scrollY||window.pageYOffset||0, vh=window.innerHeight;
const top=i=>secs[i].offsetTop;
const seg=(a,b)=> b>a ? clamp((y-a)/(b-a)) : (y>=a?1:0);
return {
align: seg(top(0),top(1)), // phase A: scene 1 -> 2
t: seg(top(1),top(3)), // phase B: scene 2 -> 4 (=1 from scene 4 on)
hidden:seg(top(4)-vh*0.6, top(4)), // scene 5 cue
dock: seg(top(3),top(4)) // scene 4 -> 5: figure rises to dock at top
};
}
function onScroll(){
if(ticking)return;ticking=true;
requestAnimationFrame(()=>{window.__t=progress();drawHero(window.__t);ticking=false;});
}
if(REDUCE){ resize();window.__t=1;drawHero(1); }
else { addEventListener('scroll',onScroll,{passive:true});resize();window.__t=progress();drawHero(window.__t); }
let ticking=false;
function frame(){window.__p=progress();drawHero(window.__p);ticking=false;}
function onScroll(){if(ticking)return;ticking=true;requestAnimationFrame(frame);}
addEventListener('resize',()=>{resize();drawHero(window.__p||{align:0,t:0,hidden:0});});
if(REDUCE){ resize();window.__p={align:1,t:1,hidden:0};drawHero(window.__p); }
else { addEventListener('scroll',onScroll,{passive:true});resize();window.__p=progress();drawHero(window.__p); }
})();
</script>
<script>
Expand All @@ -231,7 +326,7 @@ <h2>Use it.</h2>
// KaTeX render of inline equations (defer-loaded; run on load)
window.addEventListener('load',function(){
if(!window.katex)return;
document.querySelectorAll('.eq[data-tex]').forEach(function(el){
document.querySelectorAll('[data-tex]').forEach(function(el){
try{katex.render(el.getAttribute('data-tex'),el,{throwOnError:false,displayMode:false});}catch(e){el.textContent=el.getAttribute('data-tex');}
});
});
Expand Down
Loading
Loading