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) 한국어판입니다. -Python으로 하는 인과추론 웹북 - -[![DOI](https://zenodo.org/badge/255903310.svg)](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) +

+ Python으로 하는 인과추론 웹북 +

+ +

+ 웹북에서 1장부터 읽기 + 원서 보기 + 도서: 실무로 통하는 인과추론 + 강의: AI 인과추론 실무 +

+ +

+ Stars + Forks + Pull Requests + Issues + Contributors + 웹북 빌드 + 원서 DOI +

+ +**[웹북으로 읽기](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: | 가짜연구소 Pseudo Lab @@ -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}
@@ -77,6 +77,11 @@ aliases:
더 깊이 보기

직교화와 성향점수로 편향 제거하기, 예측이 실패하는 경우, 예측 지표의 함정, 컨포멀 추론.

+ +
용어집
+
한국어 용어 찾기
+

인과추론과 온라인 실험 용어의 번역어와 원어, 그 용어를 다루는 장을 한곳에 모았습니다.

+
``` @@ -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; +}