diff --git a/.github/ISSUE_TEMPLATE/term-proposal.yml b/.github/ISSUE_TEMPLATE/term-proposal.yml
new file mode 100644
index 0000000..d10efcd
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/term-proposal.yml
@@ -0,0 +1,40 @@
+name: 용어 제안
+description: 인과추론·온라인 실험 용어의 번역어를 제안하거나 고치자고 제안합니다.
+title: "[용어] "
+labels: ["glossary"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ 제안해 주셔서 고맙습니다. 논의를 거쳐 정해진 용어는 번역 가이드의 용어표와 웹북 용어집에 함께 반영합니다.
+ - type: input
+ id: original
+ attributes:
+ label: 원어
+ placeholder: "예: sample ratio mismatch (SRM)"
+ validations:
+ required: true
+ - type: input
+ id: proposal
+ attributes:
+ label: 제안하는 번역어
+ placeholder: "예: 표본 비율 불일치(SRM)"
+ validations:
+ required: true
+ - type: input
+ id: current
+ attributes:
+ label: 지금 쓰는 표기 (있다면)
+ placeholder: "예: 용어집에 없음 / 현재 '샘플 비율 불일치'"
+ - type: textarea
+ id: reason
+ attributes:
+ label: 제안 이유와 근거
+ description: 다른 책, 논문, 업계에서 쓰는 예가 있으면 함께 적어 주세요.
+ validations:
+ required: true
+ - type: textarea
+ id: example
+ attributes:
+ label: 쓰임 예시
+ placeholder: "예: 실험을 분석하기 전에 표본 비율 불일치(SRM)가 없는지 먼저 확인합니다."
diff --git a/.github/scripts/check_upstream.py b/.github/scripts/check_upstream.py
new file mode 100644
index 0000000..a5c03ea
--- /dev/null
+++ b/.github/scripts/check_upstream.py
@@ -0,0 +1,274 @@
+"""원서 저장소의 변경을 확인해 한국어판 검토 이슈를 만들거나 갱신한다.
+
+`.github/upstream.json`의 synced_sha(한국어판이 마지막으로 맞춘 원서 커밋)와 원서 브랜치 최신 커밋을 비교한다.
+
+- 새 커밋이 있으면: 바뀐 장과 본문(마크다운 셀) 변경 요약을 담은 이슈를 만들고 검토자에게 배정한다.
+ 이미 열린 이슈가 있으면 본문을 최신으로 바꾸고, 새 커밋이 생겼을 때만 댓글로 알린다.
+- 새 커밋이 없는데 열린 이슈가 있으면(번역 반영 PR에서 synced_sha를 올린 경우) 이슈를 닫는다.
+
+GitHub Actions에서는 GITHUB_TOKEN과 GITHUB_REPOSITORY를 쓴다.
+로컬 점검: python .github/scripts/check_upstream.py --git-dir <원서 clone> --dry-run
+"""
+import argparse
+import difflib
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+CONFIG = json.loads((ROOT / ".github" / "upstream.json").read_text(encoding="utf-8"))
+MARKER = ""
+SITE = "https://causalinferencelab.github.io/Causal-Inference-with-Python"
+MAX_SNIPPET = 260
+
+
+def chapter_titles():
+ spec = importlib.util.spec_from_file_location("prepare", ROOT / "book" / "prepare.py")
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod.CHAPTERS
+
+
+# ---------- 원서 정보 가져오기 (API 또는 로컬 clone) ----------
+
+class Api:
+ def __init__(self, token):
+ self.token = token
+
+ def request(self, method, url, data=None):
+ headers = {"Accept": "application/vnd.github+json", "User-Agent": "upstream-sync"}
+ if self.token:
+ headers["Authorization"] = f"Bearer {self.token}"
+ body = json.dumps(data).encode() if data is not None else None
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
+ with urllib.request.urlopen(req) as res:
+ text = res.read().decode()
+ return json.loads(text) if text else None
+
+ def get(self, url):
+ return self.request("GET", url)
+
+
+class Upstream:
+ """원서 저장소에서 비교 결과와 파일 내용을 읽는다."""
+
+ def __init__(self, api=None, git_dir=None):
+ self.api, self.git_dir = api, git_dir
+ self.repo = CONFIG["repo"]
+
+ def head(self):
+ if self.git_dir:
+ return self._git("rev-parse", f"origin/{CONFIG['branch']}")
+ return self.api.get(f"https://api.github.com/repos/{self.repo}/commits/{CONFIG['branch']}")["sha"]
+
+ def compare(self, base, head):
+ if self.git_dir:
+ log = self._git("log", "--reverse", "--format=%H%x1f%an%x1f%ad%x1f%s", "--date=short", f"{base}..{head}")
+ commits = [dict(zip(("sha", "author", "date", "message"), l.split("\x1f"))) for l in log.splitlines() if l]
+ files = []
+ for line in self._git("diff", "--name-status", base, head).splitlines():
+ status, *paths = line.split("\t")
+ files.append({"filename": paths[-1], "status": {"A": "added", "D": "removed"}.get(status[0], "modified")})
+ return commits, files
+ data = self.api.get(f"https://api.github.com/repos/{self.repo}/compare/{base}...{head}")
+ commits = [{"sha": c["sha"], "author": c["commit"]["author"]["name"], "date": c["commit"]["author"]["date"][:10],
+ "message": c["commit"]["message"].splitlines()[0]} for c in data["commits"]]
+ files = [{"filename": f["filename"], "status": f["status"]} for f in data.get("files", [])]
+ return commits, files
+
+ def read(self, sha, path):
+ try:
+ if self.git_dir:
+ return self._git("show", f"{sha}:{path}")
+ url = f"https://raw.githubusercontent.com/{self.repo}/{sha}/{path}"
+ with urllib.request.urlopen(url) as res:
+ return res.read().decode()
+ except Exception:
+ return None
+
+ def _git(self, *args):
+ return subprocess.run(["git", "-C", self.git_dir, *args], check=True, capture_output=True, text=True).stdout.strip()
+
+
+# ---------- 노트북 본문 비교 ----------
+
+def cells(text, kind):
+ if not text:
+ return []
+ nb = json.loads(text)
+ out = []
+ for c in nb.get("cells", []):
+ if c.get("cell_type") == kind:
+ src = c.get("source", "")
+ out.append("".join(src) if isinstance(src, list) else src)
+ return out
+
+
+def short(text):
+ one = " ".join(text.split())
+ return one if len(one) <= MAX_SNIPPET else one[:MAX_SNIPPET] + "…"
+
+
+def word_diff(old, new, limit=3, ctx=6):
+ """두 문단에서 바뀐 부분만 앞뒤 몇 단어와 함께 `기존 → 변경`으로 보여 준다."""
+ a, b = old.split(), new.split()
+ out = []
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=a, b=b, autojunk=False).get_opcodes():
+ if tag == "equal":
+ continue
+ before = " ".join(a[max(0, i1 - ctx):i1])
+ after = " ".join(a[i2:i2 + ctx])
+ removed, added = " ".join(a[i1:i2]), " ".join(b[j1:j2])
+ out.append(f"…{before} ~~{removed or '∅'}~~ **{added or '∅'}** {after}…")
+ if len(out) >= limit:
+ break
+ return out
+
+
+def notebook_changes(old, new):
+ """마크다운 셀 변경을 (셀 번호, 설명 줄 목록)으로, 코드 셀은 바뀐 개수만 돌려준다."""
+ a, b = cells(old, "markdown"), cells(new, "markdown")
+ changes = []
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=a, b=b, autojunk=False).get_opcodes():
+ if tag == "equal":
+ continue
+ pairs = min(i2 - i1, j2 - j1) if tag == "replace" else 0
+ for k in range(pairs):
+ changes.append((j1 + k, word_diff(a[i1 + k], b[j1 + k])))
+ for i in range(i1 + pairs, i2):
+ changes.append((i, [f"삭제된 셀: {short(a[i])}"]))
+ for j in range(j1 + pairs, j2):
+ changes.append((j, [f"추가된 셀: {short(b[j])}"]))
+ ca, cb = cells(old, "code"), cells(new, "code")
+ code_changed = sum(max(i2 - i1, j2 - j1) for t, i1, i2, j1, j2 in
+ difflib.SequenceMatcher(a=ca, b=cb, autojunk=False).get_opcodes() if t != "equal")
+ return changes, code_changed
+
+
+# ---------- 보고서 ----------
+
+def report(base, head, commits, files, upstream, titles):
+ repo, path = CONFIG["repo"], CONFIG["path"]
+ lines = [
+ MARKER,
+ f"원서 저장소 [{repo}](https://github.com/{repo})에 한국어판이 아직 반영하지 않은 변경이 있습니다.",
+ "",
+ f"- 한국어판 기준 커밋: [`{base[:7]}`](https://github.com/{repo}/commit/{base}) ({CONFIG.get('synced_at', '')})",
+ f"- 원서 최신 커밋: [`{head[:7]}`](https://github.com/{repo}/commit/{head})",
+ f"- 전체 비교: https://github.com/{repo}/compare/{base[:12]}...{head[:12]}",
+ "",
+ f"### 새 커밋 {len(commits)}개",
+ "",
+ ]
+ lines += [f"- [`{c['sha'][:7]}`](https://github.com/{repo}/commit/{c['sha']}) {c['date']} {c['message']} ({c['author']})" for c in commits]
+
+ book_files = [f for f in files if f["filename"].startswith(path + "/") and f["filename"].endswith(".ipynb")]
+ other = [f for f in files if f not in book_files]
+ lines += ["", f"### 바뀐 장 {len(book_files)}개", ""]
+ if not book_files:
+ lines.append("책 본문 노트북은 바뀌지 않았습니다. 아래 기타 파일만 확인하면 됩니다.")
+ details = []
+ for f in book_files:
+ key = Path(f["filename"]).stem
+ title = titles.get(key, key)
+ old = upstream.read(base, f["filename"]) if f["status"] != "added" else None
+ new = upstream.read(head, f["filename"]) if f["status"] != "removed" else None
+ changes, code_changed = notebook_changes(old, new)
+ diff_url = f"https://github.com/{repo}/compare/{base[:12]}...{head[:12]}"
+ summary = f"본문 셀 {len(changes)}곳, 코드 셀 {code_changed}곳"
+ if f["status"] == "added":
+ summary = "새로 추가된 장"
+ elif f["status"] == "removed":
+ summary = "원서에서 삭제됨"
+ lines.append(f"- [ ] **{title}** (`{key}`): {summary} · [웹북]({SITE}/{key}.html) · [원서 변경]({diff_url})")
+ if changes:
+ details += ["", f"{title}: 본문 변경
", ""]
+ for i, parts in changes[:20]:
+ details.append(f"- 셀 {i}")
+ details += [f" - {short(x)}" for x in parts]
+ if len(changes) > 20:
+ details.append(f"- …외 {len(changes) - 20}곳")
+ details += ["", " "]
+ lines += details
+ if other:
+ lines += ["", "### 기타 파일", ""] + [f"- `{f['filename']}` ({f['status']})" for f in other[:50]]
+ lines += [
+ "",
+ "### 반영 절차",
+ "",
+ "1. 위 목록에서 장별 변경을 확인하고, 번역에 반영할지 정합니다(오탈자·링크만 바뀐 경우는 건너뛰어도 됩니다).",
+ "2. 반영할 장은 번역 가이드에 따라 고친 뒤 PR을 엽니다. PR에서 `.github/upstream.json`의 `synced_sha`를 이 이슈의 원서 최신 커밋으로, `synced_at`을 날짜로 바꿉니다.",
+ "3. 메인테이너가 PR을 검토하고 머지하면 웹북에 배포되고, 다음 점검 때 이 이슈가 자동으로 닫힙니다.",
+ "",
+ "이 이슈는 `Upstream watch` 워크플로가 매월 1일 자동으로 갱신합니다.",
+ ]
+ body = "\n".join(lines)
+ return body[:60000]
+
+
+# ---------- 이슈 관리 ----------
+
+def sync_issue(api, repo, body, head, ahead):
+ label = CONFIG["label"]
+ base_url = f"https://api.github.com/repos/{repo}"
+ try:
+ api.request("POST", f"{base_url}/labels", {"name": label, "color": "a996d6", "description": "원서 변경 반영 검토"})
+ except Exception:
+ pass # 이미 있음
+ issues = api.get(f"{base_url}/issues?state=open&labels={label}&per_page=10")
+ issue = next((i for i in issues if MARKER in (i.get("body") or "")), None)
+ if not ahead:
+ if issue:
+ api.request("POST", f"{base_url}/issues/{issue['number']}/comments",
+ {"body": "한국어판 기준 커밋이 원서 최신 커밋과 같아져서 이 이슈를 닫습니다."})
+ api.request("PATCH", f"{base_url}/issues/{issue['number']}", {"state": "closed", "state_reason": "completed"})
+ print(f"closed #{issue['number']}")
+ return
+ title = f"[원서 변경] 반영 검토 필요 ({head[:7]})"
+ if issue:
+ known = issue.get("title", "").endswith(f"({head[:7]})")
+ api.request("PATCH", f"{base_url}/issues/{issue['number']}", {"title": title, "body": body})
+ if not known:
+ mention = " ".join(f"@{r}" for r in CONFIG.get("reviewers", []))
+ api.request("POST", f"{base_url}/issues/{issue['number']}/comments",
+ {"body": f"{mention} 원서에 새 커밋이 추가되어 이슈 내용을 갱신했습니다. 최신 커밋: `{head[:7]}`"})
+ print(f"updated #{issue['number']}")
+ else:
+ new = api.request("POST", f"{base_url}/issues", {"title": title, "body": body, "labels": [label],
+ "assignees": CONFIG.get("reviewers", [])})
+ print(f"opened #{new['number']}")
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--git-dir", help="원서 저장소 로컬 clone (API 대신 사용)")
+ parser.add_argument("--base", help="비교 기준 커밋 (기본: upstream.json의 synced_sha)")
+ parser.add_argument("--dry-run", action="store_true", help="이슈를 만들지 않고 보고서만 출력")
+ args = parser.parse_args()
+
+ token = os.environ.get("GITHUB_TOKEN", "")
+ api = Api(token)
+ upstream = Upstream(api=None if args.git_dir else api, git_dir=args.git_dir)
+ base = args.base or CONFIG["synced_sha"]
+ head = upstream.head()
+ commits, files = ([], []) if head == base else upstream.compare(base, head)
+ body = report(base, head, commits, files, upstream, chapter_titles()) if commits else ""
+
+ summary = os.environ.get("GITHUB_STEP_SUMMARY")
+ if summary:
+ Path(summary).write_text(body or f"원서 변경 없음 (기준 `{base[:7]}` = 최신 `{head[:7]}`)", encoding="utf-8")
+ if args.dry_run:
+ print(body or f"no upstream changes since {base[:7]}")
+ return
+ repo = os.environ.get("GITHUB_REPOSITORY")
+ if not repo:
+ sys.exit("GITHUB_REPOSITORY가 없습니다. 로컬에서는 --dry-run으로 실행하세요.")
+ sync_issue(api, repo, body, head, bool(commits))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/upstream.json b/.github/upstream.json
new file mode 100644
index 0000000..e2d5e78
--- /dev/null
+++ b/.github/upstream.json
@@ -0,0 +1,9 @@
+{
+ "repo": "matheusfacure/python-causality-handbook",
+ "branch": "master",
+ "path": "causal-inference-for-the-brave-and-true",
+ "synced_sha": "4df1445987d3b6f9824d508863a1a2f0a503e888",
+ "synced_at": "2026-10-18",
+ "reviewers": ["jsshin2022"],
+ "label": "upstream-sync"
+}
diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml
index e68f38c..69e098f 100644
--- a/.github/workflows/book.yml
+++ b/.github/workflows/book.yml
@@ -20,9 +20,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -43,7 +43,7 @@ jobs:
- name: Upload preview
if: github.event_name == 'pull_request'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: book-preview
path: book/_book
diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml
new file mode 100644
index 0000000..761b274
--- /dev/null
+++ b/.github/workflows/upstream-watch.yml
@@ -0,0 +1,33 @@
+name: Upstream watch
+
+# 원서 저장소에 새 변경이 생기면 메인테이너에게 배정된 검토 이슈를 만든다.
+on:
+ schedule:
+ - cron: "17 0 1 * *" # 매월 1일 오전 9시 17분(KST)
+ workflow_dispatch:
+ push:
+ branches: [main]
+ paths: [".github/upstream.json"]
+
+permissions:
+ contents: read
+ issues: write
+
+concurrency:
+ group: upstream-watch
+ cancel-in-progress: false
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-python@v7
+ with:
+ python-version: "3.12"
+
+ - name: Compare with upstream and update review issue
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: python .github/scripts/check_upstream.py
diff --git a/README.md b/README.md
index f404c20..7220015 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,28 @@
Matheus Facure의 [Causal Inference for The Brave and True](https://matheusfacure.github.io/python-causality-handbook/landing-page.html) 한국어판입니다.
-
-
-[](https://zenodo.org/badge/latestdoi/255903310)
-
-**[웹북으로 읽기](https://causalinferencelab.github.io/Causal-Inference-with-Python/)** · [번역 가이드](TRANSLATION_GUIDE.md) · [오류 제보](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/issues)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+**[웹북으로 읽기](https://causalinferencelab.github.io/Causal-Inference-with-Python/)** · [용어집](https://causalinferencelab.github.io/Causal-Inference-with-Python/glossary.html) · [번역 가이드](TRANSLATION_GUIDE.md) · [오류 제보](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/issues)
@@ -105,7 +122,13 @@ python book/prepare.py
cd book && quarto preview
```
-`book/prepare.py`는 빌드할 때만 장 제목, 수식 표기, 접힌 코드, 옮긴이 주 상자를 책 형식으로 바꿉니다. 그래서 노트북은 원서와 같은 구조로 두면 됩니다.
+`book/prepare.py`는 빌드할 때 [번역 가이드](TRANSLATION_GUIDE.md)의 용어표로 웹북의 '용어집' 페이지를 만들고, `book/postrender.py`는 검색 결과 미리보기의 수식 기호를 읽기 쉽게 정리합니다. `book/prepare.py`는 빌드할 때만 장 제목, 수식 표기, 접힌 코드, 옮긴이 주 상자를 책 형식으로 바꿉니다. 그래서 노트북은 원서와 같은 구조로 두면 됩니다.
+
+
+
+## 원서 변경 반영
+
+원서가 바뀌면 GitHub Actions(`Upstream watch`)가 매월 1일 확인해 바뀐 장과 본문 변경을 정리한 이슈를 메인테이너에게 배정합니다. 번역을 고친 PR이 메인테이너 검토를 거쳐 머지되면 웹북에 반영되고 이슈는 자동으로 닫힙니다. 자세한 절차는 [번역 가이드](TRANSLATION_GUIDE.md#5-원서-변경-반영)에 있습니다.
diff --git a/TRANSLATION_GUIDE.md b/TRANSLATION_GUIDE.md
index a825d4f..f1c0995 100644
--- a/TRANSLATION_GUIDE.md
+++ b/TRANSLATION_GUIDE.md
@@ -31,7 +31,7 @@
## 3. 용어표
-『실무로 통하는 인과추론 with 파이썬』 찾아보기와 본문에서 쓴 번역어입니다. 띄어쓰기도 표기대로 맞춥니다.
+『실무로 통하는 인과추론 with 파이썬』 찾아보기와 본문에서 쓴 번역어입니다. 띄어쓰기도 표기대로 맞춥니다. 이 표는 웹북의 [용어집](https://causalinferencelab.github.io/Causal-Inference-with-Python/glossary.html) 페이지로 자동 반영되므로, 용어를 바꾸거나 추가할 때는 이 표만 고치면 됩니다. 비고에 '`표기` 쓰지 않음'으로 적은 표기는 '다른 표기'로 함께 실려 검색에 걸립니다.
### 기본 개념
@@ -219,6 +219,35 @@
| band (예측값 구간) | 구간 | 신뢰구간과 헷갈리지 않게 문맥 표현 |
| conversion | 전환 | |
+### 온라인 실험
+
+이 책보다 넓은 범위로, 가짜연구소 인과추론팀이 온라인 실험과 제품 분석 자료에서 함께 쓰려고 정리하는 표기입니다. 새 용어나 더 나은 번역어는 [용어 제안 이슈](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/issues/new?template=term-proposal.yml)로 알려 주세요.
+
+| 원어 | 번역어 | 비고 |
+|---|---|---|
+| online controlled experiment | 온라인 통제 실험 | |
+| A/B test / A/A test | A/B 테스트 / A/A 테스트 | |
+| randomization unit | 무작위 배정 단위 | 사용자, 세션, 지역 등 |
+| variant / arm | 실험 조건 | 대조 조건과 실험 조건으로 씀 |
+| holdout group | 홀드아웃 집단 | 장기 효과를 보려고 처치를 주지 않고 남겨 둔 집단 |
+| metric | 지표 | |
+| primary / secondary metric | 핵심 지표 / 보조 지표 | |
+| guardrail metric | 가드레일 지표 | 실험 때문에 나빠지면 안 되는 지표 |
+| north star metric | 북극성 지표 | |
+| overall evaluation criterion (OEC) | 종합 평가 기준(OEC) | |
+| minimum detectable effect (MDE) | 최소 검출 효과(MDE) | |
+| sample size calculation | 표본 크기 계산 | |
+| sample ratio mismatch (SRM) | 표본 비율 불일치(SRM) | |
+| variance reduction / CUPED | 분산 감소 / CUPED | |
+| novelty effect / primacy effect | 신기 효과 / 초두 효과 | |
+| interference / network effect | 간섭 / 네트워크 효과 | SUTVA 위반 |
+| switchback experiment | 스위치백 실험 | |
+| ramp-up | 단계적 확대 | |
+| peeking | 중간 결과 엿보기 | |
+| sequential testing | 순차 검정 | |
+| multiple testing / false discovery rate (FDR) | 다중 검정 / 거짓 발견률(FDR) | |
+| quasi-experiment | 준실험 | |
+
## 4. 작업 순서
1. 원문 저장소의 해당 노트북을 내려받아 코드 셀과 출력은 그대로 둡니다.
@@ -226,3 +255,15 @@
3. 수식, 이미지, 링크 개수가 원문과 같은지 확인합니다.
4. `python book/prepare.py && cd book && quarto preview`로 웹북 렌더링을 확인합니다. 새 노트북을 추가했다면 `book/prepare.py`의 장 제목 목록과 `book/_quarto.yml` 목차에도 넣어 주세요.
5. PR에는 원문 커밋과 바뀐 셀을 적어 주세요.
+
+## 5. 원서 변경 반영
+
+원서 저장소(matheusfacure/python-causality-handbook)가 바뀌면 한국어판도 따라가야 합니다. 이 과정은 자동 알림과 메인테이너 검토로 이루어집니다.
+
+1. `Upstream watch` 워크플로가 매월 1일 원서 최신 커밋을 `.github/upstream.json`의 `synced_sha`와 비교합니다.
+2. 새 커밋이 있으면 `upstream-sync` 라벨이 붙은 이슈를 만들어 메인테이너에게 배정합니다. 이슈에는 새 커밋 목록, 바뀐 장, 장별 본문 변경(바뀐 단어와 앞뒤 문맥), 웹북과 원서 비교 링크가 들어갑니다. 원서가 다시 바뀌면 같은 이슈를 갱신하고 댓글로 알립니다.
+3. 같은 날 검토 이슈가 생긴 경우에만 정리 작업이 이어집니다. 바뀐 장마다 번역에 반영할 문장(기존 번역 → 수정안)과, 원서에서 새로 발견한 오류와 그 수정 방법(원서 저장소에 올릴 PR·이슈 초안 포함)을 문서로 정리해 검토 이슈에 댓글로 연결합니다. 변경이 없는 달에는 아무 알림도 가지 않습니다.
+4. 반영할 장을 이 가이드대로 고치고 PR을 엽니다. 같은 PR에서 `synced_sha`를 원서 최신 커밋으로, `synced_at`을 날짜로 바꿉니다. 오탈자나 링크만 바뀌어 번역을 고칠 필요가 없을 때도 `synced_sha`만 올리는 PR을 열어 기록을 남깁니다.
+5. 메인테이너가 검토해 머지하면 웹북이 배포되고, 워크플로가 다시 돌아 이슈를 자동으로 닫습니다.
+
+로컬에서 미리 보려면 원서를 clone한 뒤 `python .github/scripts/check_upstream.py --git-dir <원서 경로> --dry-run`을 실행합니다.
diff --git a/book/.gitignore b/book/.gitignore
index 6624fca..cd61329 100644
--- a/book/.gitignore
+++ b/book/.gitignore
@@ -2,6 +2,7 @@
*.ipynb
*.py
!prepare.py
+!postrender.py
data/
_book/
.quarto/
@@ -9,3 +10,4 @@ _book/
/.quarto/
**/*.quarto_ipynb
+glossary.qmd
diff --git a/book/_quarto.yml b/book/_quarto.yml
index a0704aa..3b2ae6b 100644
--- a/book/_quarto.yml
+++ b/book/_quarto.yml
@@ -1,9 +1,11 @@
project:
type: book
output-dir: _book
+ post-render: python postrender.py
resources:
- data/img/**
- assets/**
+ - robots.txt
lang: ko
@@ -11,6 +13,9 @@ execute:
enabled: false
book:
+ google-analytics:
+ tracking-id: "G-BEBVKYH8HL"
+ anonymize-ip: true
title: "Python으로 하는 인과추론"
subtitle: "Causal Inference for The Brave and True 한국어판"
author:
@@ -23,7 +28,14 @@ book:
repo-subdir: causal-inference-for-the-brave-and-true
repo-actions: [issue]
favicon: assets/causal-lab-mark.png
- search: true
+ search:
+ location: sidebar
+ type: textbox
+ limit: 50
+ collapse-after: 3
+ copy-button: true
+ show-item-context: true
+ keyboard-shortcut: ["/", "k"]
reader-mode: true
open-graph:
image: assets/og-image.png
@@ -33,7 +45,7 @@ book:
page-navigation: true
sidebar:
style: docked
- collapse-level: 2
+ collapse-level: 3
header: |
@@ -83,6 +95,7 @@ book:
- 23-Challenges-with-Effect-Heterogeneity-and-Nonlinearity.ipynb
- 24-The-Diff-in-Diff-Saga.ipynb
- 25-Synthetic-Diff-in-Diff.ipynb
+ - glossary.qmd
appendices:
- Debiasing-with-Orthogonalization.ipynb
- Debiasing-with-Propensity-Score.ipynb
@@ -111,7 +124,9 @@ format:
df-print: default
link-external-newwindow: true
back-to-top-navigation: true
- include-after-body: sidebar-resize.html
+ include-after-body:
+ - sidebar-resize.html
+ - chapter-toc.html
include-in-header:
- text: |
diff --git a/book/assets/readme-banner.png b/book/assets/readme-banner.png
deleted file mode 100644
index 08e9a88..0000000
Binary files a/book/assets/readme-banner.png and /dev/null differ
diff --git a/book/assets/readme/banner.png b/book/assets/readme/banner.png
new file mode 100644
index 0000000..ec8f019
Binary files /dev/null and b/book/assets/readme/banner.png differ
diff --git a/book/assets/readme/btn-book.png b/book/assets/readme/btn-book.png
new file mode 100644
index 0000000..828ffff
Binary files /dev/null and b/book/assets/readme/btn-book.png differ
diff --git a/book/assets/readme/btn-course.png b/book/assets/readme/btn-course.png
new file mode 100644
index 0000000..115238a
Binary files /dev/null and b/book/assets/readme/btn-course.png differ
diff --git a/book/assets/readme/btn-original.png b/book/assets/readme/btn-original.png
new file mode 100644
index 0000000..6dada30
Binary files /dev/null and b/book/assets/readme/btn-original.png differ
diff --git a/book/assets/readme/btn-read.png b/book/assets/readme/btn-read.png
new file mode 100644
index 0000000..afab470
Binary files /dev/null and b/book/assets/readme/btn-read.png differ
diff --git a/book/chapter-toc.html b/book/chapter-toc.html
new file mode 100644
index 0000000..4ca827d
--- /dev/null
+++ b/book/chapter-toc.html
@@ -0,0 +1,22 @@
+
diff --git a/book/index.qmd b/book/index.qmd
index 772cf5a..cbfd51a 100644
--- a/book/index.qmd
+++ b/book/index.qmd
@@ -58,7 +58,7 @@ aliases:
## 이 책의 구성
-원서의 25개 장과 부록 5편을 모두 옮겼습니다.
+원서의 25개 장과 부록 5편을 모두 옮겼고, 인과추론과 온라인 실험 용어를 모은 용어집을 함께 둡니다.
```{=html}
```
@@ -86,7 +91,7 @@ aliases:
각 장은 원서의 주피터 노트북을 그대로 옮긴 것이라 코드와 출력이 원서와 같습니다. 노트북 파일은 [GitHub 저장소](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/tree/main/causal-inference-for-the-brave-and-true)에서 내려받아 직접 실행해 볼 수 있습니다. 그래프를 그리는 코드처럼 길어서 접어 둔 셀은 **코드**를 눌러 펼치면 됩니다.
-용어는 같은 저자의 책을 옮긴 『실무로 통하는 인과추론 with 파이썬』(한빛미디어)과 맞췄습니다. 두 책을 함께 보셔도 용어가 헷갈리지 않습니다. 번역 원칙과 용어표는 [번역 가이드](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/blob/main/TRANSLATION_GUIDE.md)에 정리해 두었습니다.
+용어는 같은 저자의 책을 옮긴 『실무로 통하는 인과추론 with 파이썬』(한빛미디어)과 맞췄습니다. 두 책을 함께 보셔도 용어가 헷갈리지 않습니다. 용어의 원어와 그 용어를 다루는 장은 [용어집](glossary.html)에서 찾을 수 있습니다. 검색창(단축키 `/`)에 한국어나 영어 용어를 넣어도 됩니다. 번역 원칙과 용어표는 [번역 가이드](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/blob/main/TRANSLATION_GUIDE.md)에 정리해 두었습니다.
## 함께 만들어 주세요
diff --git a/book/postrender.py b/book/postrender.py
new file mode 100644
index 0000000..38c92d5
--- /dev/null
+++ b/book/postrender.py
@@ -0,0 +1,50 @@
+"""렌더링 뒤처리: 검색 색인(search.json)의 수식 기호를 읽기 쉽게 바꾼다.
+
+검색 결과 미리보기는 수식을 그리지 않으므로 `\\(Y_1 \\perp T\\)` 같은 TeX 원문이 그대로 보인다.
+구분 기호를 지우고 자주 쓰는 기호만 유니코드로 바꾼다. 검색 대상 문자열에만 적용되고 본문은 그대로다.
+"""
+import json
+import os
+import re
+from pathlib import Path
+
+OUT = Path(os.environ.get("QUARTO_PROJECT_OUTPUT_DIR", Path(__file__).parent / "_book"))
+SYMBOLS = {
+ "perp": "⊥", "mid": "|", "times": "×", "cdot": "·", "leq": "≤", "geq": "≥", "neq": "≠",
+ "approx": "≈", "sum": "Σ", "infty": "∞", "alpha": "α", "beta": "β", "gamma": "γ",
+ "delta": "δ", "epsilon": "ε", "varepsilon": "ε", "theta": "θ", "lambda": "λ", "mu": "μ",
+ "pi": "π", "sigma": "σ", "tau": "τ", "phi": "φ", "rightarrow": "→", "to": "→",
+}
+
+
+def tex_to_plain(tex):
+ tex = re.sub(r"\\[!,;: ]", "", tex)
+ tex = re.sub(r"\\(?:hat|bar|tilde|mathbb|mathcal|mathrm|text|operatorname|underbrace|overbrace|left|right|big|Big|frac|displaystyle)\b", "", tex)
+ tex = re.sub(r"\\([A-Za-z]+)", lambda m: SYMBOLS.get(m.group(1), m.group(1)), tex)
+ tex = re.sub(r"⊥(\s*⊥)+", "⊥", tex)
+ tex = tex.replace("{", "").replace("}", "").replace("\\", "")
+ return re.sub(r"\s{2,}", " ", tex).strip()
+
+
+def clean(text):
+ """\\( \\)와 \\[ \\] 안의 수식만 평문으로 바꾼다."""
+ return re.sub(r"\\\((.*?)\\\)|\\\[(.*?)\\\]", lambda m: tex_to_plain(m.group(1) or m.group(2) or ""), text, flags=re.S)
+
+
+def main():
+ path = OUT / "search.json"
+ if not path.exists():
+ return
+ data = json.loads(path.read_text(encoding="utf-8"))
+ for item in data:
+ for key in ("text", "section", "title"):
+ if isinstance(item.get(key), str):
+ item[key] = clean(item[key])
+ # 검색 결과의 경로 표시(crumbs)에 장 번호 HTML이 그대로 찍히는 문제
+ if isinstance(item.get("crumbs"), list):
+ item["crumbs"] = [re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", c)).strip() for c in item["crumbs"]]
+ path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/book/prepare.py b/book/prepare.py
index f404fc7..97c4b20 100644
--- a/book/prepare.py
+++ b/book/prepare.py
@@ -8,6 +8,8 @@
- `hide-input` 태그: 코드를 접어 두는 Quarto 옵션(code-fold)으로 바꾼다.
- 대체 텍스트가 `img`뿐인 이미지: 그림 캡션으로 찍히지 않게 비운다.
- `{note}` 지시문과 `> 옮긴이:` 인용문: Quarto 콜아웃으로 바꾼다.
+- 장 설명: 첫 문단을 front matter `description`으로 넣어 검색엔진과 공유 카드에 쓴다.
+- 용어집: TRANSLATION_GUIDE.md 용어표로 glossary.qmd를 만들고, 용어가 많이 나오는 장을 잇는다.
실행: python book/prepare.py
"""
@@ -97,6 +99,110 @@ def translator_note(m):
return text
+TEXTS = {}
+
+
+def describe(md, limit=150):
+ """첫 문단을 평문으로 줄여 장 설명으로 쓴다."""
+ for para in re.split(r"\n\s*\n", md):
+ p = para.strip()
+ if not p or p.startswith(("#", "!", "<", "|", "```", ":::", ">", "$")):
+ continue
+ p = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", p)
+ p = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", p)
+ p = re.sub(r"\$[^$]*\$", "", p)
+ p = re.sub(r"[*_`]", "", p)
+ p = re.sub(r"\s+", " ", p).strip()
+ if len(p) < 40:
+ continue
+ return p if len(p) <= limit else p[:limit].rsplit(" ", 1)[0] + "…"
+ return ""
+
+
+def label(key):
+ keys = list(CHAPTERS)
+ i = keys.index(key)
+ if i < 25:
+ return f"{i + 1}장 {CHAPTERS[key]}"
+ return f"부록 {chr(65 + i - 25)} {CHAPTERS[key]}"
+
+
+# 검색할 때 띄어 쓰는 경우가 많은 용어. 용어집에 함께 적어 두면 띄어 써서 검색해도 찾힌다.
+SPACED = {
+ "도구변수": "도구 변수", "성향점수": "성향 점수", "처치효과": "처치 효과", "패널데이터": "패널 데이터",
+ "이중차분법": "이중 차분법", "통제집단합성법": "통제 집단 합성법", "회귀분석": "회귀 분석",
+ "선택편향": "선택 편향", "교란편향": "교란 편향", "고정효과": "고정 효과", "메타러너": "메타 러너",
+ "더미변수": "더미 변수", "결과변수": "결과 변수", "처치변수": "처치 변수", "인과추론": "인과 추론",
+ "인과관계": "인과 관계", "역확률": "역 확률", "표준오차": "표준 오차", "신뢰구간": "신뢰 구간",
+}
+
+
+def build_glossary():
+ """번역 가이드 용어표로 용어집 페이지를 만든다."""
+ guide = (ROOT / "TRANSLATION_GUIDE.md").read_text(encoding="utf-8")
+ body = guide.split("## 3. 용어표", 1)[1].split("\n## ", 1)[0]
+ propose = "https://github.com/CausalInferenceLab/Causal-Inference-with-Python/issues/new?template=term-proposal.yml"
+ out = [
+ "---",
+ 'description: "인과추론과 온라인 실험에서 쓰는 한국어 용어집. 번역어와 원어, 다른 표기, 그 용어를 다루는 장을 함께 정리했습니다."',
+ "---",
+ "",
+ "# 용어집 {.unnumbered}",
+ "",
+ "인과추론과 온라인 실험을 한국어로 공부하고 이야기할 때 쓸 수 있는 용어를 모았습니다. "
+ "번역어는 『실무로 통하는 인과추론 with 파이썬』을 기준으로 하고, 이 책에 나오지 않는 온라인 실험 용어는 "
+ "가짜연구소 인과추론팀이 함께 정해 나가고 있습니다.",
+ "",
+ "용어마다 원어와 다른 표기, 그 용어를 주로 다루는 장을 붙였습니다. 다른 자료에서 본 표기(예: 처치군, 통제군)나 "
+ "띄어 쓴 표기로 검색해도 이 페이지에서 찾을 수 있고, 용어 이름 옆 링크를 복사하면 그 용어로 바로 이동하는 주소가 됩니다.",
+ "",
+ f"::: {{.callout-tip appearance=\"simple\" title=\"용어 제안하기\"}}\n빠진 용어나 더 자연스러운 번역어가 있다면 [용어 제안 이슈]({propose})로 알려 주세요. 논의를 거쳐 번역 가이드와 이 용어집에 함께 반영합니다.\n:::",
+ "",
+ ]
+ order = list(CHAPTERS)
+ for section in re.split(r"(?m)^### ", body)[1:]:
+ heading, _, rest = section.partition("\n")
+ intro = [l for l in rest.split("\n|", 1)[0].splitlines() if l.strip()]
+ items = []
+ for line in rest.splitlines():
+ cols = [c.strip() for c in line.strip().strip("|").split("|")]
+ if len(cols) < 2 or cols[0] in ("원어", "---") or set(cols[0]) <= {"-"}:
+ continue
+ en, ko = cols[0], cols[1]
+ note = cols[2] if len(cols) > 2 else ""
+ variants = [re.sub(r"\s*\([^)]*\)", "", v).strip() for v in ko.split("/")]
+ variants = [v[:-1] if v.endswith("다") and len(v) > 2 else v for v in variants if len(v) >= 2]
+
+ def score(k):
+ text = TEXTS.get(k, "")
+ n = sum(text.count(v) for v in variants)
+ if not n:
+ return 0
+ # 긴 장이 무조건 앞서지 않도록 길이로 나누고, 원어를 병기해 처음 소개하는 장에 가산점
+ intro = any(re.search(re.escape(v) + r"\s*\([A-Za-z]", text) for v in variants)
+ return n / max(len(text), 1) * 10000 + (5 if intro else 0)
+
+ scores = {k: score(k) for k in order}
+ top = sorted((k for k in order if scores[k]), key=lambda k: -scores[k])[:3]
+ top.sort(key=order.index)
+ others = [a for a in re.findall(r"`([^`]+)`", note) if "쓰지" in note]
+ anchor = "term-" + re.sub(r"[^0-9A-Za-z가-힣]+", "-", ko).strip("-")
+ line_out = f"- [**{ko}**]{{#{anchor}}} [#](#{anchor}){{.term-link aria-label=\"{ko} 링크\"}} · {en}"
+ for w, spaced in SPACED.items():
+ if w in ko:
+ alias = re.sub(r"\s*\([^)]*\)", "", ko.replace(w, spaced)).strip()
+ if alias not in others:
+ others.append(alias)
+ if others:
+ line_out += f" · 다른 표기: {', '.join(others)}"
+ if top:
+ line_out += " · " + ", ".join(f"[{label(k)}]({k}.html)" for k in top)
+ items.append(line_out)
+ if items:
+ out += [f"## {heading.strip()}", ""] + ([" ".join(intro), ""] if intro else []) + items + [""]
+ (OUT / "glossary.qmd").write_text("\n".join(out), encoding="utf-8")
+
+
def prepare_notebook(key, title):
nb = json.loads((SRC / f"{key}.ipynb").read_text(encoding="utf-8"))
cells = nb["cells"]
@@ -116,7 +222,11 @@ def prepare_notebook(key, title):
if stripped != text:
set_text(cell, stripped.lstrip("\n"))
break
- front = {"cell_type": "raw", "metadata": {}, "source": [f'---\ntitle: "{title}"\n---']}
+ md = "\n\n".join(src_text(c) for c in cells if c["cell_type"] == "markdown")
+ TEXTS[key] = md
+ desc = describe(md)
+ front_lines = [f'title: "{title}"'] + ([f"description: {json.dumps(desc, ensure_ascii=False)}"] if desc else [])
+ front = {"cell_type": "raw", "metadata": {}, "source": ["---\n" + "\n".join(front_lines) + "\n---"]}
nb["cells"] = [front] + [c for c in cells if not (c["cell_type"] == "markdown" and not src_text(c).strip())]
(OUT / f"{key}.ipynb").write_text(json.dumps(nb, ensure_ascii=False), encoding="utf-8")
@@ -124,6 +234,7 @@ def prepare_notebook(key, title):
def main():
for key, title in CHAPTERS.items():
prepare_notebook(key, title)
+ build_glossary()
# 이미지·데이터와 노트북이 import하는 보조 모듈
data = OUT / "data"
if data.is_symlink() or data.exists():
diff --git a/book/robots.txt b/book/robots.txt
new file mode 100644
index 0000000..d0730f2
--- /dev/null
+++ b/book/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://causalinferencelab.github.io/Causal-Inference-with-Python/sitemap.xml
diff --git a/book/styles-dark.scss b/book/styles-dark.scss
index 2ce1863..0e10ecb 100644
--- a/book/styles-dark.scss
+++ b/book/styles-dark.scss
@@ -97,3 +97,14 @@ body {
.further .further-tag {
color: #b4a5ea;
}
+
+.inline-toc {
+ background: rgba(169, 150, 214, 0.08);
+ border-color: rgba(169, 150, 214, 0.25);
+}
+.inline-toc summary {
+ color: #eceaff;
+}
+.quarto-secondary-nav .quarto-btn-toggle::after {
+ color: #eceaff;
+}
diff --git a/book/styles.scss b/book/styles.scss
index d70bbd7..08d6a5f 100644
--- a/book/styles.scss
+++ b/book/styles.scss
@@ -325,7 +325,7 @@ details.code-fold > summary {
.toc-cards {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.9rem;
margin: 1.2rem 0 2rem;
}
@@ -715,3 +715,117 @@ h1.title .chapter-number {
display: none;
}
}
+
+// 좁은 화면용 '이 장의 내용' (기본으로 펼침)
+.inline-toc {
+ display: none;
+ margin: 0 0 1.8rem;
+ padding: 0.9rem 1.1rem;
+ border-radius: 12px;
+ background: $lavender-soft;
+ border: 1px solid rgba($lavender, 0.45);
+}
+.inline-toc summary {
+ cursor: pointer;
+ font-weight: 700;
+ color: $navy;
+}
+.inline-toc ul {
+ margin: 0.6rem 0 0;
+ padding-left: 1.1rem;
+}
+.inline-toc li {
+ margin: 0;
+ line-height: 1.75;
+}
+.inline-toc a {
+ text-decoration: none;
+}
+@media (max-width: 991.98px) {
+ .inline-toc {
+ display: block;
+ }
+ #quarto-margin-sidebar {
+ display: none !important;
+ }
+}
+
+// 태블릿 폭(768~991px)에서도 왼쪽 목차를 접지 않고 늘 보여 줌
+@media (min-width: 768px) and (max-width: 991.98px) {
+ body.docked #quarto-content.page-columns {
+ display: grid;
+ grid-template-columns:
+ [screen-start] 250px
+ [screen-start-inset page-start page-start-inset body-start-outset] 1.5em
+ [body-start body-content-start] minmax(0, 1fr)
+ [body-content-end body-end] 1.5em
+ [body-end-outset page-end-inset page-end screen-end-inset screen-end];
+ }
+ body.docked #quarto-sidebar.collapse,
+ body.docked #quarto-sidebar.collapsing {
+ display: flex !important;
+ visibility: visible !important;
+ position: sticky !important;
+ top: 0;
+ width: 250px !important;
+ height: 100vh !important;
+ max-height: 100vh;
+ transform: none !important;
+ flex-direction: column !important;
+ overflow-y: auto !important;
+ padding: 0 0.6rem 1rem;
+ grid-column: screen-start / screen-start-inset;
+ grid-row: content-top / page-bottom;
+ z-index: 1;
+ box-shadow: none;
+ }
+ #quarto-sidebar .sidebar-menu-container {
+ min-width: 0;
+ white-space: normal;
+ }
+ .quarto-secondary-nav .quarto-btn-toggle {
+ display: none;
+ }
+ #quarto-sidebar-glass {
+ display: none !important;
+ }
+}
+
+// 휴대폰: 목차 버튼에 글자를 붙여 찾기 쉽게
+@media (max-width: 767.98px) {
+ .quarto-secondary-nav .quarto-btn-toggle::after {
+ content: "목차";
+ margin-left: 0.35rem;
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: $navy;
+ }
+}
+
+// 검색 결과 창을 본문 쪽으로 넓혀 미리보기를 읽기 쉽게
+@media (min-width: 992px) {
+ .aa-Panel {
+ min-width: min(560px, calc(100vw - 2rem));
+ }
+}
+
+// 용어집: 용어 옆 링크 표시는 마우스를 올릴 때만 또렷하게
+.term-link {
+ margin-left: 0.15rem;
+ color: $lavender !important;
+ font-size: 0.85em;
+ text-decoration: none !important;
+ opacity: 0.35;
+}
+li:hover > .term-link,
+.term-link:focus {
+ opacity: 1;
+}
+:target {
+ scroll-margin-top: 1.5rem;
+}
+span[id^="term-"]:target {
+ background: rgba($lavender, 0.25);
+ border-radius: 4px;
+ padding: 0 0.2rem;
+}