+
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 988c7db..49ddb49 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,14 +14,16 @@
| 파일 | 고치는 경우 |
| --- | --- |
| `data/featured.json` | 첫 화면 배너의 대표 오픈소스 링크를 바꿀 때. `repo`를 적으면 스타 수가 함께 나옵니다. |
-| `data/projects.json` | 기수별 프로젝트를 추가할 때. 러너는 인원수(`runners`)만 적습니다. |
-| `data/builders.json` | Builder의 LinkedIn, GitHub 아이디(프로필 사진에 쓰임), 한 줄 소개, Builder가 되기 전 러너로 참여한 프로젝트(`runnerOf`)를 넣을 때 |
+| `data/projects.json` | 기수별 프로젝트를 추가할 때. 러너는 인원수(`runners`)만 적습니다. Builder가 회고를 보내 주면 `"retro": {"why": "…", "learned": "…", "next": "…"}`로 넣습니다. 비어 있으면 화면에 나오지 않습니다. |
+| `data/builders.json` | Builder의 LinkedIn, GitHub 아이디(프로필 사진에 쓰임), 한 줄 소개, Builder가 되기 전 러너로 참여한 프로젝트(`runnerOf`)를 넣을 때. `slug`는 Builder 페이지 주소(`/builders/.html`)가 되므로 한 번 정하면 바꾸지 않습니다. |
| `data/growth.json` | 기수별 누적 러너 수. 프로젝트 시트를 xlsx로 내려받아 `python scripts/runners_from_sheet.py 파일.xlsx > data/growth.json`으로 만듭니다. 이름은 파일에 남지 않습니다. |
| `data/events.json` | Data Forum 같은 행사를 추가할 때 |
| `data/repos.json` | 레포를 추가하거나 설명·단계·트랙·상태를 바꿀 때 |
| `data/map.json` | 트랙 지도에서 레포의 위치를 정할 때. 책과 강의(`paid`)의 표지와 소개도 여기서 고칩니다. |
| `data/routes.json` | 경로 안내 항목을 바꿀 때 |
| `data/articles.json`, `articles/*.md` | 읽을거리를 추가할 때 |
+| `data/game.json` | ‘상관일까, 인과일까?’ 문항을 고칠 때. `answer`는 `corr` 또는 `cause`이고, 문항마다 웹북 장 링크(`chapter`)를 답니다. |
+| `data/daily.json` | 오늘의 한 문제 문항을 더하거나 고칠 때. 형식은 `game.json`과 같고, 날짜마다 순서대로 돌아갑니다. 인과와 상관 문항을 비슷한 수로 맞춥니다. |
| `data/stats.json` | 직접 고치지 않습니다. 매주 GitHub Actions가 스타 추이와 기여자 수를 갱신합니다. |
레포를 추가할 때의 예시입니다.
@@ -43,6 +45,29 @@
`stars`는 기본값이고, 사이트는 열릴 때 GitHub에서 최신 스타 수와 마지막 커밋 날짜를 불러옵니다.
+## Builder 페이지와 배지
+
+`builders/.html`, `builders/index.html`, `badges/.svg`는 `scripts/build_builders.py`가 데이터 파일로 만듭니다. 만들어지는 파일이라 커밋하지 않고, 배포할 때 GitHub Actions가 실행합니다. 로컬에서 보려면 먼저 실행하세요.
+
+```bash
+python3 scripts/build_builders.py
+```
+
+링크를 공유할 때 보이는 이미지(`assets/og/builders/.jpg`)는 따로 만들어 커밋합니다. Builder가 새로 생기거나 숫자가 바뀌면 다시 만들어 주세요. 이미지가 없는 페이지는 허브 기본 이미지를 씁니다.
+
+```bash
+pip install playwright && python -m playwright install chromium
+python3 scripts/builder_cards.py
+```
+
+## 학습 여권과 실험실
+
+스탬프 정의는 `assets/js/passport.js`의 `STAMPS`에 있습니다. 다른 페이지에서는 `window.hubStamp("lab")`처럼 부르면 되고, 이미 받은 스탬프는 다시 주지 않습니다. 기록은 `localStorage`에만 남습니다. 교란 실험실(`lab.html`, `assets/js/lab.js`)의 데이터는 페이지에서 만드는 가상 사용자입니다.
+
+## 이벤트 측정
+
+`assets/js/track.js`의 `hubTrack(이름, 파라미터)`가 GA4로 이벤트를 보냅니다. GA4가 설치되지 않았으면 아무 일도 하지 않습니다. 이벤트 이름과 파라미터는 PM 문서의 이벤트 사전을 따르고, 새 이벤트를 넣을 때는 사전에도 같이 적습니다. HTML에서는 `data-event="click_join" data-event-params='{"target":"template","source":"builder_page"}'`처럼 속성만으로도 보낼 수 있습니다. 로컬에서 확인하려면 콘솔에서 `window.HUB_DEBUG = true`를 켭니다.
+
## 로컬에서 보기
```bash
diff --git a/README.md b/README.md
index 5bdaa81..c8a067f 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,16 @@
팀 레포 전체는 허브의 트랙 지도와 커리큘럼 탭에서 볼 수 있습니다.
+## 허브에서 해 볼 것
+
+- **오늘의 한 문제:** 첫 화면 아래에 매일 한 문제가 바뀝니다. 사흘 연속으로 풀면 스탬프를 받습니다.
+- **교란 실험실:** 가상 사용자 800명으로 교란이 단순 비교를 얼마나 속이는지 슬라이더로 직접 움직여 봅니다. [실험실 열기](https://causalinferencelab.github.io/lab.html)
+- **학습 여권:** 자료 열기, 진단, 게임, 실험실, 완주 같은 행동마다 스탬프가 쌓입니다. 기록은 내 브라우저에만 남습니다.
+- **3문항으로 시작점 찾기:** 경험, 하는 일, 원하는 것을 고르면 볼 순서와 첫 자료를 추천합니다. [바로 해 보기](https://causalinferencelab.github.io/?quiz=1)
+- **상관일까, 인과일까?:** 데이터 상황 10개를 보고 상관인지 인과인지 고르는 2분짜리 게임입니다. 답마다 관련 웹북 장으로 이어집니다. [게임 하기](https://causalinferencelab.github.io/game.html)
+- **완주 카드:** 한 단계의 자료를 모두 학습 완료로 표시하면, 공유할 수 있는 완주 카드 이미지를 만들 수 있습니다.
+- **Builder 페이지:** 프로젝트를 연 Builder마다 이력, 만든 레포, 배지를 모은 페이지가 있습니다. [Builder 전체 보기](https://causalinferencelab.github.io/builders/)
+
## 커리큘럼: L0–L4
L0–L2는 **배우는 단계**로, 무료 자료만으로 끝까지 갈 수 있습니다. L3–L4는 **만드는 단계**입니다.
diff --git a/article.html b/article.html
index 4627f28..a88a677 100644
--- a/article.html
+++ b/article.html
@@ -2,13 +2,7 @@
-
-
+
읽을거리 | Causal Inference Hub
@@ -33,6 +27,7 @@
읽을거리참여하기
+
@@ -63,5 +58,7 @@
칸은 왼쪽부터 ${cohorts[0]}기–${cohorts[cohorts.length - 1]}기
`;
}
- function openPerson(name) {
+ /* Three lines a Builder writes after a project; nothing shows until they do. */
+ function retroHTML(r) {
+ if (!r || !(r.why || r.learned || r.next)) return "";
+ const row = (k, v) => (v ? `
`;
+ if (!dlg.open) { if (typeof dlg.showModal === "function") dlg.showModal(); else dlg.setAttribute("open", ""); }
+ dlg.querySelector(".station-close").focus();
+ if (window.hubTrack) window.hubTrack("passport_open", { count: n, source: source || "header" });
+ }
+ window.openPassport = passportDialog;
+
+ document.addEventListener("click", (e) => {
+ const b = e.target.closest("[data-passport]");
+ if (b) { e.preventDefault(); passportDialog(b.dataset.passport || "header"); }
+ });
+ document.addEventListener("DOMContentLoaded", () => {
+ paintCounts();
+ /* a Builder page view counts toward the Builder stamp */
+ const m = location.pathname.match(/^\/builders\/([^/]+)\.html$/);
+ if (m) window.hubSeen("builders", m[1], 3, "builder");
+ });
+})();
diff --git a/assets/js/track.js b/assets/js/track.js
new file mode 100644
index 0000000..e0cf985
--- /dev/null
+++ b/assets/js/track.js
@@ -0,0 +1,43 @@
+/* Event layer for every page. Events go to GA4 when it is installed (gtag or dataLayer) and are
+ dropped otherwise, so the code can ship before the measurement ID. Names follow the event
+ dictionary in the PM doc; keep them in snake_case and the params flat. */
+(() => {
+ "use strict";
+ const SITE = "https://causalinferencelab.github.io";
+ window.hubTrack = (name, params = {}) => {
+ try {
+ if (typeof window.gtag === "function") window.gtag("event", name, params);
+ else if (Array.isArray(window.dataLayer)) window.dataLayer.push({ event: name, ...params });
+ if (window.HUB_DEBUG) console.debug("[hub]", name, params);
+ } catch (e) { /* never let measurement break the page */ }
+ };
+ /* Links people share carry utm tags so shared visits can be counted. */
+ window.hubShareUrl = (path, medium) => `${SITE}${path}${path.includes("?") ? "&" : "?"}utm_source=share&utm_medium=${encodeURIComponent(medium)}`;
+ window.hubCopy = async (text) => {
+ try { await navigator.clipboard.writeText(text); return true; } catch (e) {
+ const t = document.createElement("textarea"); t.value = text; t.setAttribute("readonly", ""); t.style.position = "fixed"; t.style.opacity = "0";
+ document.body.appendChild(t); t.select();
+ let ok = false; try { ok = document.execCommand("copy"); } catch (err) { ok = false; }
+ t.remove(); return ok;
+ }
+ };
+ /* Any element with data-event sends that event on click; data-event-params holds flat JSON. */
+ document.addEventListener("click", (e) => {
+ const el = e.target.closest("[data-event]");
+ if (!el) return;
+ let params = {};
+ try { params = JSON.parse(el.dataset.eventParams || "{}"); } catch (err) { params = {}; }
+ window.hubTrack(el.dataset.event, params);
+ });
+ /* Buttons with data-copy copy their value and confirm in place. */
+ document.addEventListener("click", async (e) => {
+ const b = e.target.closest("[data-copy]");
+ if (!b) return;
+ const ok = await window.hubCopy(b.dataset.copy);
+ const label = b.dataset.label || b.textContent;
+ b.dataset.label = label;
+ b.textContent = ok ? "복사했습니다" : "복사하지 못했습니다";
+ setTimeout(() => { b.textContent = label; }, 1600);
+ if (ok && b.dataset.share) window.hubTrack("share", { content: b.dataset.share, method: "copy" });
+ });
+})();
diff --git a/assets/og/builders/bang-taemo.jpg b/assets/og/builders/bang-taemo.jpg
new file mode 100644
index 0000000..f9a0b53
Binary files /dev/null and b/assets/og/builders/bang-taemo.jpg differ
diff --git a/assets/og/builders/cho-haechang.jpg b/assets/og/builders/cho-haechang.jpg
new file mode 100644
index 0000000..cc57a76
Binary files /dev/null and b/assets/og/builders/cho-haechang.jpg differ
diff --git a/assets/og/builders/choi-seyoung.jpg b/assets/og/builders/choi-seyoung.jpg
new file mode 100644
index 0000000..ee69b1c
Binary files /dev/null and b/assets/og/builders/choi-seyoung.jpg differ
diff --git a/assets/og/builders/kim-sangwon.jpg b/assets/og/builders/kim-sangwon.jpg
new file mode 100644
index 0000000..a45c67d
Binary files /dev/null and b/assets/og/builders/kim-sangwon.jpg differ
diff --git a/assets/og/builders/kim-seongsu.jpg b/assets/og/builders/kim-seongsu.jpg
new file mode 100644
index 0000000..48d296b
Binary files /dev/null and b/assets/og/builders/kim-seongsu.jpg differ
diff --git a/assets/og/builders/kwon-myeongseok.jpg b/assets/og/builders/kwon-myeongseok.jpg
new file mode 100644
index 0000000..e7a79a0
Binary files /dev/null and b/assets/og/builders/kwon-myeongseok.jpg differ
diff --git a/assets/og/builders/kwon-namtaek.jpg b/assets/og/builders/kwon-namtaek.jpg
new file mode 100644
index 0000000..a043fe8
Binary files /dev/null and b/assets/og/builders/kwon-namtaek.jpg differ
diff --git a/assets/og/builders/lee-dongwook.jpg b/assets/og/builders/lee-dongwook.jpg
new file mode 100644
index 0000000..768c1b3
Binary files /dev/null and b/assets/og/builders/lee-dongwook.jpg differ
diff --git a/assets/og/builders/park-isak.jpg b/assets/og/builders/park-isak.jpg
new file mode 100644
index 0000000..a183f91
Binary files /dev/null and b/assets/og/builders/park-isak.jpg differ
diff --git a/assets/og/builders/park-sangho.jpg b/assets/og/builders/park-sangho.jpg
new file mode 100644
index 0000000..2d8aa3b
Binary files /dev/null and b/assets/og/builders/park-sangho.jpg differ
diff --git a/assets/og/builders/shin-jinsoo.jpg b/assets/og/builders/shin-jinsoo.jpg
new file mode 100644
index 0000000..1cb1a2c
Binary files /dev/null and b/assets/og/builders/shin-jinsoo.jpg differ
diff --git a/data/builders.json b/data/builders.json
index 169532c..72004b0 100644
--- a/data/builders.json
+++ b/data/builders.json
@@ -1,13 +1,13 @@
[
- {"name": "신진수", "linkedin": "https://www.linkedin.com/in/jinsoo-shin-436060162/", "github": "jsshin2022", "intro": "", "runnerOf": [{"cohort": 8, "project": "Kafka - Zero to Hero 실시간 성장하기"}, {"cohort": 10, "project": "Marketing Science"}]},
- {"name": "김성수", "linkedin": "https://www.linkedin.com/in/%EC%84%B1%EC%88%98-%EA%B9%80-50825717b/", "github": "", "intro": "", "runnerOf": [{"cohort": 4, "project": "Casual하게 Causality 이해하기"}, {"cohort": 5, "project": "Casual하지 않게 Causality 이해하기"}, {"cohort": 6, "project": "Case로 Causality 이해하기"}, {"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]},
- {"name": "방태모", "linkedin": "https://www.linkedin.com/in/taemo/", "github": "", "intro": "", "runnerOf": [{"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]},
- {"name": "박상호", "linkedin": "https://www.linkedin.com/in/shstat1729/", "github": "", "intro": "", "runnerOf": [{"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]},
- {"name": "이동욱", "linkedin": "https://www.linkedin.com/in/dwlee-1995-lkdin/", "github": "", "intro": "", "runnerOf": []},
- {"name": "박이삭", "linkedin": "https://www.linkedin.com/in/%EC%9D%B4%EC%82%AD-%EB%B0%95-75a7a916a/", "github": "", "intro": "", "runnerOf": [{"cohort": 7, "project": "Gamifying Causal Inference with Game"}, {"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]},
- {"name": "권남택", "linkedin": "https://www.linkedin.com/in/namtaek-kwon/", "github": "", "intro": "", "runnerOf": []},
- {"name": "권명석", "linkedin": "https://www.linkedin.com/in/myeongseok-gwon-b41414312/", "github": "", "intro": "", "runnerOf": [{"cohort": 12, "project": "Causal Studio: Causal Inference Handbook & Visualization"}]},
- {"name": "김상원", "linkedin": "https://www.linkedin.com/in/sangwon-kim-1438ba221/", "github": "", "intro": "", "runnerOf": []},
- {"name": "최세영", "linkedin": "https://www.linkedin.com/in/seyoung4503/", "github": "", "intro": "", "runnerOf": [{"cohort": 11, "project": "Lang2SQL"}]},
- {"name": "조해창", "linkedin": "https://www.linkedin.com/in/hae-chang-cho/", "github": "", "intro": "", "runnerOf": [{"cohort": 11, "project": "Awesome Causal Inference with Python"}]}
+ {"name": "신진수", "slug": "shin-jinsoo", "linkedin": "https://www.linkedin.com/in/jinsoo-shin-436060162/", "github": "jsshin2022", "intro": "", "runnerOf": [{"cohort": 8, "project": "Kafka - Zero to Hero 실시간 성장하기"}, {"cohort": 10, "project": "Marketing Science"}]},
+ {"name": "김성수", "slug": "kim-seongsu", "linkedin": "https://www.linkedin.com/in/%EC%84%B1%EC%88%98-%EA%B9%80-50825717b/", "github": "", "intro": "", "runnerOf": [{"cohort": 4, "project": "Casual하게 Causality 이해하기"}, {"cohort": 5, "project": "Casual하지 않게 Causality 이해하기"}, {"cohort": 6, "project": "Case로 Causality 이해하기"}, {"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]},
+ {"name": "방태모", "slug": "bang-taemo", "linkedin": "https://www.linkedin.com/in/taemo/", "github": "", "intro": "", "runnerOf": [{"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]},
+ {"name": "박상호", "slug": "park-sangho", "linkedin": "https://www.linkedin.com/in/shstat1729/", "github": "", "intro": "", "runnerOf": [{"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]},
+ {"name": "이동욱", "slug": "lee-dongwook", "linkedin": "https://www.linkedin.com/in/dwlee-1995-lkdin/", "github": "", "intro": "", "runnerOf": []},
+ {"name": "박이삭", "slug": "park-isak", "linkedin": "https://www.linkedin.com/in/%EC%9D%B4%EC%82%AD-%EB%B0%95-75a7a916a/", "github": "", "intro": "", "runnerOf": [{"cohort": 7, "project": "Gamifying Causal Inference with Game"}, {"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]},
+ {"name": "권남택", "slug": "kwon-namtaek", "linkedin": "https://www.linkedin.com/in/namtaek-kwon/", "github": "", "intro": "", "runnerOf": []},
+ {"name": "권명석", "slug": "kwon-myeongseok", "linkedin": "https://www.linkedin.com/in/myeongseok-gwon-b41414312/", "github": "", "intro": "", "runnerOf": [{"cohort": 12, "project": "Causal Studio: Causal Inference Handbook & Visualization"}]},
+ {"name": "김상원", "slug": "kim-sangwon", "linkedin": "https://www.linkedin.com/in/sangwon-kim-1438ba221/", "github": "", "intro": "", "runnerOf": []},
+ {"name": "최세영", "slug": "choi-seyoung", "linkedin": "https://www.linkedin.com/in/seyoung4503/", "github": "", "intro": "", "runnerOf": [{"cohort": 11, "project": "Lang2SQL"}]},
+ {"name": "조해창", "slug": "cho-haechang", "linkedin": "https://www.linkedin.com/in/hae-chang-cho/", "github": "", "intro": "", "runnerOf": [{"cohort": 11, "project": "Awesome Causal Inference with Python"}]}
]
diff --git a/data/daily.json b/data/daily.json
new file mode 100644
index 0000000..88ce7dd
--- /dev/null
+++ b/data/daily.json
@@ -0,0 +1,192 @@
+{
+ "title": "오늘의 한 문제",
+ "questions": [
+ {
+ "id": "reviews",
+ "text": "리뷰가 많은 상품일수록 판매량이 많았다.",
+ "claim": "리뷰를 늘리면 판매가 는다",
+ "answer": "corr",
+ "why": "많이 팔린 상품에 리뷰가 많이 쌓이는 방향도 있습니다. 원인과 결과가 서로를 움직이면 관측만으로는 방향을 가를 수 없습니다.",
+ "chapter": {
+ "title": "1장 인과추론 소개",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/01-Introduction-To-Causality.html"
+ }
+ },
+ {
+ "id": "onboarding",
+ "text": "새 온보딩을 도입한 국가와 비교 국가의 가입 전환율은 도입 전 2년 동안 나란히 움직였다. 도입 뒤에는 도입 국가만 올랐고, 같은 시기 그 나라에만 있던 다른 캠페인은 없었다. 차이는 매달 같은 방향으로 이어졌다.",
+ "claim": "새 온보딩이 가입 전환율을 올렸다",
+ "answer": "cause",
+ "why": "도입이 없었다면 두 나라가 계속 나란히 움직였을 것이라는 평행 추세 가정이 받아들일 만합니다. 그러면 벌어진 차이를 온보딩의 효과로 읽을 수 있습니다.",
+ "chapter": {
+ "title": "13장 이중차분법",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html"
+ }
+ },
+ {
+ "id": "delivery_geo",
+ "text": "전국 250개 시군구 중 무작위로 고른 125곳에서만 한 달 동안 배달비를 낮췄더니, 그 지역의 주문이 다른 지역보다 12% 늘었고 통계적으로 유의했다.",
+ "claim": "배달비 인하가 주문을 늘렸다",
+ "answer": "cause",
+ "why": "배달비를 낮출 지역을 무작위로 골랐기 때문에 지역 간 다른 차이는 평균적으로 고르게 나뉩니다. 지역 단위 무작위 실험입니다.",
+ "chapter": {
+ "title": "2장 무작위 실험",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html"
+ }
+ },
+ {
+ "id": "firefighters",
+ "text": "소방관이 많이 출동한 화재일수록 피해액이 컸다.",
+ "claim": "소방관이 많이 출동하면 피해가 커진다",
+ "answer": "corr",
+ "why": "큰 불일수록 소방관이 많이 출동하고 피해도 큽니다. 화재 규모가 둘을 함께 움직이는 교란 요인입니다.",
+ "chapter": {
+ "title": "4장 그래프 인과 모델",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/04-Graphical-Causal-Models.html"
+ }
+ },
+ {
+ "id": "tutoring_show",
+ "text": "추첨으로 절반의 학생에게 무료 과외를 배정했다. 그중 실제로 과외에 꾸준히 나온 학생만 골라, 배정받지 못한 학생과 비교했더니 점수 차이가 컸다.",
+ "claim": "과외가 그만큼 점수를 올렸다",
+ "answer": "corr",
+ "why": "배정은 추첨이었지만, 꾸준히 나온 학생은 스스로 골라졌습니다. 성실한 학생이 더 잘 나옵니다. 배정 기준으로 그대로 비교하거나, 배정을 도구변수로 써서 ‘배정되면 나오는 학생’에 대한 효과를 따로 추정해야 합니다.",
+ "chapter": {
+ "title": "9장 불응과 LATE",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/09-Non-Compliance-and-LATE.html"
+ }
+ },
+ {
+ "id": "tutoring",
+ "text": "학생 1,000명 중 추첨으로 절반에게만 무료 과외를 제공했다. 과외를 받은 쪽의 시험 점수가 평균 6점 높았고, 통계적으로 유의했다.",
+ "claim": "무료 과외가 점수를 올렸다",
+ "answer": "cause",
+ "why": "추첨으로 나눴기 때문에 두 집단은 과외 말고는 평균적으로 같습니다. 남은 차이는 과외의 효과로 볼 수 있습니다.",
+ "chapter": {
+ "title": "2장 무작위 실험",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html"
+ }
+ },
+ {
+ "id": "gym",
+ "text": "헬스장 회원권을 산 사람은 사지 않은 사람보다 1년 동안 체중이 덜 늘었다.",
+ "claim": "회원권을 사면 체중 증가가 줄어든다",
+ "answer": "corr",
+ "why": "원래 건강에 관심이 많은 사람이 회원권을 삽니다. 누가 샀는지를 스스로 정했기 때문에 선택 편향이 섞여 있습니다.",
+ "chapter": {
+ "title": "1장 인과추론 소개",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/01-Introduction-To-Causality.html"
+ }
+ },
+ {
+ "id": "coffee",
+ "text": "커피를 많이 마시는 사람일수록 폐암 발병률이 높았다. 흡연자는 커피도 많이 마시는 경향이 있었다.",
+ "claim": "커피가 폐암 위험을 높인다",
+ "answer": "corr",
+ "why": "흡연이 커피 소비와 폐암을 함께 늘립니다. 흡연이라는 교란 요인을 빼고 보면 커피의 효과는 사라질 수 있습니다.",
+ "chapter": {
+ "title": "4장 그래프 인과 모델",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/04-Graphical-Causal-Models.html"
+ }
+ },
+ {
+ "id": "loan_rdd",
+ "text": "대출 심사 점수 600점을 기준으로 금리가 갈렸다. 600점에서 달라지는 것은 금리뿐이고, 양쪽 모두 대출이 승인됐다. 600점 바로 위와 바로 아래 고객을 비교했더니 낮은 금리를 받은 쪽의 연체율이 낮았다. 고객이 점수를 조정할 방법은 없었다.",
+ "claim": "기준선 근처 고객에게 낮은 금리가 연체율을 낮췄다",
+ "answer": "cause",
+ "why": "기준선 바로 근처 고객은 신용이 거의 같고 금리만 갈립니다. 그래서 그 근처의 차이는 금리의 효과로 볼 수 있습니다. 기준선 근처 고객에 대한 효과입니다.",
+ "chapter": {
+ "title": "16장 회귀 불연속 설계",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/16-Regression-Discontinuity-Design.html"
+ }
+ },
+ {
+ "id": "did_diverge",
+ "text": "새 요금제를 출시한 국가와 비교 국가의 매출을 이중차분법으로 비교했다. 그런데 두 나라 매출은 출시 1년 전부터 이미 벌어지고 있었다.",
+ "claim": "새 요금제가 매출 격차를 만들었다",
+ "answer": "corr",
+ "why": "이중차분법은 ‘처치가 없었다면 두 집단이 나란히 갔을 것’이라는 가정 위에 서 있습니다. 출시 전부터 벌어지고 있었다면 그 가정이 깨져, 출시 뒤의 격차를 요금제 효과로 읽을 수 없습니다.",
+ "chapter": {
+ "title": "13장 이중차분법",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html"
+ }
+ },
+ {
+ "id": "housing_lottery",
+ "text": "공공임대 입주자를 추첨으로 뽑았다. 신청한 가구 4,000곳을 당첨 여부로 나눠 비교했더니, 당첨 가구 아이들의 결석률이 낮았고 통계적으로 유의했다.",
+ "claim": "공공임대 당첨이 아이들의 결석률을 낮췄다",
+ "answer": "cause",
+ "why": "추첨은 무작위 배정과 같습니다. 당첨 여부로 그대로 비교했으니 차이는 당첨의 효과입니다. 실제로 입주한 가구만 고르면 다시 선택 편향이 생깁니다.",
+ "chapter": {
+ "title": "9장 불응과 LATE",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/09-Non-Compliance-and-LATE.html"
+ }
+ },
+ {
+ "id": "trial",
+ "text": "컴퓨터 추첨으로 신약과 위약을 배정하고, 환자도 의사도 무엇을 받았는지 모르게 한 임상시험에서 신약 쪽 회복률이 높았고 통계적으로 유의했다.",
+ "claim": "신약이 회복률을 높였다",
+ "answer": "cause",
+ "why": "무작위 배정이 두 집단을 같게 만들고, 이중맹검이 기대 효과를 막습니다. 남은 차이는 약의 효과로 볼 수 있습니다.",
+ "chapter": {
+ "title": "2장 무작위 실험",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html"
+ }
+ },
+ {
+ "id": "premium",
+ "text": "프리미엄 요금제로 바꾼 사용자는 바꾸기 전보다 사용 시간이 늘었다.",
+ "claim": "프리미엄 전환이 사용 시간을 늘렸다",
+ "answer": "corr",
+ "why": "원래 사용이 늘던 사람이 프리미엄으로 바꾸기 쉽습니다. 전후 비교는 그 추세와 전환 효과를 가를 수 없습니다. 바꾸지 않은 사용자와 비교하더라도 전환 전 추세까지 같아야 하고, 그래도 바꿀 시점을 스스로 골랐다는 문제가 남습니다.",
+ "chapter": {
+ "title": "13장 이중차분법",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html"
+ }
+ },
+ {
+ "id": "training",
+ "text": "사내 교육을 들은 직원의 다음 해 성과 평가가 더 높았다. 교육은 팀장이 추천한 사람만 들었다.",
+ "claim": "교육이 성과를 올렸다",
+ "answer": "corr",
+ "why": "팀장은 성과가 좋을 사람을 추천했을 가능성이 큽니다. 추천 기준이 기록에 남아 있다면 비슷한 사람끼리 짝지어 비교(매칭)할 수 있지만, 팀장의 감처럼 기록되지 않은 기준이 있다면 매칭으로도 가를 수 없습니다.",
+ "chapter": {
+ "title": "10장 매칭",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/10-Matching.html"
+ }
+ },
+ {
+ "id": "tobacco_sc",
+ "text": "한 나라가 담배세를 올렸다. 담배세를 올리지 않은 여러 나라를 섞어 그 나라의 과거 흡연율 추세를 거의 똑같이 따라가는 ‘합성 비교군’을 만들었더니, 인상 뒤 그 나라의 흡연율이 합성 비교군보다 크게 떨어졌다. 같은 시기 다른 금연 정책은 없었고, 다른 나라에 같은 방법을 적용했을 때는 이만큼 큰 차이가 나온 적이 없었다.",
+ "claim": "담배세 인상이 흡연율을 낮췄다",
+ "answer": "cause",
+ "why": "과거를 잘 따라가는 합성 비교군은 ‘인상하지 않았다면’의 모습을 대신합니다. 정책이 없던 나라들에 같은 방법을 써 봐도 이만큼 차이가 나지 않았다면 우연으로 보기 어렵습니다. 다른 충격이 없었다는 전제에서 정책 효과로 읽을 수 있습니다. 통제집단합성법입니다.",
+ "chapter": {
+ "title": "15장 통제집단합성법",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/15-Synthetic-Control.html"
+ }
+ },
+ {
+ "id": "rdd_manip",
+ "text": "장학금 기준 점수 바로 위와 바로 아래 학생을 비교했더니 장학금을 받은 쪽의 졸업률이 높았다. 다만 기준에 조금 못 미친 학생은 재시험으로 점수를 올릴 수 있었고, 의욕 있는 학생이 많이 응시했다.",
+ "claim": "기준선 근처 학생에게 장학금이 졸업률을 올렸다",
+ "answer": "corr",
+ "why": "재시험으로 기준선을 넘은 학생은 의욕이 높은 쪽으로 골라졌습니다. 기준선 위와 아래가 더는 비슷한 학생이 아니어서, 회귀 불연속 설계의 전제(기준선 근처는 운으로 갈린다)가 깨집니다.",
+ "chapter": {
+ "title": "16장 회귀 불연속 설계",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/16-Regression-Discontinuity-Design.html"
+ }
+ },
+ {
+ "id": "books",
+ "text": "책을 많이 읽는 아이일수록 성적이 높았다.",
+ "claim": "아이에게 책을 더 읽히면 성적이 오른다",
+ "answer": "corr",
+ "why": "부모의 관심이나 가정 환경이 독서량과 성적을 함께 올릴 수 있습니다. 이런 교란 요인을 다 재지 않으면 독서만의 효과는 알 수 없습니다.",
+ "chapter": {
+ "title": "4장 그래프 인과 모델",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/04-Graphical-Causal-Models.html"
+ }
+ }
+ ]
+}
diff --git a/data/game.json b/data/game.json
new file mode 100644
index 0000000..9c38bbc
--- /dev/null
+++ b/data/game.json
@@ -0,0 +1,115 @@
+{
+ "title": "상관일까, 인과일까?",
+ "questions": [
+ {
+ "id": "icecream",
+ "text": "아이스크림이 많이 팔린 날일수록 물놀이 사고도 많았다.",
+ "claim": "아이스크림 판매가 물놀이 사고를 늘린다",
+ "answer": "corr",
+ "why": "더운 날씨가 아이스크림 판매와 물놀이를 함께 늘립니다. 둘을 같이 움직이는 교란 요인이 있으면, 관계가 있어 보여도 한쪽이 다른 쪽의 원인이라고 할 수 없습니다.",
+ "chapter": {
+ "title": "4장 그래프 인과 모델",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/04-Graphical-Causal-Models.html"
+ }
+ },
+ {
+ "id": "random_ui",
+ "text": "사용자 10만 명을 무작위로 반씩 나눠 한쪽에만 새 추천 화면을 보여 줬다. 두 집단의 크기는 계획대로 나뉘었고, 새 화면 쪽 구매율이 2%p 높았으며 통계적으로 유의했다.",
+ "claim": "새 추천 화면이 구매율을 올렸다",
+ "answer": "cause",
+ "why": "무작위 배정은 두 집단을 평균적으로 같게 만듭니다. 배정이 제대로 됐고 차이가 우연으로 보기 어렵다면, 남은 차이는 새 화면 때문이라고 볼 수 있습니다.",
+ "chapter": {
+ "title": "2장 무작위 실험",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html"
+ }
+ },
+ {
+ "id": "push",
+ "text": "앱 알림을 켜 둔 사용자는 끈 사용자보다 30일 뒤 남아 있는 비율이 두 배 높았다.",
+ "claim": "알림을 켜게 하면 사용자가 더 남는다",
+ "answer": "corr",
+ "why": "원래 앱을 좋아하는 사람이 알림을 켜기 쉽습니다. 누가 알림을 켰는지를 사용자가 스스로 정했기 때문에 선택 편향이 섞여 있습니다.",
+ "chapter": {
+ "title": "1장 인과추론 소개",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/01-Introduction-To-Causality.html"
+ }
+ },
+ {
+ "id": "ai_launch",
+ "text": "AI 요약 기능을 출시한 달에 매출이 10% 늘었다. 마침 연말 성수기였다.",
+ "claim": "AI 요약 기능이 매출을 10% 올렸다",
+ "answer": "corr",
+ "why": "출시 전후만 비교하면 같은 시기의 다른 변화(계절, 프로모션)와 효과가 섞입니다. 기능을 받지 않은 비교 대상을 함께 놓아야 효과를 떼어 낼 수 있습니다.",
+ "chapter": {
+ "title": "13장 이중차분법",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html"
+ }
+ },
+ {
+ "id": "geo_holdout",
+ "text": "전국 200개 지역 중 무작위로 고른 100곳에서만 한 달 동안 TV 광고를 껐다. 광고를 끈 지역의 매출이 다른 지역보다 4% 낮았다.",
+ "claim": "TV 광고가 매출을 올린다",
+ "answer": "cause",
+ "why": "광고를 끌 지역을 무작위로 골랐기 때문에, 지역 간 다른 차이는 평균적으로 비슷하게 나뉩니다. 사람 대신 지역을 무작위로 나누는 지역 단위 실험입니다.",
+ "chapter": {
+ "title": "2장 무작위 실험",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html"
+ }
+ },
+ {
+ "id": "did",
+ "text": "시범 지역과 비교 지역의 매출은 정책 전 3년 동안 나란히 움직였다. 정책 뒤에는 시범 지역만 올랐고, 같은 시기 시범 지역에만 생긴 다른 변화는 없었다.",
+ "claim": "정책이 시범 지역의 매출을 올렸다",
+ "answer": "cause",
+ "why": "정책이 없었다면 두 지역이 계속 나란히 움직였을 것이라는 평행 추세 가정이 받아들일 만하면, 벌어진 차이를 정책 효과로 읽을 수 있습니다. 과거에 나란했다는 것은 이 가정의 근거일 뿐 증명은 아닙니다.",
+ "chapter": {
+ "title": "13장 이중차분법",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html"
+ }
+ },
+ {
+ "id": "ads",
+ "text": "광고를 본 사람의 구매율이 안 본 사람보다 높았다. 광고는 구매 가능성이 높은 사람에게 보여 주도록 설정되어 있었다.",
+ "claim": "광고가 구매율을 올렸다",
+ "answer": "corr",
+ "why": "타기팅 자체가 구매할 사람을 골라냈습니다. 타기팅에 쓴 정보를 모두 알고 있다면 성향점수로 비슷한 사람끼리 비교할 수 있고, 가장 깔끔한 방법은 일부에게 광고를 무작위로 보여 주지 않는 실험입니다.",
+ "chapter": {
+ "title": "11장 성향점수",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/11-Propensity-Score.html"
+ }
+ },
+ {
+ "id": "rdd",
+ "text": "장학금 기준 점수 바로 위와 바로 아래 학생을 비교했더니, 장학금을 받은 쪽의 졸업률이 높았다. 학생이 점수를 기준선에 맞춰 조정할 방법은 없었다.",
+ "claim": "기준선 근처 학생에게 장학금은 졸업률을 올렸다",
+ "answer": "cause",
+ "why": "기준선 바로 근처의 학생들은 실력이 거의 같고, 받았는지 여부만 운처럼 갈립니다. 그래서 그 근처의 차이는 장학금 효과로 볼 수 있습니다. 다만 기준선 근처 학생에 대한 효과입니다.",
+ "chapter": {
+ "title": "16장 회귀 불연속 설계",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/16-Regression-Discontinuity-Design.html"
+ }
+ },
+ {
+ "id": "coupon_itt",
+ "text": "쿠폰을 무작위로 절반에게 보냈다. 쿠폰을 받은 사람 전체(쓰지 않은 사람 포함)의 평균 구매액이 받지 않은 사람보다 컸다.",
+ "claim": "쿠폰을 보내는 것이 구매액을 늘렸다",
+ "answer": "cause",
+ "why": "무작위로 나눈 집단을 그대로 비교했기 때문에, 차이는 쿠폰을 보낸 효과입니다. 쓴 사람만 고르지 않고 배정대로 비교하는 것이 핵심입니다.",
+ "chapter": {
+ "title": "9장 불응과 LATE",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/09-Non-Compliance-and-LATE.html"
+ }
+ },
+ {
+ "id": "coupon_used",
+ "text": "같은 실험에서 이번에는 쿠폰을 실제로 쓴 사람만 골라 받지 않은 사람과 비교했더니, 차이가 훨씬 더 컸다.",
+ "claim": "쿠폰을 쓰면 구매액이 그만큼 늘어난다",
+ "answer": "corr",
+ "why": "보낸 것은 무작위지만, 쓴 사람은 스스로 골라졌습니다. 원래 살 사람이 쿠폰을 쓰기 쉽습니다. 배정을 도구변수로 쓰면 ‘받으면 쓰는 사람’에 대한 효과(LATE)를 따로 추정할 수 있습니다.",
+ "chapter": {
+ "title": "9장 불응과 LATE",
+ "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/09-Non-Compliance-and-LATE.html"
+ }
+ }
+ ]
+}
diff --git a/game.html b/game.html
new file mode 100644
index 0000000..7c87a99
--- /dev/null
+++ b/game.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+상관일까, 인과일까? | Causal Inference Hub
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/build_builders.py b/scripts/build_builders.py
new file mode 100644
index 0000000..df4439f
--- /dev/null
+++ b/scripts/build_builders.py
@@ -0,0 +1,295 @@
+"""Build one page per Builder, an index page, and a badge for each Builder.
+
+Reads data/builders.json, data/projects.json and data/repos.json and writes
+builders/.html, builders/index.html and badges/.svg. The output is
+generated, so it is not committed; the deploy workflow runs this script before
+publishing. Run it locally to preview:
+
+ python3 scripts/build_builders.py && python3 -m http.server 8000
+
+Share images live in assets/og/builders/.jpg and are made separately by
+scripts/builder_cards.py. A page whose image is missing falls back to the hub's.
+"""
+import html
+import json
+import os
+from collections import OrderedDict
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+SITE = "https://causalinferencelab.github.io"
+ORG = "CausalInferenceLab"
+
+
+def load(name):
+ with open(os.path.join(ROOT, "data", name), encoding="utf-8") as f:
+ return json.load(f)
+
+
+def esc(s):
+ return html.escape(str(s if s is not None else ""), quote=True)
+
+
+def builder_stats():
+ """Mirror builderStats() in assets/js/hub.js so the pages and the hub agree."""
+ builders = load("builders.json")
+ projects = load("projects.json")
+ repos = {r["repo"]: r for r in load("repos.json")}
+ current = max(p["cohort"] for p in projects)
+ people = OrderedDict()
+
+ def get(name):
+ if name not in people:
+ people[name] = {"name": name, "items": [], "repos": [], "runnerOf": []}
+ return people[name]
+
+ for p in projects:
+ if not p.get("builder"):
+ continue
+ r = repos.get(p.get("repo") or "")
+ get(p["builder"])["items"].append({
+ "cohort": p["cohort"], "term": p.get("term", ""), "name": p["name"], "runners": p.get("runners"),
+ "repo": r["repo"] if r else None, "title": r["title"] if r else None, "link": p.get("link"),
+ "line": (r.get("lines") or ["none"])[0] if r else "none", "retro": p.get("retro"), "live": False,
+ })
+ for r in repos.values():
+ for b in r.get("builders") or []:
+ if r["repo"] not in get(b)["repos"]:
+ get(b)["repos"].append(r["repo"])
+ if r.get("status") != "running" or any(p.get("repo") == r["repo"] for p in projects):
+ continue
+ for b in r.get("builders") or []:
+ get(b)["items"].append({
+ "cohort": current, "term": "", "name": r["title"], "runners": None, "repo": r["repo"], "title": r["title"],
+ "link": None, "line": (r.get("lines") or ["none"])[0], "retro": None, "live": True,
+ })
+ info = {b["name"]: b for b in builders}
+ for name, p in people.items():
+ p["runnerOf"] = info.get(name, {}).get("runnerOf", [])
+ p["info"] = info.get(name, {"name": name})
+ p["items"].sort(key=lambda i: i["cohort"])
+ p["cohorts"] = sorted({i["cohort"] for i in p["items"]})
+ p["projects"] = len(p["items"])
+ p["runners"] = sum(i["runners"] or 0 for i in p["items"])
+ p["first"] = p["cohorts"][0]
+ p["line"] = next((i["line"] for i in p["items"] if i["line"] != "none"), "main")
+ ranked = sorted(people.values(), key=lambda p: (-p["projects"], -p["runners"], p["first"], p["name"]))
+ for i, p in enumerate(ranked):
+ p["rank"] = i + 1
+ terms = {p["cohort"]: p.get("term", "") for p in projects}
+ return ranked, repos, projects, terms
+
+
+def span(cohorts):
+ if not cohorts:
+ return ""
+ return f"{cohorts[0]}기" if cohorts[0] == cohorts[-1] else f"{cohorts[0]}–{cohorts[-1]}기"
+
+
+def head(title, desc, url, image):
+ return f"""
+
+
+
+
+
+{esc(title)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+FOOT = """
+
+
+
+
+"""
+
+
+def avatar(p, size="lg"):
+ gh = p["info"].get("github")
+ img = f'' if gh else ""
+ return f'{esc(p["name"][:1])}{img}'
+
+
+def retro_html(r):
+ if not r or not any(r.get(k) for k in ("why", "learned", "next")):
+ return ""
+ rows = "".join(f"
{k}
{esc(r[f])}
" for k, f in (("왜 만들었나", "why"), ("무엇을 배웠나", "learned"), ("다음 사람에게", "next")) if r.get(f))
+ return f'회고
{rows}
'
+
+
+def note_html(note):
+ return f'{note}' if note else ""
+
+
+def timeline(p, repos, projects, terms):
+ events = [dict(i, role="Builder") for i in p["items"]]
+ for x in p["runnerOf"]:
+ proj = next((q for q in projects if q["cohort"] == x["cohort"] and q["name"] == x["project"]), None)
+ r = repos.get((proj or {}).get("repo") or "")
+ events.append({"cohort": x["cohort"], "role": "러너", "name": x["project"], "repo": r["repo"] if r else None,
+ "title": r["title"] if r else None, "link": (proj or {}).get("link"), "by": (proj or {}).get("builder"),
+ "line": (r.get("lines") or ["none"])[0] if r else "none", "runners": None, "live": False, "retro": None})
+ events.sort(key=lambda e: (-e["cohort"], 0 if e["role"] == "Builder" else 1))
+ out = []
+ for e in events:
+ if e["role"] == "Builder":
+ facts = [f"러너 {e['runners']}명" if e.get("runners") else "", "진행 중" if e.get("live") else ""]
+ else:
+ facts = [f"Builder {esc(e['by'])}" if e.get("by") else ""]
+ link = (f'레포 보기' if e.get("repo")
+ else f'기록 보기' if e.get("link") else "")
+ note = '·'.join(x for x in facts + [link] if x)
+ cls = "b" if e["role"] == "Builder" else "r"
+ out.append(f'