|
6 | 6 | const $ = (s) => document.querySelector(s); |
7 | 7 | const track = (n, p) => window.hubTrack && window.hubTrack(n, p); |
8 | 8 | const N = 800, SVG_NS = "http://www.w3.org/2000/svg"; |
9 | | - let seed = 7, base = null, started = false, solved = false; |
| 9 | + let seed = 9, base = null, started = false, solved = false; |
10 | 10 |
|
11 | 11 | function rng(a) { return () => { 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; }; } |
12 | 12 | function draw(s) { |
|
24 | 24 | if (Math.abs(A[c][c]) < 1e-9) return NaN; |
25 | 25 | for (let r2 = 0; r2 < 3; r2++) if (r2 !== c) { const f = A[r2][c] / A[c][c]; for (let k = c; k < 3; k++) A[r2][k] -= f * A[c][k]; b[r2] -= f * b[c]; } |
26 | 26 | } |
27 | | - return b[1] / A[1][1]; |
| 27 | + return [b[0] / A[0][0], b[1] / A[1][1], b[2] / A[2][2]]; |
28 | 28 | } |
29 | 29 | const fmt = (v) => (Number.isFinite(v) ? `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(1)}` : "계산 불가"); |
30 | 30 |
|
31 | 31 | function compute() { |
32 | 32 | const conf = Number($("#in-conf").value), eff = Number($("#in-eff").value), rand = $("#in-rand").getAttribute("aria-pressed") === "true"; |
33 | | - const k = conf * 0.4; |
| 33 | + const k = conf * 0.12; |
34 | 34 | const rows = base.map((d) => { const p = rand ? 0.5 : 1 / (1 + Math.exp(-k * d.z)); const t = d.u < p ? 1 : 0; return { z: d.z, t, y: 50 + 6 * d.z + eff * t + d.e }; }); |
35 | 35 | const on = rows.filter((d) => d.t), off = rows.filter((d) => !d.t); |
36 | 36 | const mean = (a) => a.reduce((s, d) => s + d.y, 0) / (a.length || 1); |
37 | 37 | const mOn = mean(on), mOff = mean(off); |
38 | | - return { conf, eff, rand, rows, mOn, mOff, naive: mOn - mOff, adj: ols(rows), nOn: on.length }; |
| 38 | + const fit = ols(rows); |
| 39 | + return { conf, eff, rand, rows, mOn, mOff, naive: mOn - mOff, adj: Array.isArray(fit) ? fit[1] : NaN, fit, nOn: on.length }; |
39 | 40 | } |
40 | 41 |
|
41 | 42 | function bars(r) { |
|
44 | 45 | $("#bar-true").style.width = w(r.eff); $("#bar-naive").style.width = w(r.naive); $("#bar-adj").style.width = w(r.adj); |
45 | 46 | $("#v-true").textContent = fmt(r.eff); $("#v-naive").textContent = fmt(r.naive); $("#v-adj").textContent = fmt(r.adj); |
46 | 47 | const bias = r.naive - r.eff; |
47 | | - $("#lab-bias").innerHTML = Math.abs(bias) <= 1 |
48 | | - ? `단순 비교의 편향 <b>${fmt(bias)}</b>: 진짜 효과에 거의 붙었습니다.` |
49 | | - : `단순 비교의 편향 <b>${fmt(bias)}</b>: 진짜 효과보다 ${Math.abs(bias).toFixed(1)}점 ${bias > 0 ? "부풀려졌습니다" : "작게 나왔습니다"}.`; |
| 48 | + $("#lab-bias").innerHTML = r.rand |
| 49 | + ? `단순 비교와 진짜 효과의 차이 <b>${fmt(bias)}</b>: 800명 표본의 우연 오차 범위(±1 안팎)입니다.` |
| 50 | + : Math.abs(bias) <= 1 |
| 51 | + ? `단순 비교의 편향 <b>${fmt(bias)}</b>: 진짜 효과에 거의 붙었습니다.` |
| 52 | + : `단순 비교의 편향 <b>${fmt(bias)}</b>: 진짜 효과보다 ${Math.abs(bias).toFixed(1)}점 ${bias > 0 ? "부풀려졌습니다" : "작게 나왔습니다"}.`; |
50 | 53 | $("#out-conf").textContent = r.conf; $("#out-eff").textContent = `+${r.eff}`; |
| 54 | + /* while randomized, loyalty no longer decides who turns notifications on */ |
| 55 | + const ctl = $("#in-conf").closest(".lab-ctl"); |
| 56 | + ctl.classList.toggle("off", r.rand); |
| 57 | + $("#in-conf").setAttribute("aria-disabled", String(r.rand)); |
| 58 | + ctl.querySelector("small").textContent = r.rand ? "무작위 배정 중: 충성도가 알림을 정하지 못합니다" : "충성도가 알림 켜기에 얼마나 영향을 주나"; |
51 | 59 | } |
52 | 60 |
|
53 | 61 | function chart(r) { |
54 | 62 | const svg = $("#lab-svg"); while (svg.lastChild && svg.lastChild.nodeName !== "desc") svg.removeChild(svg.lastChild); |
55 | 63 | /* draw in the chart's own pixel size so labels stay readable on phones */ |
56 | | - const W = Math.max(300, Math.round(svg.parentElement.clientWidth - 40)), H = Math.round(Math.min(360, Math.max(260, W * 0.62))); |
| 64 | + const W = Math.max(300, Math.round(svg.parentElement.clientWidth - 40)), H = W < 480 ? 220 : Math.round(Math.min(360, Math.max(260, W * 0.62))); |
57 | 65 | svg.setAttribute("viewBox", `0 0 ${W} ${H}`); |
58 | 66 | const small = W < 480, dot = small ? 2 : 2.6; |
59 | 67 | const L = 40, R = W - 16, T = 14, B = H - 40, X = (z) => L + ((Math.max(-3, Math.min(3, z)) + 3) / 6) * (R - L), Y = (y) => B - ((Math.max(15, Math.min(85, y)) - 15) / 70) * (B - T); |
|
64 | 72 | el("text", { x: R, y: B + 34, class: "axis-t", "text-anchor": "end" }, g).textContent = "충성도 →"; |
65 | 73 | const dots = el("g", { class: "dots" }); |
66 | 74 | r.rows.forEach((d) => el("circle", { cx: X(d.z).toFixed(1), cy: Y(d.y).toFixed(1), r: dot, class: d.t ? "on" : "off" }, dots)); |
| 75 | + /* the adjusted estimate: two parallel fitted lines, one per group, at the same loyalty */ |
| 76 | + if (Array.isArray(r.fit)) { |
| 77 | + const [a, tau, bz] = r.fit, fitG = el("g", { class: "fit" }); |
| 78 | + [[0, "off"], [1, "on"]].forEach(([tt, cls]) => el("line", { x1: X(-3), x2: X(3), y1: Y(a + bz * -3 + tau * tt), y2: Y(a + bz * 3 + tau * tt), class: `fit-l ${cls}` }, fitG)); |
| 79 | + const zl = -2.2, yOn = Y(a + bz * zl + tau), yOff = Y(a + bz * zl); |
| 80 | + el("line", { x1: X(zl), x2: X(zl), y1: yOn, y2: yOff, class: "fit-gap" }, fitG); |
| 81 | + el("text", { x: X(zl) + 6, y: (yOn + yOff) / 2 + 4, class: "fit-t" }, fitG).textContent = `같은 충성도에서의 간격 = 보정 ${fmt(tau)}`; |
| 82 | + } |
67 | 83 | const means = el("g", { class: "means" }); |
68 | 84 | [["on", r.mOn, "켬 평균"], ["off", r.mOff, "끔 평균"]].forEach(([cls, m, label]) => { |
69 | 85 | el("line", { x1: L, x2: R, y1: Y(m), y2: Y(m), class: `mean ${cls}` }, means); |
70 | 86 | const t = el("text", { x: R - 4, y: Y(m) + (cls === "on" ? -6 : 14), class: `mean-t ${cls}`, "text-anchor": "end" }, means); t.textContent = `${label} ${m.toFixed(1)}`; |
71 | 87 | }); |
72 | 88 | } |
73 | 89 |
|
| 90 | + let moves = 0; |
74 | 91 | function challenge(r) { |
75 | 92 | if (solved) return; |
76 | | - const ok = r.conf >= 5 && Math.abs(r.naive - r.eff) <= 1; |
77 | | - if (!ok) return; |
78 | | - solved = true; |
79 | | - $("#lab-challenge").classList.add("done"); |
80 | | - $("#lab-ch-text").innerHTML = r.rand |
81 | | - ? "해냈습니다. 무작위로 켜면 충성도와 알림이 따로 움직여서, 단순 비교만으로도 진짜 효과에 가까워집니다. 이것이 무작위 실험이 하는 일입니다." |
82 | | - : "해냈습니다. 이번 표본에서는 우연히 편향이 작게 나왔습니다. ‘알림을 무작위로 켜기’를 눌러 보면 왜 실험이 확실한 방법인지 보입니다."; |
83 | | - track("lab_challenge_complete", { randomized: r.rand, conf: r.conf }); |
84 | | - if (window.hubStamp) window.hubStamp("lab"); |
| 93 | + const close = Math.abs(r.naive - r.eff) <= 1; |
| 94 | + if (r.conf >= 5 && r.rand && close) { |
| 95 | + solved = true; |
| 96 | + $("#lab-challenge").classList.add("done"); |
| 97 | + $("#lab-ch-text").textContent = "해냈습니다. 무작위로 켜면 충성도와 알림이 따로 움직여서, 단순 비교만으로도 진짜 효과에 가까워집니다. 이것이 무작위 실험이 하는 일입니다."; |
| 98 | + track("lab_challenge_complete", { randomized: true, conf: r.conf }); |
| 99 | + if (window.hubStamp) window.hubStamp("lab"); |
| 100 | + return; |
| 101 | + } |
| 102 | + if (r.rand && r.conf >= 5) { |
| 103 | + $("#lab-ch-text").textContent = "무작위 배정이 맞는 답입니다. 지금 남은 차이는 800명 표본의 우연 오차입니다. ‘새 사용자 800명 뽑기’를 눌러 보세요."; |
| 104 | + } else if (moves >= 8) { |
| 105 | + $("#lab-ch-text").textContent = "힌트: 교란 강도는 5 이상으로 그대로 두고, 슬라이더가 아닌 다른 버튼을 찾아보세요."; |
| 106 | + } |
85 | 107 | } |
86 | 108 |
|
87 | 109 | function update(source) { |
| 110 | + if (source === "confounding" || source === "effect") moves += 1; |
88 | 111 | const r = compute(); |
89 | 112 | bars(r); chart(r); challenge(r); |
90 | 113 | if (source && !started) { started = true; track("lab_start", { control: source }); } |
|
94 | 117 | base = draw(seed); |
95 | 118 | $("#in-conf").addEventListener("input", () => update("confounding")); |
96 | 119 | $("#in-eff").addEventListener("input", () => update("effect")); |
97 | | - $("#in-rand").addEventListener("click", (e) => { const b = e.currentTarget; b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true")); update("randomize"); track("lab_randomize", { on: b.getAttribute("aria-pressed") === "true" }); }); |
| 120 | + $("#in-rand").addEventListener("click", (e) => { const b = e.currentTarget; b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true")); track("lab_randomize", { on: b.getAttribute("aria-pressed") === "true" }); update("randomize"); }); |
98 | 121 | $("#in-seed").addEventListener("click", () => { seed += 1; base = draw(seed); update("reseed"); }); |
99 | 122 | $("#lab-share").addEventListener("click", async (e) => { |
100 | 123 | const url = window.hubShareUrl ? window.hubShareUrl("/lab.html", "lab") : location.href; |
|
0 commit comments