Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| <div class="d3-seed-variance"></div> | |
| <style> | |
| .d3-seed-variance { position: relative; } | |
| .d3-seed-variance .controls { | |
| display: flex; gap: 16px; align-items: flex-start; justify-content: flex-start; | |
| flex-wrap: wrap; margin: 10px 0 0 0; | |
| } | |
| .d3-seed-variance .controls .control-group { | |
| display: flex; flex-direction: column; align-items: flex-start; gap: 6px; | |
| flex: 0 1 auto; min-width: 0; | |
| } | |
| .d3-seed-variance .controls select { max-width: 100%; } | |
| .d3-seed-variance .controls label { | |
| font-size: 12px; font-weight: 700; color: var(--text-color); | |
| } | |
| .d3-seed-variance .controls select { | |
| font-size: 12px; padding: 8px 28px 8px 10px; | |
| border: 1px solid var(--border-color); border-radius: 8px; | |
| background: var(--surface-bg); color: var(--text-color); cursor: pointer; | |
| } | |
| .d3-seed-variance .legend { | |
| display: flex; flex-direction: column; align-items: flex-start; gap: 6px; | |
| margin: 12px 0 0 0; | |
| } | |
| .d3-seed-variance .legend .legend-title { | |
| font-size: 12px; font-weight: 700; color: var(--text-color); | |
| } | |
| .d3-seed-variance .legend .items { display: flex; flex-wrap: wrap; gap: 8px 16px; } | |
| .d3-seed-variance .legend .item { | |
| display: inline-flex; align-items: center; gap: 6px; font-size: 12px; | |
| color: var(--text-color); white-space: nowrap; | |
| } | |
| .d3-seed-variance .legend .swatch { | |
| width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border-color); | |
| } | |
| .d3-seed-variance .d3-tooltip { | |
| position: absolute; top: 0; left: 0; | |
| transform: translate(-9999px, -9999px); | |
| pointer-events: none; padding: 8px 12px; border-radius: 8px; | |
| font-size: 12px; line-height: 1.45; | |
| border: 1px solid var(--border-color); | |
| background: var(--surface-bg); color: var(--text-color); | |
| box-shadow: 0 4px 24px rgba(0,0,0,.18); | |
| opacity: 0; transition: opacity .12s ease; min-width: 190px; | |
| } | |
| .d3-seed-variance .d3-tooltip .row { | |
| display: flex; align-items: center; justify-content: space-between; gap: 12px; | |
| } | |
| .d3-seed-variance .d3-tooltip .row .name { display: inline-flex; align-items: center; gap: 6px; } | |
| .d3-seed-variance .d3-tooltip .row .swatch { width: 10px; height: 10px; border-radius: 2px; } | |
| .d3-seed-variance .axes path, | |
| .d3-seed-variance .axes line { stroke: var(--axis-color); } | |
| .d3-seed-variance .axes text { fill: var(--tick-color); font-size: 12px; } | |
| .d3-seed-variance .grid line { stroke: var(--grid-color); } | |
| .d3-seed-variance .x-label, | |
| .d3-seed-variance .y-label { fill: var(--text-color); font-size: 13px; } | |
| .d3-seed-variance .gap-label { fill: var(--muted-color); font-size: 11px; font-weight: 600; } | |
| </style> | |
| <script> | |
| (() => { | |
| const ensureD3 = (cb) => { | |
| if (window.d3 && typeof window.d3.select === 'function') return cb(); | |
| let s = document.getElementById('d3-cdn-script'); | |
| if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); } | |
| const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); }; | |
| s.addEventListener('load', onReady, { once: true }); | |
| if (window.d3) onReady(); | |
| }; | |
| const bootstrap = () => { | |
| const scriptEl = document.currentScript; | |
| let container = scriptEl ? scriptEl.previousElementSibling : null; | |
| if (!(container && container.classList && container.classList.contains('d3-seed-variance'))) { | |
| const cs = Array.from(document.querySelectorAll('.d3-seed-variance')) | |
| .filter(el => !(el.dataset && el.dataset.mounted === 'true')); | |
| container = cs[cs.length - 1] || null; | |
| } | |
| if (!container) return; | |
| if (container.dataset) { | |
| if (container.dataset.mounted === 'true') return; | |
| container.dataset.mounted = 'true'; | |
| } | |
| container.style.position = container.style.position || 'relative'; | |
| // Two semantic series colors aligned with the rest of the blog: the table | |
| // mix uses the FinePhrase orange, the raw baseline uses the baseline gray. | |
| const CONFIGS = { | |
| mix: { label: 'FineWeb-Edu-HQ + table (50/50)', color: '#EBA937' }, | |
| baseline: { label: 'FineWeb-Edu-HQ (baseline)', color: '#8b8b8b' }, | |
| }; | |
| const SIZES = ['0.5b', '1.7b', '2.9b', '6.2b']; | |
| const sizeLabel = (s) => s.toUpperCase(); | |
| // Variance-source colors for the decomposition view; residual is muted to | |
| // read as "unexplained". First two come from the shared palette. | |
| const srcDefs = () => { | |
| const pal = (window.ColorPalettes && window.ColorPalettes.getColors) | |
| ? window.ColorPalettes.getColors('categorical', 4) | |
| : ['#4e79a7', '#59a14f', '#e15759', '#bab0ac']; | |
| return [ | |
| { key: 'seed', label: 'Model-init seed', color: pal[0] }, | |
| { key: 'data', label: 'Data order', color: pal[1] }, | |
| { key: 'resid', label: 'Residual / interaction', color: 'var(--muted-color)' }, | |
| ]; | |
| }; | |
| // metric key -> { label, col (CSV column), group }. Labels, optgroups, and | |
| // ordering mirror the main experiments chart (d3-benchmark-comparison.html) | |
| // exactly; macro is the default. | |
| const METRICS = { | |
| agg_score_macro: { label: 'Aggregate Score (Macro)', col: 'agg_score_macro', group: 'Aggregate Scores' }, | |
| agg_score_micro: { label: 'Aggregate Score (Micro)', col: 'agg_score_micro', group: 'Aggregate Scores' }, | |
| agg_score_RC: { label: 'Reading Comprehension', col: 'agg_score_RC', group: 'Aggregate Scores' }, | |
| agg_score_GK: { label: 'General Knowledge', col: 'agg_score_GK', group: 'Aggregate Scores' }, | |
| agg_score_NLU: { label: 'Natural Language Understanding', col: 'agg_score_NLU', group: 'Aggregate Scores' }, | |
| agg_score_MATH: { label: 'Math', col: 'agg_score_MATH', group: 'Aggregate Scores' }, | |
| agg_score_TABLE: { label: 'Table Understanding', col: 'agg_score_TABLE', group: 'Aggregate Scores' }, | |
| agg_score_RES: { label: 'Reasoning', col: 'agg_score_RES', group: 'Aggregate Scores' }, | |
| arc: { label: 'ARC-Easy', col: 'lighteval|arc_cf:easy|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| drop: { label: 'DROP', col: 'lighteval|drop|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| gsm8k: { label: 'GSM8K', col: 'lighteval|gsm8k|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| hellaswag: { label: 'HellaSwag', col: 'lighteval|hellaswag_cf|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| openbookqa: { label: 'OpenBookQA', col: 'lighteval|openbookqa_cf|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| piqa: { label: 'PIQA', col: 'lighteval|piqa_cf|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| squad: { label: 'SQuAD v2', col: 'lighteval|squad_v2|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| treb_qa: { label: 'TriviaQA', col: 'lighteval|treb_qa|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| wikitablequestions: { label: 'WikiTableQuestions', col: 'lighteval|wikitablequestions|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| winogrande: { label: 'Winogrande', col: 'lighteval|winogrande_cf|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| xcsqa: { label: 'XCSQA', col: 'lighteval|xcsqa_cf|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| mmlu: { label: 'MMLU Redux', col: 'lighteval|mmlu_redux_cf:_average|3/prob_norm_token', group: 'Individual Benchmarks' }, | |
| }; | |
| let currentMetric = 'agg_score_macro'; | |
| let currentView = 'dots'; // 'dots' | 'fan' | 'decomp' | |
| let currentSize = '1.7b'; // only used by the fan view | |
| // ---- Controls (below the chart, per authoring directives) ------------- | |
| const svg = d3.select(container).append('svg').attr('width', '100%').style('display', 'block'); | |
| const gRoot = svg.append('g'); | |
| const controls = document.createElement('div'); | |
| controls.className = 'controls'; | |
| const mkSelect = (labelText, options, value) => { | |
| const group = document.createElement('div'); | |
| group.className = 'control-group'; | |
| const label = document.createElement('label'); | |
| const id = `sv-${labelText.replace(/\s+/g, '-').toLowerCase()}-${Math.random().toString(36).slice(2, 7)}`; | |
| label.htmlFor = id; label.textContent = labelText; | |
| const select = document.createElement('select'); | |
| select.id = id; | |
| options.forEach(o => { | |
| const opt = document.createElement('option'); | |
| opt.value = o.value; opt.textContent = o.label; | |
| if (o.value === value) opt.selected = true; | |
| select.appendChild(opt); | |
| }); | |
| group.appendChild(label); group.appendChild(select); | |
| return { group, select }; | |
| }; | |
| const viewCtl = mkSelect('View', [ | |
| { value: 'dots', label: 'By scale' }, | |
| { value: 'fan', label: 'Over training' }, | |
| { value: 'decomp', label: 'Variance source' }, | |
| ], currentView); | |
| const sizeCtl = mkSelect('Model size', SIZES.map(s => ({ value: s, label: sizeLabel(s) })), currentSize); | |
| // Metric select with optgroups; visible label text must be exactly "Metric". | |
| const metricGroup = document.createElement('div'); | |
| metricGroup.className = 'control-group'; | |
| const metricLabel = document.createElement('label'); | |
| const metricId = `sv-metric-${Math.random().toString(36).slice(2, 7)}`; | |
| metricLabel.htmlFor = metricId; metricLabel.textContent = 'Metric'; | |
| const metricSelect = document.createElement('select'); | |
| metricSelect.id = metricId; | |
| ['Aggregate Scores', 'Individual Benchmarks'].forEach(grp => { | |
| const og = document.createElement('optgroup'); | |
| og.label = grp; | |
| Object.entries(METRICS).filter(([, m]) => m.group === grp).forEach(([key, m]) => { | |
| const opt = document.createElement('option'); | |
| opt.value = key; opt.textContent = m.label; | |
| if (key === currentMetric) opt.selected = true; | |
| og.appendChild(opt); | |
| }); | |
| metricSelect.appendChild(og); | |
| }); | |
| metricGroup.appendChild(metricLabel); metricGroup.appendChild(metricSelect); | |
| controls.appendChild(viewCtl.group); | |
| controls.appendChild(sizeCtl.group); | |
| controls.appendChild(metricGroup); | |
| container.appendChild(controls); | |
| // ---- Legend (rebuilt per view) --------------------------------------- | |
| const legend = document.createElement('div'); | |
| legend.className = 'legend'; | |
| legend.innerHTML = '<div class="legend-title">Legend</div>'; | |
| const legendItems = document.createElement('div'); | |
| legendItems.className = 'items'; | |
| legend.appendChild(legendItems); | |
| container.appendChild(legend); | |
| const setLegend = (entries) => { | |
| legendItems.innerHTML = ''; | |
| entries.forEach(c => { | |
| const item = document.createElement('span'); | |
| item.className = 'item'; | |
| item.innerHTML = `<span class="swatch" style="background:${c.color}"></span>${c.label}`; | |
| legendItems.appendChild(item); | |
| }); | |
| }; | |
| // ---- Tooltip --------------------------------------------------------- | |
| const tip = document.createElement('div'); | |
| tip.className = 'd3-tooltip'; | |
| const tipInner = document.createElement('div'); | |
| tip.appendChild(tipInner); | |
| container.appendChild(tip); | |
| const showTip = (html, event) => { | |
| tipInner.innerHTML = html; | |
| tip.style.opacity = '1'; | |
| const cr = container.getBoundingClientRect(); | |
| const mx = event.clientX - cr.left, my = event.clientY - cr.top; | |
| const tw = tip.offsetWidth; | |
| const x = mx + tw + 16 > cr.width ? mx - tw - 12 : mx + 12; | |
| tip.style.transform = `translate(${x}px, ${Math.max(0, my - 40)}px)`; | |
| }; | |
| const hideTip = () => { tip.style.opacity = '0'; tip.style.transform = 'translate(-9999px,-9999px)'; }; | |
| // ---- Data ------------------------------------------------------------ | |
| let mountEl = container; | |
| while (mountEl && !mountEl.getAttribute?.('data-datafiles')) mountEl = mountEl.parentElement; | |
| let providedData = null; | |
| try { | |
| const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null; | |
| if (attr && attr.trim()) providedData = attr.trim().startsWith('[') ? JSON.parse(attr)[0] : attr.trim(); | |
| } catch (_) {} | |
| const ensurePrefix = (p) => (typeof p === 'string' && p && !p.includes('/')) ? `/data/${p}` : p; | |
| const CSV_PATHS = providedData | |
| ? [ensurePrefix(providedData)] | |
| : ['/data/benchmark-results.csv', './assets/data/benchmark-results.csv', '../assets/data/benchmark-results.csv']; | |
| const fetchFirstAvailable = async (paths) => { | |
| for (const p of paths) { | |
| try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) return await r.text(); } catch (_) {} | |
| } | |
| throw new Error('benchmark-results.csv not found'); | |
| }; | |
| // Derive config + trained-student size from the run name. The size suffix | |
| // (-0.5b/-2.9b/-6.2b) only appears at the very end; 1.7b has no suffix and | |
| // must not be confused with the "1.7b" inside the rephraser name. | |
| const parseRun = (runname) => { | |
| const config = runname.startsWith('mix-') ? 'mix' : 'baseline'; | |
| // The size suffix sits on the run stem, before the "--data-seed=" part; | |
| // 1.7b has no suffix and must not match the "1.7b" inside the rephraser name. | |
| const stem = runname.split('--data-seed=')[0]; | |
| let size = '1.7b'; | |
| for (const s of ['0.5b', '2.9b', '6.2b']) if (stem.endsWith('-' + s)) size = s; | |
| return { config, size }; | |
| }; | |
| let records = []; // { config, size, step, seed, raw } | |
| let maxStep = 10000; | |
| const val = (rec, key) => +rec.raw[METRICS[key].col]; | |
| const fmt = (v, d = 3) => (v == null || Number.isNaN(v)) ? '-' : v.toFixed(d); | |
| // ---- Rendering ------------------------------------------------------- | |
| const margin = { top: 18, right: 26, bottom: 54, left: 66 }; | |
| let iw = 0, ih = 0; | |
| function frame() { | |
| const width = container.clientWidth || 820; | |
| const height = Math.max(340, Math.round(width / 2.1)); | |
| svg.attr('width', width).attr('height', height); | |
| gRoot.attr('transform', `translate(${margin.left},${margin.top})`); | |
| iw = width - margin.left - margin.right; | |
| ih = height - margin.top - margin.bottom; | |
| gRoot.selectAll('*').remove(); | |
| } | |
| function drawAxes(x, y, xTicksCfg, xLabel) { | |
| const axesG = gRoot.append('g').attr('class', 'axes'); | |
| axesG.append('g').attr('transform', `translate(0,${ih})`).call(xTicksCfg(d3.axisBottom(x))); | |
| axesG.append('g') | |
| .call(d3.axisLeft(y).ticks(6).tickSize(-iw)) | |
| .call(g => g.selectAll('.tick line').attr('stroke', 'var(--grid-color)')) | |
| .call(g => g.select('.domain').remove()); | |
| gRoot.append('text').attr('class', 'x-label') | |
| .attr('x', iw / 2).attr('y', ih + 42).attr('text-anchor', 'middle').text(xLabel); | |
| gRoot.append('text').attr('class', 'y-label') | |
| .attr('transform', 'rotate(-90)').attr('x', -ih / 2).attr('y', -52) | |
| .attr('text-anchor', 'middle').text(METRICS[currentMetric].label); | |
| } | |
| function renderDots() { | |
| // Final-step mean +/- std per (size, config). | |
| const stats = {}; | |
| let lo = Infinity, hi = -Infinity; | |
| SIZES.forEach(size => { | |
| stats[size] = {}; | |
| Object.keys(CONFIGS).forEach(cfg => { | |
| const vals = records.filter(r => r.size === size && r.config === cfg && r.step === maxStep).map(r => val(r, currentMetric)); | |
| if (!vals.length) return; | |
| const mean = d3.mean(vals), std = d3.deviation(vals) || 0; | |
| stats[size][cfg] = { mean, std, n: vals.length }; | |
| lo = Math.min(lo, mean - std); hi = Math.max(hi, mean + std); | |
| }); | |
| }); | |
| const pad = (hi - lo) * 0.12 || 0.01; | |
| const x = d3.scalePoint().domain(SIZES.map(sizeLabel)).range([0, iw]).padding(0.5); | |
| const y = d3.scaleLinear().domain([Math.max(0, lo - pad), hi + pad]).range([ih, 0]).nice(); | |
| gRoot.append('g').attr('class', 'grid').selectAll('line').data(y.ticks(6)).join('line') | |
| .attr('x1', 0).attr('x2', iw).attr('y1', d => y(d)).attr('y2', d => y(d)); | |
| drawAxes(x, y, (a) => a, 'Trained student size'); | |
| // Gap annotations between baseline and mix at each scale. | |
| SIZES.forEach(size => { | |
| const m = stats[size].mix, b = stats[size].baseline; | |
| if (!m || !b) return; | |
| const cx = x(sizeLabel(size)); | |
| gRoot.append('line').attr('x1', cx).attr('x2', cx) | |
| .attr('y1', y(b.mean)).attr('y2', y(m.mean)) | |
| .attr('stroke', 'var(--muted-color)').attr('stroke-width', 1).attr('stroke-dasharray', '2 3').attr('opacity', 0.7); | |
| gRoot.append('text').attr('class', 'gap-label') | |
| .attr('x', cx + 8).attr('y', y((m.mean + b.mean) / 2) + 4) | |
| .attr('text-anchor', 'start').text(`+${fmt(m.mean - b.mean, 3)}`); | |
| }); | |
| // Connecting line + whiskers + dots per config. | |
| Object.entries(CONFIGS).forEach(([cfg, meta]) => { | |
| const pts = SIZES.filter(s => stats[s][cfg]).map(s => ({ size: s, ...stats[s][cfg] })); | |
| const line = d3.line().x(d => x(sizeLabel(d.size))).y(d => y(d.mean)); | |
| gRoot.append('path').datum(pts).attr('fill', 'none') | |
| .attr('stroke', meta.color).attr('stroke-width', 2.5).attr('opacity', 0.85).attr('d', line); | |
| pts.forEach(p => { | |
| const cx = x(sizeLabel(p.size)); | |
| // whisker | |
| gRoot.append('line').attr('x1', cx).attr('x2', cx).attr('y1', y(p.mean - p.std)).attr('y2', y(p.mean + p.std)) | |
| .attr('stroke', meta.color).attr('stroke-width', 2); | |
| [-1, 1].forEach(s => gRoot.append('line').attr('x1', cx - 5).attr('x2', cx + 5) | |
| .attr('y1', y(p.mean + s * p.std)).attr('y2', y(p.mean + s * p.std)).attr('stroke', meta.color).attr('stroke-width', 2)); | |
| }); | |
| gRoot.append('g').selectAll('circle').data(pts).join('circle') | |
| .attr('cx', d => x(sizeLabel(d.size))).attr('cy', d => y(d.mean)).attr('r', 5) | |
| .attr('fill', meta.color).attr('stroke', 'var(--surface-bg)').attr('stroke-width', 1.5).style('cursor', 'pointer') | |
| .on('mousemove', (event, d) => { | |
| const cv = d.std / d.mean * 100; | |
| showTip(`<div style="margin-bottom:4px"><strong>${sizeLabel(d.size)} student</strong></div> | |
| <div class="row"><span class="name"><span class="swatch" style="background:${meta.color}"></span>${meta.label}</span></div> | |
| <div class="row"><span>mean</span><strong>${fmt(d.mean)}</strong></div> | |
| <div class="row"><span>std (n=${d.n})</span><strong>${fmt(d.std, 4)}</strong></div> | |
| <div class="row"><span>CV</span><strong>${cv.toFixed(2)}%</strong></div>`, event); | |
| }) | |
| .on('mouseleave', hideTip); | |
| }); | |
| } | |
| function renderFan() { | |
| const byCfg = {}; | |
| const stepsSet = new Set(); | |
| Object.keys(CONFIGS).forEach(cfg => { | |
| const recs = records.filter(r => r.size === currentSize && r.config === cfg); | |
| const bySeed = d3.group(recs, r => r.seed); | |
| const byStep = d3.group(recs, r => r.step); | |
| const band = Array.from(byStep, ([step, rs]) => { | |
| const vs = rs.map(r => val(r, currentMetric)); | |
| stepsSet.add(step); | |
| return { step, min: d3.min(vs), max: d3.max(vs), median: d3.median(vs) }; | |
| }).sort((a, b) => a.step - b.step); | |
| const seeds = Array.from(bySeed, ([seed, rs]) => ({ seed, pts: rs.slice().sort((a, b) => a.step - b.step) })); | |
| byCfg[cfg] = { band, seeds }; | |
| }); | |
| const steps = Array.from(stepsSet).sort((a, b) => a - b); | |
| // Fix the y-domain across every model size (not just the selected one) so the | |
| // axis ticks stay identical when switching the size selector. | |
| const allVals = records.map(r => val(r, currentMetric)); | |
| const lo = d3.min(allVals), hi = d3.max(allVals); | |
| const pad = (hi - lo) * 0.06 || 0.01; | |
| const x = d3.scaleLinear().domain(d3.extent(steps)).range([0, iw]); | |
| const y = d3.scaleLinear().domain([Math.max(0, lo - pad), hi + pad]).range([ih, 0]).nice(); | |
| gRoot.append('g').attr('class', 'grid').selectAll('line').data(y.ticks(6)).join('line') | |
| .attr('x1', 0).attr('x2', iw).attr('y1', d => y(d)).attr('y2', d => y(d)); | |
| drawAxes(x, y, (a) => a.ticks(6).tickFormat(d3.format('~s')), 'Training step'); | |
| const area = d3.area().x(d => x(d.step)).y0(d => y(d.min)).y1(d => y(d.max)); | |
| const medLine = d3.line().x(d => x(d.step)).y(d => y(d.median)); | |
| const seedLine = d3.line().x(d => x(d.step)).y(d => y(val(d, currentMetric))); | |
| Object.entries(CONFIGS).forEach(([cfg, meta]) => { | |
| const { band, seeds } = byCfg[cfg]; | |
| gRoot.append('path').datum(band).attr('fill', meta.color).attr('opacity', 0.14).attr('d', area); | |
| seeds.forEach(s => gRoot.append('path').datum(s.pts).attr('fill', 'none') | |
| .attr('stroke', meta.color).attr('stroke-width', 1).attr('opacity', 0.22).attr('d', seedLine)); | |
| gRoot.append('path').datum(band).attr('fill', 'none') | |
| .attr('stroke', meta.color).attr('stroke-width', 2.6).attr('d', medLine); | |
| }); | |
| // Hover bisector across both configs. | |
| const overlay = gRoot.append('rect').attr('width', iw).attr('height', ih).attr('fill', 'transparent'); | |
| const hoverLine = gRoot.append('line').attr('y1', 0).attr('y2', ih) | |
| .attr('stroke', 'var(--muted-color)').attr('stroke-dasharray', '3 3').style('opacity', 0); | |
| overlay.on('mousemove', (event) => { | |
| const [mx] = d3.pointer(event); | |
| const xv = x.invert(mx); | |
| const nearest = steps.reduce((a, b) => Math.abs(b - xv) < Math.abs(a - xv) ? b : a); | |
| hoverLine.attr('x1', x(nearest)).attr('x2', x(nearest)).style('opacity', 1); | |
| const rows = Object.entries(CONFIGS).map(([cfg, meta]) => { | |
| const d = byCfg[cfg].band.find(p => p.step === nearest); | |
| if (!d) return ''; | |
| return `<div class="row"><span class="name"><span class="swatch" style="background:${meta.color}"></span>${meta.label}</span> | |
| <span><strong>${fmt(d.median)}</strong> <span style="color:var(--muted-color)">[${fmt(d.min)}-${fmt(d.max)}]</span></span></div>`; | |
| }).join(''); | |
| showTip(`<div style="margin-bottom:4px"><strong>${sizeLabel(currentSize)} student, step ${nearest.toLocaleString()}</strong></div>${rows}`, event); | |
| }).on('mouseleave', () => { hoverLine.style('opacity', 0); hideTip(); }); | |
| } | |
| // Two-way (seed x data-order) variance decomposition with one observation | |
| // per cell, so the residual term captures interaction plus noise. Shares | |
| // are eta^2 = SS_factor / SS_total, matching the experiment report. | |
| function eta2(cellRecs, metricKey) { | |
| const obs = cellRecs.map(r => ({ s: +r.seed % 100, d: Math.floor(+r.seed / 100), v: val(r, metricKey) })); | |
| const grand = d3.mean(obs, o => o.v); | |
| const sst = d3.sum(obs, o => (o.v - grand) ** 2); | |
| if (!sst) return { seed: 0, data: 0, resid: 0 }; | |
| const seeds = Array.from(new Set(obs.map(o => o.s))); | |
| const orders = Array.from(new Set(obs.map(o => o.d))); | |
| let ssSeed = 0, ssData = 0; | |
| seeds.forEach(si => { const m = d3.mean(obs.filter(o => o.s === si), o => o.v); ssSeed += orders.length * (m - grand) ** 2; }); | |
| orders.forEach(dj => { const m = d3.mean(obs.filter(o => o.d === dj), o => o.v); ssData += seeds.length * (m - grand) ** 2; }); | |
| const ssResid = Math.max(0, sst - ssSeed - ssData); | |
| return { seed: ssSeed / sst * 100, data: ssData / sst * 100, resid: ssResid / sst * 100 }; | |
| } | |
| function renderDecomp() { | |
| const SRC = srcDefs(); | |
| const cfgKeys = Object.keys(CONFIGS); | |
| const x0 = d3.scaleBand().domain(SIZES.map(sizeLabel)).range([0, iw]).paddingInner(0.28).paddingOuter(0.12); | |
| const x1 = d3.scaleBand().domain(cfgKeys).range([0, x0.bandwidth()]).padding(0.18); | |
| const y = d3.scaleLinear().domain([0, 100]).range([ih, 0]); | |
| gRoot.append('g').attr('class', 'grid').selectAll('line').data(y.ticks(5)).join('line') | |
| .attr('x1', 0).attr('x2', iw).attr('y1', d => y(d)).attr('y2', d => y(d)); | |
| gRoot.append('g').attr('class', 'axes') | |
| .call(d3.axisLeft(y).ticks(5).tickFormat(d => d + '%')); | |
| gRoot.append('text').attr('class', 'y-label').attr('transform', 'rotate(-90)') | |
| .attr('x', -ih / 2).attr('y', -52).attr('text-anchor', 'middle').text('Share of run-to-run variance'); | |
| SIZES.forEach(size => { | |
| const gx = x0(sizeLabel(size)); | |
| gRoot.append('text').attr('class', 'x-label').attr('x', gx + x0.bandwidth() / 2).attr('y', ih + 34) | |
| .attr('text-anchor', 'middle').text(sizeLabel(size)); | |
| cfgKeys.forEach(cfg => { | |
| const recs = records.filter(r => r.size === size && r.config === cfg && r.step === maxStep); | |
| if (!recs.length) return; | |
| const e = eta2(recs, currentMetric); | |
| const bx = gx + x1(cfg), bw = x1.bandwidth(); | |
| const rows = SRC.map(s => `<div class="row"><span class="name"><span class="swatch" style="background:${s.color}"></span>${s.label}</span><strong>${e[s.key].toFixed(0)}%</strong></div>`).join(''); | |
| // Stack bottom-to-top in reverse of SRC so residual sits at the bottom | |
| // and the bar's top-to-bottom order matches the legend/tooltip order. | |
| let acc = 0; | |
| [...SRC].reverse().forEach(src => { | |
| const v = e[src.key]; | |
| gRoot.append('rect').attr('x', bx).attr('width', bw) | |
| .attr('y', y(acc + v)).attr('height', Math.max(0, y(acc) - y(acc + v))) | |
| .attr('fill', src.color).style('cursor', 'pointer') | |
| .on('mousemove', (event) => showTip(`<div style="margin-bottom:4px"><strong>${sizeLabel(size)} ${CONFIGS[cfg].label}</strong></div>${rows}`, event)) | |
| .on('mouseleave', hideTip); | |
| acc += v; | |
| }); | |
| gRoot.append('text').attr('x', bx + bw / 2).attr('y', ih + 15).attr('text-anchor', 'middle') | |
| .attr('fill', 'var(--muted-color)').attr('font-size', 11).text(cfg === 'mix' ? 'mix' : 'base'); | |
| }); | |
| }); | |
| } | |
| function render() { | |
| if (!records.length) return; | |
| sizeCtl.group.style.display = currentView === 'fan' ? '' : 'none'; | |
| setLegend(currentView === 'decomp' | |
| ? srcDefs().map(s => ({ label: s.label, color: s.color })) | |
| : Object.values(CONFIGS).map(c => ({ label: c.label, color: c.color }))); | |
| frame(); | |
| if (currentView === 'dots') renderDots(); | |
| else if (currentView === 'fan') renderFan(); | |
| else renderDecomp(); | |
| } | |
| viewCtl.select.addEventListener('change', () => { currentView = viewCtl.select.value; render(); }); | |
| sizeCtl.select.addEventListener('change', () => { currentSize = sizeCtl.select.value; render(); }); | |
| metricSelect.addEventListener('change', () => { currentMetric = metricSelect.value; render(); }); | |
| fetchFirstAvailable(CSV_PATHS).then(text => { | |
| const rows = d3.csvParse(text); | |
| records = rows.filter(r => r.runname.includes('data-seed=')).map(r => { | |
| const { config, size } = parseRun(r.runname); | |
| return { config, size, step: +r.steps, seed: r.seed, raw: r }; | |
| }); | |
| maxStep = d3.max(records, r => r.step); | |
| render(); | |
| }).catch(err => { | |
| const pre = document.createElement('pre'); | |
| pre.style.color = 'red'; | |
| pre.textContent = `Error loading data: ${err.message}`; | |
| container.appendChild(pre); | |
| }); | |
| if (window.ResizeObserver) new ResizeObserver(() => render()).observe(container); | |
| else window.addEventListener('resize', render); | |
| }; | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); | |
| } else { | |
| ensureD3(bootstrap); | |
| } | |
| })(); | |
| </script> | |