diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5331651 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy to .env and fill in. NEVER commit .env. +# cp .env.example .env + +# 공공데이터포털 (https://www.data.go.kr) — 일반 인증키 (Decoding) +DATA_GO_KR_API_KEY= + +# KOSIS 국가통계포털 OpenAPI (https://kosis.kr/openapi) +KOSIS_API_KEY= + +# LLM providers (use whichever you have; open models via OpenAI-compatible endpoint) +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +# e.g. vLLM / Ollama / LM Studio: http://localhost:11434/v1 +OPENAI_BASE_URL= +LLM_MODEL= diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2328210 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,27 @@ +# CODEOWNERS — PR이 해당 경로를 수정하면 소유자에게 자동으로 리뷰 요청이 갑니다. +# 채우는 법: +# 1) GitHub Org > Teams 에서 팀 생성 (예: core-maintainers, group1 ... group6) +# 2) 아래 @CausalInferenceLab/ 핸들을 실제 팀 이름으로 교체 +# 3) 조 폴더가 생기면 한 줄씩 추가: /cases/group3-*/ @CausalInferenceLab/group3 +# 4) 팀에 저장소 Write 권한을 부여해야 리뷰 요청이 동작합니다. +# 규칙: 아래쪽 줄이 위쪽 줄보다 우선합니다. + +# 기본: 운영진 +* @CausalInferenceLab/mentors + +# 공통 엔진 +/core/ @CausalInferenceLab/core-maintainers +/tests/core/ @CausalInferenceLab/core-maintainers + +# 운영/인프라 +/.github/ @CausalInferenceLab/mentors +/app/ @CausalInferenceLab/mentors +/cases/_template/ @CausalInferenceLab/mentors + +# 조별 케이스 (조 편성 후 주석 해제·수정) +# /cases/group1-*/ @CausalInferenceLab/group1 +# /cases/group2-*/ @CausalInferenceLab/group2 +# /cases/group3-*/ @CausalInferenceLab/group3 +# /cases/group4-*/ @CausalInferenceLab/group4 +# /cases/group5-*/ @CausalInferenceLab/group5 +# /cases/group6-*/ @CausalInferenceLab/group6 diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..b9c66f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,30 @@ +name: 버그 리포트 +description: 코드·CI·앱이 예상대로 동작하지 않을 때 +title: "[버그] " +labels: ["bug"] +body: + - type: input + id: where + attributes: + label: 위치 + placeholder: core/estimators, app/streamlit_app.py, cases/group3-.../estimate.py + validations: + required: true + - type: textarea + id: repro + attributes: + label: 재현 방법 + description: 실행한 명령어를 그대로 붙여 주세요 + render: bash + validations: + required: true + - type: textarea + id: error + attributes: + label: 에러 메시지 / 기대 동작 + render: text + - type: input + id: env + attributes: + label: 환경 + placeholder: macOS 14 / Python 3.11 / uv diff --git a/.github/ISSUE_TEMPLATE/case-proposal.yml b/.github/ISSUE_TEMPLATE/case-proposal.yml new file mode 100644 index 0000000..e876ea1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/case-proposal.yml @@ -0,0 +1,80 @@ +name: 케이스 제안 +description: 분석할 정책/사회 문제를 제안합니다 (조 주제 후보) +title: "[케이스] " +labels: ["case-proposal"] +body: + - type: markdown + attributes: + value: | + 결과를 보기 전에 질문을 먼저 적는 것이 목표입니다. 모르는 칸은 "미정"으로 두세요. + - type: input + id: group + attributes: + label: 조 + placeholder: group3 + validations: + required: true + - type: textarea + id: question + attributes: + label: 질문 + description: 어떤 정책이 어떤 결과를 바꿨는지, 한 문장으로 + placeholder: 2022년 청년월세 특별지원이 수혜 지역 청년 1인가구의 전출률을 낮췄는가? + validations: + required: true + - type: textarea + id: treatment + attributes: + label: 처치 (정책·시점·대상) + placeholder: 시행일, 적용 지역/집단, 처치 강도 + validations: + required: true + - type: textarea + id: control + attributes: + label: 대조군 + description: 정책이 없었다면 어땠을지 보여줄 비교 대상 + placeholder: 미시행 지역, 연령 경계 바로 위 집단 등 + validations: + required: true + - type: textarea + id: outcomes + attributes: + label: 결과 지표 + placeholder: 월별 시군구 전출률 (KOSIS 인구이동통계) + - type: textarea + id: data + attributes: + label: 데이터 출처 + description: 기관/데이터셋 이름과 URL, 기간·단위 + validations: + required: true + - type: dropdown + id: license + attributes: + label: 데이터 라이선스 + options: + - 공공누리 제1유형 (출처표시) + - 공공누리 제2유형 (출처표시+상업적 이용금지) + - 공공누리 제3유형 (출처표시+변경금지) + - 공공누리 제4유형 (출처표시+상업적 이용금지+변경금지) + - KOSIS 이용약관 + - 기타 / 확인 필요 + validations: + required: true + - type: dropdown + id: estimator + attributes: + label: 예상 추정 방법 + options: + - 이중차분(DiD) + - 이벤트 스터디 + - 합성통제(Synthetic Control) + - 단절적 시계열(ITS) + - 회귀불연속(RD) + - 모름 / 멘토링 필요 + - type: textarea + id: risks + attributes: + label: 우려되는 가정 위반·중단 조건 + placeholder: 동시 시행 정책, 사전 추세 불일치, 데이터 단절 등 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c6dc61b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: GitHub 사용법이 막혔어요 + url: https://github.com/CausalInferenceLab/policy-effect-analytics-agent/blob/main/docs/ops/github-onboarding.md + about: 초대 수락 → clone → 브랜치 → PR 단계별 가이드 + - name: 기여 규칙 + url: https://github.com/CausalInferenceLab/policy-effect-analytics-agent/blob/main/CONTRIBUTING.md + about: 브랜치·커밋·리뷰 규칙 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d0a083f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +## 무엇을 했나요? + + +## 조 / 케이스 + + +## 체크리스트 +- [ ] `plan.yaml`을 **결과를 보기 전에** 작성·커밋했습니다 (이후 변경 시 이유를 커밋 메시지에 기록) +- [ ] 데이터 출처와 **라이선스**(공공누리 유형 등)를 `plan.yaml > data_sources`에 명시했습니다 +- [ ] API 키·`.env`·개인정보가 담긴 원자료·재배포 불가 데이터를 포함하지 않았습니다 +- [ ] 그림/수치는 `fetch.py` → `estimate.py` 실행으로 **재현** 가능합니다 +- [ ] `make check` (ruff + pytest)가 통과합니다 +- [ ] 다른 조의 폴더나 `core/`를 (합의 없이) 수정하지 않았습니다 + +## 리뷰어에게 + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ddff42 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + # Tests must never hit external data APIs; keys are intentionally empty. + DATA_GO_KR_API_KEY: "" + KOSIS_API_KEY: "" + MPLBACKEND: Agg + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: pyproject.toml + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Ruff + run: | + ruff check . + ruff format --check . || echo "::warning::ruff format differences (run 'ruff format .')" + - name: Pytest + run: pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aba5276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +.python-version +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.ipynb_checkpoints/ +.coverage +htmlcov/ + +# Secrets — never commit keys +.env +.env.* +!.env.example +*.pem +*.key +secrets.toml +.streamlit/secrets.toml + +# Data: raw/intermediate files stay local (license + size + privacy) +data/raw/ +data/cache/ +**/data/raw/ +**/data/cache/ +*.parquet.tmp +*.xlsx~ +~$* + +# OS / editors +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..26737db --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-yaml + - id: check-added-large-files + args: [--maxkb=5000] + - id: detect-private-key diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b93459d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,14 @@ +# 행동 강령 (Code of Conduct) + +이 프로젝트는 [Contributor Covenant 2.1](https://www.contributor-covenant.org/ko/version/2/1/code_of_conduct/)을 따릅니다. + +## 요약 + +- 경험·배경·직무와 관계없이 모두를 존중합니다. 처음 GitHub를 쓰는 분의 질문을 환영합니다. +- 비판은 **코드와 분석**에 대해, 구체적이고 건설적으로 합니다. +- 괴롭힘, 차별적 언행, 타인의 개인정보 공개는 허용되지 않습니다. +- 공공데이터를 다룰 때 개인·기관을 특정하거나 낙인찍는 해석을 피합니다. + +## 신고 + +위반 사례는 멘토(신진수) 또는 운영진에게 GitHub 비공개 메시지나 프로그램 공식 채널로 알려 주세요. 신고자의 신원은 보호됩니다. 위반 시 경고, 일시적 또는 영구적 참여 제한이 있을 수 있습니다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4bb8d3a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# 기여 가이드 (CONTRIBUTING) + +GitHub 협업이 처음이라면 먼저 [`docs/ops/github-onboarding.md`](docs/ops/github-onboarding.md)를 따라 하세요. + +## 1. 작업 방식: 조별 브랜치 (권장) + +| 방식 | 언제 | 비고 | +|---|---|---| +| **조직 저장소에서 브랜치** (권장) | 조직 초대를 수락한 멘티 | CI·리뷰·모니터링이 한 곳에서 보임 | +| Fork → PR | 초대 전이거나 외부 기여자 | PR 대상은 `main` | + +- `main`은 보호 브랜치입니다. **직접 push 금지, PR로만 병합.** +- 브랜치 이름: `<조>/<작업>` — 예) `group3/plan`, `group3/fetch-kosis`, `group1/fix-report` +- 공통 코드(`core/`)는 `core/<작업>` 브랜치로, 먼저 이슈에서 논의한 뒤 수정합니다. + +## 2. 폴더 소유권 + +| 경로 | 소유 | 규칙 | +|---|---|---| +| `cases/<조>-<주제>/` | 해당 조 | 조 안에서 자유롭게. 다른 조 폴더는 수정하지 않음 | +| `core/` | 멘토·코어 메인테이너 | 이슈 → 합의 → PR. 테스트 필수 | +| `cases/_template/`, `app/`, `.github/`, `docs/` | 운영진 | 개선 제안은 이슈로 | + +소유자는 [`.github/CODEOWNERS`](.github/CODEOWNERS)로 자동 리뷰 요청됩니다. + +## 3. 커밋 규칙 (Conventional Commits) + +``` +(): <요약, 50자 이내> +``` + +- type: `feat` 기능 · `fix` 버그 · `data` 수집/전처리 · `analysis` 추정/그림 · `docs` 문서 · `plan` plan.yaml · `test` · `chore` +- scope: 조 폴더명 또는 `core`, `app` +- 예) `plan(group3-youth-rent): 처치·대조 지역 정의`, `analysis(group3-youth-rent): DiD 1차 추정` +- 작은 단위로 자주 커밋하세요. 주 1회 이상 커밋이 활동 확인 기준입니다. + +## 4. PR 흐름 + +1. 최신 `main`에서 브랜치 생성 → 작업 → `make check` 통과 확인 +2. PR 생성 (템플릿 체크리스트 작성). 작업 중이면 **Draft PR**로 일찍 올리세요. +3. 리뷰: **승인 1명 + CI 통과** 시 병합 (조원 상호 리뷰 가능, `core/`는 코어 메인테이너 승인) +4. 병합 방식: **Squash merge** (PR 제목이 커밋 메시지가 되므로 규칙에 맞게) +5. 병합 후 브랜치 삭제, 로컬 `git switch main && git pull` + +## 5. 분석 원칙 (리뷰에서 확인) + +- **plan.yaml을 결과보다 먼저 커밋** (사전 등록). 이후 변경은 커밋 메시지에 이유를 적습니다. +- 데이터 출처·라이선스 명시 (공공누리 유형 등). 재배포 불가 원자료, 개인정보, API 키는 커밋 금지. +- 그림·수치는 `estimate.py` 실행으로 재현 가능해야 합니다. +- 가정이 깨지면 `abstention` 규칙에 따라 **결론을 보류**하는 것도 좋은 결과입니다. + +## 6. 개발 환경 + +```bash +make install # 의존성 설치 (dev 포함) +make check # ruff + pytest — CI와 동일 +make app # Streamlit 로컬 실행 +``` + +선택: `pre-commit install` 로 커밋 시 ruff 자동 실행. + +## 7. 질문·제안 + +- 버그: 이슈 → `버그 리포트` +- 새 케이스 주제: 이슈 → `케이스 제안` +- 행동 강령: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..418f7e6 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +.PHONY: install check lint test format app activity + +PY ?= python + +install: + @command -v uv >/dev/null 2>&1 && uv pip install -e ".[dev]" || $(PY) -m pip install -e ".[dev]" + +check: lint test + +lint: + ruff check . + +test: + pytest -q + +format: + ruff format . && ruff check --fix . + +app: + streamlit run app/streamlit_app.py + +activity: + $(PY) scripts/weekly_activity.py --days 7 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a69322 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# policy-effect-analytics-agent + +> **에이전틱 AI × 데이터 — 문제 정의부터 효과 분석 자동화까지** +> NIPA OpenUp 오픈소스 AI 특화형 2차 · Track 3 · 멘토 신진수 (가짜연구소 인과추론팀) + +**English summary.** An open-source toolkit and case library for estimating the effects of Korean public policies with open data. Each group writes a pre-registered `plan.yaml`, fetches public data, runs a causal estimator (DiD / event study / synthetic control …), and publishes a reproducible report. LLM agents (LangGraph + open LLMs) automate collect → metrics → estimate → report. Results are browsable in a Streamlit app. + +--- + +## 왜 하나요? + +1. 공공·사회 문제를 **데이터로 정의**하고, 그 해법(정책)이 **실제로 어떤 효과를 냈는지 추정**합니다. +2. 수집 → 지표 구조화 → 효과 추정 → 리포팅 전 과정을 **LLM 에이전트로 자동화**합니다. +3. 같은 문제를 다루는 누구나 바로 쓸 수 있도록 **GitHub + 분석 플랫폼(Streamlit)** 으로 공개합니다. + +핵심 원칙: **결과를 보기 전에 `plan.yaml`을 먼저 커밋한다.** (사전 등록 → 사후 끼워맞추기 방지) + +## 저장소 구조 + +``` +core/ 공통 엔진 (Data 담당) — 어댑터, schema/plan.py, estimators, report, agent +cases/ + _template/ 새 케이스 시작용 템플릿 (복사해서 사용) + _example_*/ 참고용 예시 케이스 + <조-주제>/ 조별 케이스 (plan.yaml, fetch.py, estimate.py, report.md, figures/) +app/streamlit_app.py 케이스 브라우저 (API 키 없이 실행) +docs/ops/ GitHub 온보딩, 모니터링 가이드 +docs/strategy/ 문제 정의·전략 문서 +scripts/ 운영 스크립트 (weekly_activity.py 등) +tests/ 테스트 +.github/ CI, PR/이슈 템플릿, CODEOWNERS +``` + +## 빠른 시작 + +```bash +git clone https://github.com/CausalInferenceLab/policy-effect-analytics-agent.git +cd policy-effect-analytics-agent + +# uv 권장 (pip도 가능: python -m venv .venv && pip install -e ".[dev]") +uv venv -p 3.11 && source .venv/bin/activate +make install # = uv pip install -e ".[dev]" +cp .env.example .env # API 키 입력 (공공데이터포털, KOSIS, LLM) + +make check # ruff + pytest +make app # Streamlit 케이스 브라우저 (http://localhost:8501) +``` + +에이전트/인과 추가 기능: `uv pip install -e ".[agent,causal]"` + +## 케이스 추가하기 (4단계) + +```bash +git switch -c group3/plan +cp -r cases/_template cases/group3-youth-rent # 폴더명: <조>-<주제>, 소문자-하이픈 +``` + +1. **질문 정의** — `plan.yaml` 작성 (질문·처치·대조·시점·지표·추정법·가정·중단조건·데이터 라이선스) → **먼저 PR** +2. **수집** — `fetch.py`: 공공데이터 → `data/raw/`(커밋 금지) → 정제 결과만 `data/processed/` +3. **추정** — `estimate.py`: `core.estimators`로 효과 추정 + 반증(placebo 등) → `figures/*.png` +4. **리포트** — `report.md`: 결과·한계·정책 시사점. `make app`에서 바로 보입니다. + +자세한 절차: [`cases/_template/README.md`](cases/_template/README.md), 협업 규칙: [`CONTRIBUTING.md`](CONTRIBUTING.md), GitHub가 처음이라면: [`docs/ops/github-onboarding.md`](docs/ops/github-onboarding.md) + +## 7주 로드맵 + +| 주차 | 목표 | 산출물 (커밋 기준) | +|---|---|---| +| 1 | 온보딩·조 편성·주제 후보 | 이슈 `케이스 제안` 등록, 첫 PR(자기소개/브랜치) | +| 2 | 문제 정의·데이터 탐색 | `cases/<조>/plan.yaml` 초안 PR (결과 보기 전) | +| 3 | 수집 자동화 | `fetch.py`, 데이터 출처·라이선스 명시 | +| 4 | 지표 구조화·1차 추정 | `estimate.py`, 기본 그림 | +| 5 | 강건성·반증 + 에이전트화 | placebo/민감도, LangGraph 노드 연결 | +| 6 | 리포트·플랫폼 | `report.md`, Streamlit 반영 | +| 7 | 발표·회고·공개 정리 | 최종 PR 머지, 릴리스 태그 | + +## 라이선스 + +- **코드**: MIT ([LICENSE](LICENSE)) — © 가짜연구소 Causal Inference Team +- **데이터**: 각 출처의 이용 조건을 따릅니다 (공공누리 제1~4유형, KOSIS 이용약관 등). 각 케이스의 `plan.yaml > data_sources[].license`에 반드시 명시하고, 재배포가 제한된 원자료는 커밋하지 않습니다. +- 개인정보가 포함된 원자료는 어떤 경우에도 커밋하지 않습니다. + +## 기여 + +[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) diff --git a/app/streamlit_app.py b/app/streamlit_app.py new file mode 100644 index 0000000..bb4be4b --- /dev/null +++ b/app/streamlit_app.py @@ -0,0 +1,149 @@ +"""Case browser: discovers cases/*/ and renders plan.yaml, report.md and figures. + +Run: streamlit run app/streamlit_app.py (or `make app`) +Needs no API keys; it only reads files committed to the repo. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CASES_DIR = ROOT / "cases" + + +@dataclass +class Case: + slug: str + path: Path + plan: dict[str, Any] = field(default_factory=dict) + plan_error: str | None = None + report: str | None = None + figures: list[Path] = field(default_factory=list) + + @property + def is_template(self) -> bool: + return self.slug.startswith("_") + + @property + def title(self) -> str: + return str(self.plan.get("title") or self.plan.get("case_id") or self.slug) + + +def load_case(path: Path) -> Case: + case = Case(slug=path.name, path=path) + plan_path = path / "plan.yaml" + if plan_path.exists(): + try: + data = yaml.safe_load(plan_path.read_text(encoding="utf-8")) or {} + case.plan = data if isinstance(data, dict) else {} + except yaml.YAMLError as exc: + case.plan_error = str(exc) + report_path = path / "report.md" + if report_path.exists(): + case.report = report_path.read_text(encoding="utf-8") + fig_dir = path / "figures" + if fig_dir.is_dir(): + case.figures = sorted(fig_dir.glob("*.png")) + return case + + +def discover_cases(cases_dir: Path = CASES_DIR, include_templates: bool = False) -> list[Case]: + if not cases_dir.is_dir(): + return [] + cases = [ + load_case(p) + for p in sorted(cases_dir.iterdir()) + if p.is_dir() and not p.name.startswith(".") and (p / "plan.yaml").exists() + ] + return [c for c in cases if include_templates or not c.is_template] + + +def _describe(value: Any) -> str: + if isinstance(value, dict): + for key in ("description", "definition", "method", "name"): + if value.get(key): + return str(value[key]) + return str(value) + if isinstance(value, list): + return ", ".join(str(v.get("name", v)) if isinstance(v, dict) else str(v) for v in value) + return "" if value is None else str(value) + + +def _treat_time(plan: dict[str, Any]) -> str: + treatment = plan.get("treatment") + if isinstance(treatment, dict) and treatment.get("treat_time"): + return str(treatment["treat_time"]) + return str(plan.get("treatment_time") or "-") + + +def render_card(st: Any, case: Case) -> None: + plan = case.plan + with st.container(border=True): + st.subheader(case.title) + st.caption(f"`cases/{case.slug}` · owners: {_describe(plan.get('owners')) or '-'}") + if case.plan_error: + st.error(f"plan.yaml 파싱 오류: {case.plan_error}") + return + st.markdown(f"**질문** {plan.get('question', '-')}") + cols = st.columns(3) + cols[0].markdown(f"**처치** \n{_describe(plan.get('treatment'))}") + cols[1].markdown(f"**대조** \n{_describe(plan.get('control'))}") + cols[2].markdown( + f"**시점 / 방법** \n{_treat_time(plan)} · {_describe(plan.get('estimator'))}" + ) + licenses = { + str(s.get("license", "?")) + for s in plan.get("data_sources") or [] + if isinstance(s, dict) + } + st.caption("데이터 라이선스: " + (", ".join(sorted(licenses)) or "미기재")) + + +def render_detail(st: Any, case: Case) -> None: + tab_report, tab_figs, tab_plan = st.tabs(["리포트", "그림", "plan.yaml"]) + with tab_report: + if case.report: + st.markdown(case.report) + else: + st.info("report.md가 아직 없습니다.") + with tab_figs: + if case.figures: + for fig in case.figures: + st.image(str(fig), caption=fig.name) + else: + st.info("figures/*.png가 아직 없습니다.") + with tab_plan: + st.code((case.path / "plan.yaml").read_text(encoding="utf-8"), language="yaml") + + +def main() -> None: + import streamlit as st + + st.set_page_config(page_title="정책 효과 분석 케이스", layout="wide") + st.title("정책 효과 분석 케이스") + st.caption("에이전틱 AI × 데이터 — CausalInferenceLab/policy-effect-analytics-agent") + + show_templates = st.sidebar.toggle("템플릿/예시 포함", value=False) + cases = discover_cases(include_templates=show_templates) + if not cases: + st.info("아직 케이스가 없습니다. `cases/_template`을 복사해 시작하세요.") + return + + for case in cases: + render_card(st, case) + + st.divider() + selected = st.sidebar.selectbox( + "케이스 상세", cases, format_func=lambda c: f"{c.title} ({c.slug})" + ) + st.header(selected.title) + render_detail(st, selected) + + +if __name__ == "__main__": + main() diff --git a/cases/_example_night_clinic/README.md b/cases/_example_night_clinic/README.md new file mode 100644 index 0000000..26aafe8 --- /dev/null +++ b/cases/_example_night_clinic/README.md @@ -0,0 +1,31 @@ +# [예제] 달빛어린이병원 × 소아 야간 경증 응급실 방문 — DiD / Event study + +> ## ⚠️ 이 케이스의 데이터는 **합성(synthetic)** 입니다 +> `core.adapters.synthetic.simulate_panel` 로 만든 가짜 시군구 패널(80개 지역, 2012–2019, +> 30개 지역 2016년 동시 지정, **참효과 = -5.0**)입니다. 결과 수치는 실제 정책 효과가 **아니며**, +> 파이프라인(plan → fetch → estimate → report)이 참값을 복원하는지 보여주는 시연용입니다. + +## 실행 +```bash +python cases/_example_night_clinic/fetch.py # data/panel.csv (+ panel.source.json) +python cases/_example_night_clinic/estimate.py # figures/*.png, report.md, results.json +``` + +## 파일 +| 파일 | 역할 | +|---|---| +| `plan.yaml` | 분석계획: 질문·처치/통제·결과변수·가정·반박검정·**보류 조건** (`core/schema/plan.py` 로 검증) | +| `fetch.py` | 데이터 수집 → `data/` 스냅샷 + 출처 JSON | +| `estimate.py` | `core.pipeline.run_plan` 실행 + 보조 DiD + 그림 + 리포트 | +| `report.md` | 자동 생성 리포트 (판정·경고가 수치보다 먼저) | + +## 우리 조 케이스로 바꾸려면 +1. 이 폴더를 `cases/<조이름>_<주제>/` 로 복사 (또는 `cases/_template/` 사용) +2. `plan.yaml` 을 먼저 쓴다 — 특히 `control.rationale` 과 `abstention` 은 데이터 보기 **전에** 확정 +3. `fetch.py` 를 실데이터 어댑터로 교체하고 `synthetic_data: false`, `data_sources.license` 를 공공누리 유형으로 +4. 실제 지정 시점이 지역마다 다르면 `treatment.first_treat_col` 사용 → 시차도입 경고가 뜨는 것이 정상. + 이 경우 TWFE 결과는 `not_identified` 로 보류되며, Callaway–Sant'Anna 추정이 필요합니다(멘토 논의). + +## 해볼 것 (W6 과잉해석 방지 실습) +`fetch.py` 의 `simulate_panel(..., pretrend_slope=1.5, effect=0)` 로 바꿔 다시 실행해 보세요. +효과가 "유의하게" 나오더라도 사전추세 검정이 기각되어 판정이 **식별 불가**로 바뀝니다. diff --git a/cases/_example_night_clinic/data/README.md b/cases/_example_night_clinic/data/README.md new file mode 100644 index 0000000..d9d9bfb --- /dev/null +++ b/cases/_example_night_clinic/data/README.md @@ -0,0 +1,14 @@ +# data/ + +| 파일 | 내용 | 출처 / 라이선스 | +|---|---|---| +| `panel.csv` | **합성** 시군구×연도 패널 (`region_id, year, treated, first_treat, night_ed_rate`) | `simulate_panel(seed=42)` / synthetic | +| `panel.source.json` | 생성 파라미터·시각 (재현용) | — | + +## 실데이터 후보 (조별 확인 필요 — 가용성·공간단위·라이선스 미검증) +- **처치 시점**: 보건복지부/지자체 달빛어린이병원 지정 현황 (공공데이터포털 파일데이터 또는 보도자료) +- **결과변수**: 국가응급진료정보망(NEDIS) 기반 응급실 이용 통계 — 시군구·시간대·KTAS 단위 공개 여부 확인 필요. + 공개 수준이 시도 단위뿐이면 클러스터가 17개 → `few_clusters` 경고 대상 +- **분모**: KOSIS 주민등록인구(0~14세) — `core.adapters.KosisAdapter` (`KOSIS_API_KEY` 필요) + +원천 데이터는 가능하면 커밋하지 말고 `fetch.py` 로 재생성합니다. 크기가 작고 라이선스가 허용(공공누리 1유형 등)하면 스냅샷 커밋 가능. diff --git a/cases/_example_night_clinic/data/panel.csv b/cases/_example_night_clinic/data/panel.csv new file mode 100644 index 0000000..25258d2 --- /dev/null +++ b/cases/_example_night_clinic/data/panel.csv @@ -0,0 +1,641 @@ +region_id,year,treated,first_treat,night_ed_rate +R000,2012,1,2016.0,53.1221112878512 +R000,2013,1,2016.0,53.35510253977927 +R000,2014,1,2016.0,54.183894251695435 +R000,2015,1,2016.0,53.04163169653151 +R000,2016,1,2016.0,47.53325414778534 +R000,2017,1,2016.0,48.866506094296525 +R000,2018,1,2016.0,46.01045294613245 +R000,2019,1,2016.0,46.99464510482064 +R001,2012,1,2016.0,42.79171281535594 +R001,2013,1,2016.0,43.74404060254178 +R001,2014,1,2016.0,46.9291665572221 +R001,2015,1,2016.0,44.7041386248556 +R001,2016,1,2016.0,40.90001973382024 +R001,2017,1,2016.0,44.90089318170682 +R001,2018,1,2016.0,41.94908794195102 +R001,2019,1,2016.0,44.64039525662635 +R002,2012,1,2016.0,52.522053190279756 +R002,2013,1,2016.0,54.27983565224425 +R002,2014,1,2016.0,53.181750504188834 +R002,2015,1,2016.0,54.78920709400936 +R002,2016,1,2016.0,52.28913762704857 +R002,2017,1,2016.0,47.799972249984755 +R002,2018,1,2016.0,52.48263968140036 +R002,2019,1,2016.0,52.5930118345915 +R003,2012,1,2016.0,54.15155624182971 +R003,2013,1,2016.0,52.74916266212639 +R003,2014,1,2016.0,56.17662123236152 +R003,2015,1,2016.0,55.35885543060667 +R003,2016,1,2016.0,52.02444137776697 +R003,2017,1,2016.0,52.24888499034012 +R003,2018,1,2016.0,55.76791777987425 +R003,2019,1,2016.0,52.58939697790468 +R004,2012,1,2016.0,38.83486164275432 +R004,2013,1,2016.0,41.54183011559068 +R004,2014,1,2016.0,42.01435605965206 +R004,2015,1,2016.0,44.6782164739899 +R004,2016,1,2016.0,38.77131192166147 +R004,2017,1,2016.0,38.460923292367006 +R004,2018,1,2016.0,41.0329662544463 +R004,2019,1,2016.0,36.232582598979775 +R005,2012,1,2016.0,42.84663197145614 +R005,2013,1,2016.0,42.57440537182459 +R005,2014,1,2016.0,44.039021494354586 +R005,2015,1,2016.0,42.450747437353996 +R005,2016,1,2016.0,41.615669732418716 +R005,2017,1,2016.0,40.54701418882804 +R005,2018,1,2016.0,38.40902629195945 +R005,2019,1,2016.0,39.82322895416799 +R006,2012,1,2016.0,51.90326228200404 +R006,2013,1,2016.0,53.25390994057241 +R006,2014,1,2016.0,55.96220243419771 +R006,2015,1,2016.0,58.18194421462929 +R006,2016,1,2016.0,48.32428625548991 +R006,2017,1,2016.0,46.16248289310987 +R006,2018,1,2016.0,44.23664586965016 +R006,2019,1,2016.0,49.539909591418755 +R007,2012,1,2016.0,47.42993741892867 +R007,2013,1,2016.0,48.526527306869944 +R007,2014,1,2016.0,48.52413207514018 +R007,2015,1,2016.0,49.85222253222752 +R007,2016,1,2016.0,47.40701287295887 +R007,2017,1,2016.0,46.23524129050995 +R007,2018,1,2016.0,45.96305377948107 +R007,2019,1,2016.0,44.712764183525124 +R008,2012,1,2016.0,47.20366089433707 +R008,2013,1,2016.0,49.88183318327912 +R008,2014,1,2016.0,51.13796774581963 +R008,2015,1,2016.0,55.16687130641653 +R008,2016,1,2016.0,47.03280862852582 +R008,2017,1,2016.0,49.383830351861846 +R008,2018,1,2016.0,46.77893943067661 +R008,2019,1,2016.0,45.91139133053917 +R009,2012,1,2016.0,44.44157940893458 +R009,2013,1,2016.0,45.22278302200782 +R009,2014,1,2016.0,51.321258461980136 +R009,2015,1,2016.0,45.80702427046153 +R009,2016,1,2016.0,44.26802414270628 +R009,2017,1,2016.0,41.431283123295216 +R009,2018,1,2016.0,45.45946280315694 +R009,2019,1,2016.0,44.86996694523848 +R010,2012,1,2016.0,54.72074665026519 +R010,2013,1,2016.0,55.2539196109905 +R010,2014,1,2016.0,54.41695311843307 +R010,2015,1,2016.0,57.00415154405651 +R010,2016,1,2016.0,50.34328828673408 +R010,2017,1,2016.0,49.44813546411682 +R010,2018,1,2016.0,49.70265114095126 +R010,2019,1,2016.0,53.10745036065304 +R011,2012,1,2016.0,57.68417476123284 +R011,2013,1,2016.0,55.147397693238844 +R011,2014,1,2016.0,54.98122165990269 +R011,2015,1,2016.0,56.17562922312803 +R011,2016,1,2016.0,53.357228533659885 +R011,2017,1,2016.0,51.83008179720962 +R011,2018,1,2016.0,50.92864163075362 +R011,2019,1,2016.0,54.46682174815918 +R012,2012,1,2016.0,51.82469893599653 +R012,2013,1,2016.0,54.34012026074529 +R012,2014,1,2016.0,52.0261609972276 +R012,2015,1,2016.0,49.596232691143285 +R012,2016,1,2016.0,44.450100462416245 +R012,2017,1,2016.0,51.134366469605744 +R012,2018,1,2016.0,51.63902134464903 +R012,2019,1,2016.0,48.33639971213554 +R013,2012,1,2016.0,55.506863963835414 +R013,2013,1,2016.0,59.497549408271794 +R013,2014,1,2016.0,54.7516533057309 +R013,2015,1,2016.0,55.561769263706715 +R013,2016,1,2016.0,53.77912499731975 +R013,2017,1,2016.0,52.34935290645815 +R013,2018,1,2016.0,53.48749871667638 +R013,2019,1,2016.0,53.6746048886858 +R014,2012,1,2016.0,53.649728380125325 +R014,2013,1,2016.0,56.09096522283489 +R014,2014,1,2016.0,53.84825516004143 +R014,2015,1,2016.0,55.340441564592155 +R014,2016,1,2016.0,45.09346788229968 +R014,2017,1,2016.0,49.74246702473717 +R014,2018,1,2016.0,48.512622585950844 +R014,2019,1,2016.0,48.26520524130763 +R015,2012,1,2016.0,44.58426552299353 +R015,2013,1,2016.0,45.973745593129415 +R015,2014,1,2016.0,48.86488140527354 +R015,2015,1,2016.0,44.765769516882884 +R015,2016,1,2016.0,42.62106604387414 +R015,2017,1,2016.0,42.23755593625382 +R015,2018,1,2016.0,42.90972791213624 +R015,2019,1,2016.0,46.074337987087674 +R016,2012,1,2016.0,53.55701736568758 +R016,2013,1,2016.0,55.45700492424521 +R016,2014,1,2016.0,52.8642811968904 +R016,2015,1,2016.0,52.166885963849296 +R016,2016,1,2016.0,48.25230165975299 +R016,2017,1,2016.0,49.83110462029211 +R016,2018,1,2016.0,50.058437052596275 +R016,2019,1,2016.0,48.04026242684042 +R017,2012,1,2016.0,46.02359913037798 +R017,2013,1,2016.0,46.60049844508004 +R017,2014,1,2016.0,51.570073705900555 +R017,2015,1,2016.0,50.67429348519655 +R017,2016,1,2016.0,40.35536566783886 +R017,2017,1,2016.0,42.13317739009383 +R017,2018,1,2016.0,40.14023940745736 +R017,2019,1,2016.0,42.38945761882269 +R018,2012,1,2016.0,55.66049408498427 +R018,2013,1,2016.0,57.742413537260056 +R018,2014,1,2016.0,54.26362246602685 +R018,2015,1,2016.0,54.79897589317897 +R018,2016,1,2016.0,46.953938820160545 +R018,2017,1,2016.0,51.56927678278372 +R018,2018,1,2016.0,50.12895909809359 +R018,2019,1,2016.0,51.69865730269887 +R019,2012,1,2016.0,48.63368146000078 +R019,2013,1,2016.0,50.50030012551085 +R019,2014,1,2016.0,47.564452297333176 +R019,2015,1,2016.0,48.53129722106495 +R019,2016,1,2016.0,50.86513004222682 +R019,2017,1,2016.0,44.677882399857836 +R019,2018,1,2016.0,45.41833570343581 +R019,2019,1,2016.0,51.78948215260602 +R020,2012,1,2016.0,55.522855092021736 +R020,2013,1,2016.0,47.671009713570236 +R020,2014,1,2016.0,49.668728903675344 +R020,2015,1,2016.0,51.473816550932916 +R020,2016,1,2016.0,49.38934884418637 +R020,2017,1,2016.0,44.60433114275439 +R020,2018,1,2016.0,46.44666890566583 +R020,2019,1,2016.0,48.99564798529184 +R021,2012,1,2016.0,48.10191699818076 +R021,2013,1,2016.0,46.781494924467324 +R021,2014,1,2016.0,47.65724498392592 +R021,2015,1,2016.0,45.56057792801854 +R021,2016,1,2016.0,42.97527016313246 +R021,2017,1,2016.0,43.564934415299554 +R021,2018,1,2016.0,44.92122847250916 +R021,2019,1,2016.0,43.84998249123692 +R022,2012,1,2016.0,57.692816309727654 +R022,2013,1,2016.0,59.07659311795703 +R022,2014,1,2016.0,57.75310398368479 +R022,2015,1,2016.0,58.53123677694676 +R022,2016,1,2016.0,53.07528276162738 +R022,2017,1,2016.0,53.615232596890145 +R022,2018,1,2016.0,52.53112704156081 +R022,2019,1,2016.0,55.11097986761143 +R023,2012,1,2016.0,49.66981196409623 +R023,2013,1,2016.0,54.3521439964644 +R023,2014,1,2016.0,53.70360102958418 +R023,2015,1,2016.0,51.71406296154719 +R023,2016,1,2016.0,44.55750354336794 +R023,2017,1,2016.0,44.50488676302632 +R023,2018,1,2016.0,49.47117491111007 +R023,2019,1,2016.0,48.11813569084492 +R024,2012,1,2016.0,49.45568026723492 +R024,2013,1,2016.0,45.307643704183306 +R024,2014,1,2016.0,51.04277648727854 +R024,2015,1,2016.0,50.482218832391396 +R024,2016,1,2016.0,42.493764893457005 +R024,2017,1,2016.0,44.41766839162174 +R024,2018,1,2016.0,46.247331713173864 +R024,2019,1,2016.0,46.32857913679161 +R025,2012,1,2016.0,48.29202244721897 +R025,2013,1,2016.0,48.97081050037416 +R025,2014,1,2016.0,49.06491612612279 +R025,2015,1,2016.0,50.259474538541994 +R025,2016,1,2016.0,48.038581566646656 +R025,2017,1,2016.0,40.60837248303342 +R025,2018,1,2016.0,45.627168133815836 +R025,2019,1,2016.0,46.9576417411984 +R026,2012,1,2016.0,53.89056647840817 +R026,2013,1,2016.0,52.856171554071395 +R026,2014,1,2016.0,50.47764099778729 +R026,2015,1,2016.0,55.03255416197317 +R026,2016,1,2016.0,52.97251172919661 +R026,2017,1,2016.0,47.09618023527164 +R026,2018,1,2016.0,52.25073837043948 +R026,2019,1,2016.0,50.36978013241561 +R027,2012,1,2016.0,52.341604201429924 +R027,2013,1,2016.0,50.65987808935972 +R027,2014,1,2016.0,52.487846612086344 +R027,2015,1,2016.0,56.142326777996516 +R027,2016,1,2016.0,49.848796178897295 +R027,2017,1,2016.0,52.79420064997745 +R027,2018,1,2016.0,52.043580515052135 +R027,2019,1,2016.0,51.070678330574204 +R028,2012,1,2016.0,56.188564681582754 +R028,2013,1,2016.0,53.88010416440377 +R028,2014,1,2016.0,55.04917804655177 +R028,2015,1,2016.0,53.18553841768873 +R028,2016,1,2016.0,49.053020062799845 +R028,2017,1,2016.0,48.17117252871656 +R028,2018,1,2016.0,51.90436734244601 +R028,2019,1,2016.0,48.072340462644874 +R029,2012,1,2016.0,54.35583827114196 +R029,2013,1,2016.0,52.71125768897632 +R029,2014,1,2016.0,55.82613783695176 +R029,2015,1,2016.0,55.370860259280136 +R029,2016,1,2016.0,52.65166270343451 +R029,2017,1,2016.0,51.11801151465387 +R029,2018,1,2016.0,46.87156091900928 +R029,2019,1,2016.0,50.385483320137666 +R030,2012,0,,59.001256190429906 +R030,2013,0,,60.610133108566984 +R030,2014,0,,65.06023350588346 +R030,2015,0,,63.69832289292591 +R030,2016,0,,61.166642520296314 +R030,2017,0,,61.18316038880868 +R030,2018,0,,63.635338605381186 +R030,2019,0,,61.64040235648472 +R031,2012,0,,47.26267693455099 +R031,2013,0,,49.530398650126635 +R031,2014,0,,51.60808762526803 +R031,2015,0,,50.90046512930401 +R031,2016,0,,45.241611273982215 +R031,2017,0,,51.079015493474614 +R031,2018,0,,50.973528479818434 +R031,2019,0,,52.16099013971697 +R032,2012,0,,51.30823828921195 +R032,2013,0,,44.25076458998005 +R032,2014,0,,47.58611813923781 +R032,2015,0,,50.335616267007225 +R032,2016,0,,46.13186292883592 +R032,2017,0,,52.893041568086495 +R032,2018,0,,51.03703599531998 +R032,2019,0,,52.49723897722377 +R033,2012,0,,45.42628160698425 +R033,2013,0,,48.49711852764026 +R033,2014,0,,49.3976181057164 +R033,2015,0,,48.11190966581315 +R033,2016,0,,48.25620351725949 +R033,2017,0,,48.97417995466123 +R033,2018,0,,47.0659822445582 +R033,2019,0,,49.001363649174564 +R034,2012,0,,53.411884619239984 +R034,2013,0,,54.78513963053169 +R034,2014,0,,56.40908424167577 +R034,2015,0,,52.67784221569886 +R034,2016,0,,54.68614442533981 +R034,2017,0,,58.545165325532594 +R034,2018,0,,54.454257070528705 +R034,2019,0,,54.800681729191496 +R035,2012,0,,56.68650641853407 +R035,2013,0,,58.27208663352232 +R035,2014,0,,56.99725220970348 +R035,2015,0,,60.01779991125664 +R035,2016,0,,59.214714801839 +R035,2017,0,,59.83085871472175 +R035,2018,0,,59.614630881528996 +R035,2019,0,,63.66545231519129 +R036,2012,0,,49.65697190526076 +R036,2013,0,,46.361672916539824 +R036,2014,0,,53.96831006973762 +R036,2015,0,,50.22988111659018 +R036,2016,0,,51.50228897351318 +R036,2017,0,,54.551721198783845 +R036,2018,0,,49.08728004488413 +R036,2019,0,,50.292252934408005 +R037,2012,0,,43.233694312216876 +R037,2013,0,,45.14939982408374 +R037,2014,0,,48.008029462199055 +R037,2015,0,,48.56261056759111 +R037,2016,0,,48.20803134694596 +R037,2017,0,,45.4760429647398 +R037,2018,0,,44.040547157198425 +R037,2019,0,,49.273209436862786 +R038,2012,0,,45.57107442553436 +R038,2013,0,,47.734820253924255 +R038,2014,0,,48.61103980896406 +R038,2015,0,,47.86909405243937 +R038,2016,0,,49.25412546539701 +R038,2017,0,,48.83837378711111 +R038,2018,0,,49.7992597480231 +R038,2019,0,,47.8335320466987 +R039,2012,0,,53.53077368272781 +R039,2013,0,,54.584970921384645 +R039,2014,0,,56.22355952493605 +R039,2015,0,,54.18049886145256 +R039,2016,0,,56.15156382368922 +R039,2017,0,,55.22364347262992 +R039,2018,0,,55.879416019743594 +R039,2019,0,,58.27728667406631 +R040,2012,0,,50.367182685176125 +R040,2013,0,,52.06178098881492 +R040,2014,0,,52.08143869610928 +R040,2015,0,,50.764055883098806 +R040,2016,0,,54.21600734880835 +R040,2017,0,,57.71749577280986 +R040,2018,0,,56.00803913852842 +R040,2019,0,,57.477135670795576 +R041,2012,0,,55.53123890621478 +R041,2013,0,,56.30959839500858 +R041,2014,0,,53.9070341070514 +R041,2015,0,,57.13796038744297 +R041,2016,0,,54.75629003149568 +R041,2017,0,,53.543332011208214 +R041,2018,0,,54.388507052404854 +R041,2019,0,,53.119982967290596 +R042,2012,0,,45.53321632735645 +R042,2013,0,,46.894872876354896 +R042,2014,0,,49.60916013729144 +R042,2015,0,,51.8290083926667 +R042,2016,0,,45.76435380590394 +R042,2017,0,,49.96046351741354 +R042,2018,0,,47.452900000517815 +R042,2019,0,,50.98713029137759 +R043,2012,0,,51.53566588522537 +R043,2013,0,,48.43744975258576 +R043,2014,0,,54.346939233448275 +R043,2015,0,,51.66582245649434 +R043,2016,0,,51.94927151168798 +R043,2017,0,,51.523658910091385 +R043,2018,0,,52.713776477234845 +R043,2019,0,,55.38187214761809 +R044,2012,0,,50.841972935098056 +R044,2013,0,,52.17920783921583 +R044,2014,0,,52.63681138853904 +R044,2015,0,,54.939769623538176 +R044,2016,0,,51.75412211707637 +R044,2017,0,,50.13207052934934 +R044,2018,0,,55.57941029412396 +R044,2019,0,,53.285737352403785 +R045,2012,0,,53.95966044406704 +R045,2013,0,,52.798651998957226 +R045,2014,0,,52.160706558216084 +R045,2015,0,,53.506012038515856 +R045,2016,0,,56.85173347699916 +R045,2017,0,,57.749761159089736 +R045,2018,0,,54.093741669186976 +R045,2019,0,,54.77910882117387 +R046,2012,0,,57.14665677594095 +R046,2013,0,,53.60427661315348 +R046,2014,0,,56.35282326995517 +R046,2015,0,,56.02043546061144 +R046,2016,0,,56.84122568575755 +R046,2017,0,,55.192763395179306 +R046,2018,0,,54.03954531839936 +R046,2019,0,,53.576461668653025 +R047,2012,0,,49.52024208916142 +R047,2013,0,,51.13908196294197 +R047,2014,0,,51.86113320555359 +R047,2015,0,,56.70745733551065 +R047,2016,0,,55.1862298567895 +R047,2017,0,,51.69615262852732 +R047,2018,0,,54.67493106405168 +R047,2019,0,,53.669105922067416 +R048,2012,0,,53.462872710546506 +R048,2013,0,,54.703673903137464 +R048,2014,0,,55.96244868428797 +R048,2015,0,,54.431068114369644 +R048,2016,0,,57.37853625392968 +R048,2017,0,,53.613048480411834 +R048,2018,0,,56.268782355360365 +R048,2019,0,,61.955199919445796 +R049,2012,0,,51.42108740396223 +R049,2013,0,,54.14277914960403 +R049,2014,0,,51.850474338768585 +R049,2015,0,,53.21446680488226 +R049,2016,0,,52.080594331760565 +R049,2017,0,,52.49943730245322 +R049,2018,0,,51.64046696926997 +R049,2019,0,,54.563782716254224 +R050,2012,0,,50.37955518634561 +R050,2013,0,,53.715222255024 +R050,2014,0,,54.945709637319446 +R050,2015,0,,53.89367707470258 +R050,2016,0,,52.72936489944003 +R050,2017,0,,54.85588526940448 +R050,2018,0,,53.689787297633444 +R050,2019,0,,56.681975895274874 +R051,2012,0,,50.13066153202968 +R051,2013,0,,53.42368118190264 +R051,2014,0,,50.50435577392241 +R051,2015,0,,51.88133673551649 +R051,2016,0,,57.740430961922016 +R051,2017,0,,57.44916821047324 +R051,2018,0,,54.57901707789947 +R051,2019,0,,53.51671886800556 +R052,2012,0,,37.422195796305594 +R052,2013,0,,42.56568467380363 +R052,2014,0,,48.884589560194186 +R052,2015,0,,45.29900671042717 +R052,2016,0,,43.45134170172418 +R052,2017,0,,46.146738437059454 +R052,2018,0,,42.45384061000914 +R052,2019,0,,45.48485882622106 +R053,2012,0,,49.2376314355122 +R053,2013,0,,49.16789740351862 +R053,2014,0,,51.31279479657137 +R053,2015,0,,50.80595163746381 +R053,2016,0,,51.59456132753098 +R053,2017,0,,49.527256471089146 +R053,2018,0,,53.058811150310945 +R053,2019,0,,55.024802464356135 +R054,2012,0,,46.34487026050043 +R054,2013,0,,46.81120020605413 +R054,2014,0,,51.6492440361542 +R054,2015,0,,48.98046602192411 +R054,2016,0,,52.31204488577063 +R054,2017,0,,49.265422422088825 +R054,2018,0,,53.419764296353804 +R054,2019,0,,51.276393013041584 +R055,2012,0,,47.95910097669591 +R055,2013,0,,50.87350159107032 +R055,2014,0,,47.411608438872655 +R055,2015,0,,46.63838383371158 +R055,2016,0,,47.76474771582807 +R055,2017,0,,50.21263577740673 +R055,2018,0,,46.53562902969861 +R055,2019,0,,51.44583721498019 +R056,2012,0,,48.18377867989832 +R056,2013,0,,51.85836885428093 +R056,2014,0,,45.16530676904335 +R056,2015,0,,48.766174460436446 +R056,2016,0,,47.10761781449987 +R056,2017,0,,49.474186928475845 +R056,2018,0,,51.98115696136082 +R056,2019,0,,51.63112014386173 +R057,2012,0,,57.60498398954318 +R057,2013,0,,58.09479160235909 +R057,2014,0,,59.21102167238958 +R057,2015,0,,57.17265173906397 +R057,2016,0,,60.74467121107158 +R057,2017,0,,61.302395614280115 +R057,2018,0,,61.10631884676054 +R057,2019,0,,61.953058092569165 +R058,2012,0,,46.900712994418335 +R058,2013,0,,50.67944581594315 +R058,2014,0,,46.826194714406476 +R058,2015,0,,46.77169525164284 +R058,2016,0,,46.02005463392052 +R058,2017,0,,46.10867619771528 +R058,2018,0,,46.04343726160799 +R058,2019,0,,47.25853444596661 +R059,2012,0,,55.33706358092049 +R059,2013,0,,56.44843681890006 +R059,2014,0,,56.27321452503944 +R059,2015,0,,55.02533848487619 +R059,2016,0,,58.49802627409883 +R059,2017,0,,58.82257423489647 +R059,2018,0,,57.38363157338973 +R059,2019,0,,56.90084413452264 +R060,2012,0,,43.3195395548478 +R060,2013,0,,42.90005310584041 +R060,2014,0,,40.01893525806859 +R060,2015,0,,43.16470789673037 +R060,2016,0,,43.96598813917123 +R060,2017,0,,42.28861868628264 +R060,2018,0,,44.826874342872216 +R060,2019,0,,42.041290822300056 +R061,2012,0,,51.634979621362696 +R061,2013,0,,51.75992960913722 +R061,2014,0,,49.15007881644291 +R061,2015,0,,50.76750079252748 +R061,2016,0,,45.36199629157287 +R061,2017,0,,48.51523664687763 +R061,2018,0,,50.599553425043226 +R061,2019,0,,49.54659345811207 +R062,2012,0,,52.87959086205357 +R062,2013,0,,55.74681317596824 +R062,2014,0,,49.79007452164413 +R062,2015,0,,50.85385578413285 +R062,2016,0,,53.14092743524638 +R062,2017,0,,56.53835473986144 +R062,2018,0,,51.23055311572158 +R062,2019,0,,54.67712222103357 +R063,2012,0,,57.21074192968983 +R063,2013,0,,50.56604814938508 +R063,2014,0,,51.698511878082435 +R063,2015,0,,53.79891571065292 +R063,2016,0,,53.74620020418706 +R063,2017,0,,57.058671349640846 +R063,2018,0,,56.27596751171442 +R063,2019,0,,52.74647218840679 +R064,2012,0,,55.22398627603312 +R064,2013,0,,53.339509913200196 +R064,2014,0,,57.43456596782446 +R064,2015,0,,54.015975092868636 +R064,2016,0,,54.1391677058613 +R064,2017,0,,57.1407532379105 +R064,2018,0,,57.9435222789145 +R064,2019,0,,57.81761627773134 +R065,2012,0,,51.23257411249724 +R065,2013,0,,55.981259844803354 +R065,2014,0,,53.22765870762854 +R065,2015,0,,56.15230566595436 +R065,2016,0,,52.975532678303004 +R065,2017,0,,57.361737588422926 +R065,2018,0,,55.21507478266124 +R065,2019,0,,54.76675152637324 +R066,2012,0,,50.32104748298984 +R066,2013,0,,49.67811847105854 +R066,2014,0,,48.357959670949725 +R066,2015,0,,52.87374960357815 +R066,2016,0,,49.23133516871982 +R066,2017,0,,50.82294710403685 +R066,2018,0,,51.655737949391366 +R066,2019,0,,50.382327421395246 +R067,2012,0,,49.26754619474425 +R067,2013,0,,47.559791131294936 +R067,2014,0,,48.19450302722245 +R067,2015,0,,52.12854358306205 +R067,2016,0,,47.463334304753786 +R067,2017,0,,45.36503749215535 +R067,2018,0,,53.771651441977305 +R067,2019,0,,56.15218159278582 +R068,2012,0,,54.116373446654336 +R068,2013,0,,51.35465811399263 +R068,2014,0,,54.99845008887159 +R068,2015,0,,55.432450773382634 +R068,2016,0,,55.76629709734275 +R068,2017,0,,54.565460439779244 +R068,2018,0,,58.31053810651508 +R068,2019,0,,58.7041788077526 +R069,2012,0,,46.691699707220536 +R069,2013,0,,51.38032662792892 +R069,2014,0,,54.478386974461415 +R069,2015,0,,51.10241630724425 +R069,2016,0,,50.225093424556405 +R069,2017,0,,51.26182906411405 +R069,2018,0,,53.1355283248054 +R069,2019,0,,48.947419815472145 +R070,2012,0,,44.587382360532224 +R070,2013,0,,43.779095273332 +R070,2014,0,,48.646757044200186 +R070,2015,0,,44.98824018247868 +R070,2016,0,,48.81360897865092 +R070,2017,0,,43.91644410508354 +R070,2018,0,,47.65762313219233 +R070,2019,0,,47.62565356178751 +R071,2012,0,,43.23250200552026 +R071,2013,0,,45.626810948203705 +R071,2014,0,,48.0881402386235 +R071,2015,0,,45.400997788187595 +R071,2016,0,,42.80590400474456 +R071,2017,0,,46.800795394364876 +R071,2018,0,,45.39025415567392 +R071,2019,0,,47.01416670354949 +R072,2012,0,,45.87659560188893 +R072,2013,0,,42.929888926908134 +R072,2014,0,,43.5009605263283 +R072,2015,0,,48.0818895078212 +R072,2016,0,,46.21356655075408 +R072,2017,0,,42.77560700368798 +R072,2018,0,,49.83396305848457 +R072,2019,0,,49.312762731435086 +R073,2012,0,,51.69508664346308 +R073,2013,0,,50.79059808709956 +R073,2014,0,,55.4869581478752 +R073,2015,0,,54.89952223216788 +R073,2016,0,,59.10727365873846 +R073,2017,0,,55.828538035055864 +R073,2018,0,,56.122746418069205 +R073,2019,0,,55.51723251094635 +R074,2012,0,,52.98271298602164 +R074,2013,0,,52.90075387179636 +R074,2014,0,,54.545117332460926 +R074,2015,0,,51.38450010985646 +R074,2016,0,,51.6975791075664 +R074,2017,0,,52.256279455866036 +R074,2018,0,,55.15526853798716 +R074,2019,0,,57.07416221184095 +R075,2012,0,,53.17177835530747 +R075,2013,0,,53.54133410369988 +R075,2014,0,,55.410119853056145 +R075,2015,0,,54.67592103709124 +R075,2016,0,,57.21279944972011 +R075,2017,0,,51.45122930643792 +R075,2018,0,,54.6605530917814 +R075,2019,0,,55.252878753310604 +R076,2012,0,,43.860057314812266 +R076,2013,0,,46.87491466852695 +R076,2014,0,,47.36296317160319 +R076,2015,0,,49.17654472605788 +R076,2016,0,,51.94593392473935 +R076,2017,0,,49.8759725832731 +R076,2018,0,,48.663645743051376 +R076,2019,0,,51.11511233678493 +R077,2012,0,,53.528078269533495 +R077,2013,0,,49.77923561210099 +R077,2014,0,,50.301087322518384 +R077,2015,0,,53.62481364071022 +R077,2016,0,,52.20589159702347 +R077,2017,0,,54.59002495413969 +R077,2018,0,,53.626941287069336 +R077,2019,0,,55.561310402457366 +R078,2012,0,,51.69482831302263 +R078,2013,0,,54.04223745720738 +R078,2014,0,,54.036110576088085 +R078,2015,0,,52.41118738840663 +R078,2016,0,,51.857261447200806 +R078,2017,0,,57.00180715772318 +R078,2018,0,,55.28758362067708 +R078,2019,0,,54.448676057436685 +R079,2012,0,,48.89794199881937 +R079,2013,0,,51.64775980504464 +R079,2014,0,,45.22133024563267 +R079,2015,0,,47.17500718837036 +R079,2016,0,,48.463759790709766 +R079,2017,0,,53.877982152176635 +R079,2018,0,,51.87997768251376 +R079,2019,0,,53.353186433215924 diff --git a/cases/_example_night_clinic/data/panel.source.json b/cases/_example_night_clinic/data/panel.source.json new file mode 100644 index 0000000..cdcf8be --- /dev/null +++ b/cases/_example_night_clinic/data/panel.source.json @@ -0,0 +1,17 @@ +{ + "name": "합성 패널 (simulate_panel)", + "provider": "core.adapters.synthetic", + "license": "synthetic", + "url": null, + "query": { + "n_units": 80, + "n_treated": 30, + "start": 2012, + "end": 2019, + "treat_time": 2016, + "effect": -5.0, + "seed": 42 + }, + "rows": 640, + "fetched_at": "2026-09-23T17:47:09+00:00" +} \ No newline at end of file diff --git a/cases/_example_night_clinic/estimate.py b/cases/_example_night_clinic/estimate.py new file mode 100644 index 0000000..d583bba --- /dev/null +++ b/cases/_example_night_clinic/estimate.py @@ -0,0 +1,78 @@ +"""plan.yaml → 추정 → 그림 → report.md + +실행: python cases/_example_night_clinic/estimate.py (먼저 fetch.py) +""" + +import json +import sys +from pathlib import Path + +import pandas as pd + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[1])) + +from core.estimators import apply_abstention, did # noqa: E402 +from core.pipeline import run_plan # noqa: E402 +from core.report import event_study_plot, raw_trends, render_report # noqa: E402 +from core.schema.plan import load_plan # noqa: E402 + + +def main(): + plan = load_plan(HERE / "plan.yaml") + df = pd.read_csv(HERE / "data" / "panel.csv") + y = plan.primary_outcome.col + + results = run_plan(plan, df) # event study (+ placebo, abstention) + es = results[0] + # 보조: 단일 계수 DiD 도 함께 보고 + kw = dict( + unit=plan.unit.id_col, + time=plan.time.col, + group_col=plan.treatment.group_col, + treat_time=plan.treatment.treat_time, + cluster=plan.estimator.cluster_col, + ) + results.append(apply_abstention(did(df, y, **kw), plan.abstention, plan.thresholds)) + + fig_dir = HERE / "figures" + fig_dir.mkdir(exist_ok=True) + raw_trends( + df, + y, + plan.time.col, + plan.treatment.group_col, + plan.treatment.treat_time, + fig_dir / "raw_trends.png", + ylabel="Night ED visits per 1,000 children (SYNTHETIC)", + ) + event_study_plot(es.extra["coefs"], fig_dir / "event_study.png") + + pt = es.assumptions_checked["parallel_pretrends"] + pl = es.assumptions_checked.get("placebo_time", {}) + checks = { + "평행추세": f"사전계수 결합 Wald p={pt['p_value']:.3f} → {'통과' if pt['passed'] else '기각'}", + "도입시점 외생성": ( + f"가짜 도입({pl['fake_treat_time']}) 추정 {pl['estimate']:.3f}, " + f"p={pl['p_value']:.3f} → {'통과' if pl['passed'] else '실패'}" + ) + if pl + else "미실시", + } + md = render_report( + plan, + results, + {"Raw trends": "figures/raw_trends.png", "Event study": "figures/event_study.png"}, + checks, + ) + (HERE / "report.md").write_text(md, encoding="utf-8") + (HERE / "results.json").write_text( + json.dumps([r.to_dict() for r in results], ensure_ascii=False, indent=2, default=float), + "utf-8", + ) + for r in results: + print(r.summary_ko()) + + +if __name__ == "__main__": + main() diff --git a/cases/_example_night_clinic/fetch.py b/cases/_example_night_clinic/fetch.py new file mode 100644 index 0000000..56e16bd --- /dev/null +++ b/cases/_example_night_clinic/fetch.py @@ -0,0 +1,45 @@ +"""데이터 수집 단계 — 이 예제는 합성 데이터를 생성한다 (⚠️ 실데이터 아님). + +실데이터로 바꿀 때는 아래 `fetch_real()` 처럼 core.adapters 의 어댑터를 쓰고, +plan.yaml 의 synthetic_data / data_sources 를 함께 수정하세요. + +실행: python cases/_example_night_clinic/fetch.py +""" + +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[1])) # 패키지 설치 전에도 실행되도록 + +from core.adapters import KosisAdapter, SyntheticAdapter # noqa: E402 + +TRUE_EFFECT = -5.0 + + +def fetch_synthetic(): + ad = SyntheticAdapter() + q = dict( + n_units=80, n_treated=30, start=2012, end=2019, treat_time=2016, effect=TRUE_EFFECT, seed=42 + ) + df = ad.fetch(**q).rename(columns={"y": "night_ed_rate"}) + return ad.save(df, HERE / "data" / "panel.csv", q) + + +def fetch_real(): # 참고용: KOSIS_API_KEY 필요, 네트워크 필요 + """예: 시군구별 0~14세 주민등록인구(분모). 통계표 ID 는 KOSIS 에서 확인 후 수정.""" + ad = KosisAdapter() + q = dict( + orgId="101", + tblId="DT_1B04005N", + itmId="T2", + objL1="ALL", + prdSe="Y", + startPrdDe="2012", + endPrdDe="2019", + ) + return ad.save(ad.fetch(**q), HERE / "data" / "raw_population.csv", q) + + +if __name__ == "__main__": + print("saved:", fetch_synthetic()) diff --git a/cases/_example_night_clinic/figures/event_study.png b/cases/_example_night_clinic/figures/event_study.png new file mode 100644 index 0000000..56a39d6 Binary files /dev/null and b/cases/_example_night_clinic/figures/event_study.png differ diff --git a/cases/_example_night_clinic/figures/raw_trends.png b/cases/_example_night_clinic/figures/raw_trends.png new file mode 100644 index 0000000..2e52676 Binary files /dev/null and b/cases/_example_night_clinic/figures/raw_trends.png differ diff --git a/cases/_example_night_clinic/plan.yaml b/cases/_example_night_clinic/plan.yaml new file mode 100644 index 0000000..acf8a09 --- /dev/null +++ b/cases/_example_night_clinic/plan.yaml @@ -0,0 +1,73 @@ +# ⚠️ 합성(SYNTHETIC) 데이터 예제 — 실제 정책 효과 아님. 파이프라인 시연/템플릿용. +case_id: _example_night_clinic +title: "[예제·합성데이터] 달빛어린이병원 지정이 소아 야간 경증 응급실 방문에 미친 효과" +question: > + 달빛어린이병원(야간·휴일 소아 경증 진료기관)이 지정된 시군구에서, + 지정되지 않은 시군구 대비 소아 야간 경증 응급실 방문율이 감소했는가? +synthetic_data: true + +unit: {name: 시군구, id_col: region_id} +time: {col: year, freq: year, start: 2012, end: 2019} # COVID-19(2020~) 교란을 피하려 2019 까지 + +treatment: + definition: "2016년에 달빛어린이병원이 1곳 이상 지정된 시군구 (예제 단순화: 동시 도입)" + group_col: treated + treat_time: 2016 + # 실데이터는 지정 시점이 제각각(시차 도입) → first_treat_col 사용 + staggered 경고가 뜬다 +control: + definition: "2019년까지 지정 이력이 없는 시군구" + rationale: "처치 지역과 도입 전 방문율 추세가 평행하다고 가정 (event study 사전계수로 점검)" + +outcomes: + - name: 소아 야간 경증 응급실 방문율 + col: night_ed_rate + definition: "0~14세 인구 1,000명당 18시~익일 09시 KTAS 4~5등급 응급실 방문 건수 (연간)" + unit: "건/천명" + primary: true + expected_direction: decrease + +estimator: + method: event_study + cluster_col: region_id + ref_period: -1 + window: [-4, 3] + alpha: 0.05 + +assumptions: + - name: 평행추세 + description: "정책이 없었다면 처치·통제 지역의 방문율 추세는 같았을 것" + check: pretrend_test + - name: 도입시점 외생성 + description: "지정 시점이 방문율 급증 등 결과변수 변화에 반응해 정해지지 않음" + check: placebo_time + - name: 파급효과 없음(SUTVA) + description: "인접 통제 시군구 주민이 처치 지역 병원을 이용해 통제군 방문율이 줄지 않음" + check: manual + +refutations: + - kind: placebo_time + params: {shift: 2} + +abstention: + - when: pretrend_rejected + verdict: not_identified + note: "사전추세가 다르면 DiD 추정치를 정책 효과로 보고하지 않는다" + - when: staggered_adoption + verdict: not_identified + note: "TWFE 대신 Callaway-Sant'Anna 로 재추정 전까지 보류" + - when: placebo_significant + verdict: conditional + - when: few_clusters + verdict: conditional + - when: ci_crosses_zero + verdict: conditional + note: "효과 없음이 아니라 '판별 불가'로 보고" + +thresholds: {pretrend_alpha: 0.10, min_clusters: 20, min_pre_periods: 3} + +data_sources: + - name: "합성 시군구 패널 (simulate_panel, seed=42, 참효과 -5.0)" + provider: core.adapters.synthetic + license: synthetic + adapter: synthetic + notes: "실데이터 후보는 data/README.md 참고" diff --git a/cases/_example_night_clinic/report.md b/cases/_example_night_clinic/report.md new file mode 100644 index 0000000..aba907e --- /dev/null +++ b/cases/_example_night_clinic/report.md @@ -0,0 +1,45 @@ +# [예제·합성데이터] 달빛어린이병원 지정이 소아 야간 경증 응급실 방문에 미친 효과 + +> **⚠️ 합성(synthetic) 데이터입니다. 아래 수치는 실제 정책 효과가 아니며, 파이프라인 시연용입니다.** + +**분석 질문**: 달빛어린이병원(야간·휴일 소아 경증 진료기관)이 지정된 시군구에서, 지정되지 않은 시군구 대비 소아 야간 경증 응급실 방문율이 감소했는가? + + +**종합 판정**: ✅ 식별됨 (가정 하에서) + +## 1. 설계 + +- 분석 단위: 시군구 (`region_id`), 기간 2012–2019 (year) +- 처치: 2016년에 달빛어린이병원이 1곳 이상 지정된 시군구 (예제 단순화: 동시 도입) +- 통제: 2019년까지 지정 이력이 없는 시군구 — 처치 지역과 도입 전 방문율 추세가 평행하다고 가정 (event study 사전계수로 점검) +- 주 결과변수: 소아 야간 경증 응급실 방문율 — 0~14세 인구 1,000명당 18시~익일 09시 KTAS 4~5등급 응급실 방문 건수 (연간) +- 추정 방법: `event_study` (cluster: `region_id`) + +## 2. 결과 + +| 방법 | 결과변수 | 추정치 | 95% CI | p | N | 클러스터 | 판정 | +|---|---|---:|---|---:|---:|---:|---| +| event_study | night_ed_rate | -5.033 | [-6.105, -3.960] | <0.001 | 640 | 80 | ✅ 식별됨 (가정 하에서) | +| did_twfe | night_ed_rate | -5.185 | [-5.956, -4.413] | <0.001 | 640 | 80 | ✅ 식별됨 (가정 하에서) | + +![Raw trends](figures/raw_trends.png) + +![Event study](figures/event_study.png) + +## 3. 가정 점검 및 반박 검정 + +| 가정 | 점검 방법 | 결과 | +|---|---|---| +| 평행추세 | pretrend_test | 사전계수 결합 Wald p=0.891 → 통과 | +| 도입시점 외생성 | placebo_time | 가짜 도입(2014) 추정 -0.325, p=0.525 → 통과 | +| 파급효과 없음(SUTVA) | manual | 수동 검토 필요 | + +## 4. 경고 및 보류 조건 + +- (자동 경고 없음 — 그래도 가정은 검증된 것이 아니라 '반박되지 않은' 것입니다) + +## 5. 데이터 출처 + +| 이름 | 제공 | 라이선스 | URL | +|---|---|---|---| +| 합성 시군구 패널 (simulate_panel, seed=42, 참효과 -5.0) | core.adapters.synthetic | synthetic | - | diff --git a/cases/_example_night_clinic/results.json b/cases/_example_night_clinic/results.json new file mode 100644 index 0000000..798130a --- /dev/null +++ b/cases/_example_night_clinic/results.json @@ -0,0 +1,113 @@ +[ + { + "method": "event_study", + "outcome": "night_ed_rate", + "estimate": -5.032702345732548, + "se": 0.5471286269132056, + "ci_low": -6.105054749393283, + "ci_high": -3.9603499420718125, + "p_value": 3.6335376481325645e-20, + "n_obs": 640, + "n_clusters": 80, + "assumptions_checked": { + "parallel_pretrends": { + "test": "joint Wald (chi2)", + "stat": 0.6252747974975548, + "df": 3, + "p_value": 0.8906226863229644, + "passed": true + }, + "pre_periods": 4, + "n_adoption_cohorts": 1, + "placebo_time": { + "fake_treat_time": 2014, + "estimate": -0.32469326228919176, + "p_value": 0.5245409054480956, + "passed": true + } + }, + "warnings": [], + "triggers": [], + "verdict": "identified", + "extra": { + "coefs": [ + { + "rel_time": -4, + "coef": 0.44969282466849825, + "se": 0.6116580595373967, + "ci_low": -0.7677820885266659, + "ci_high": 1.6671677378636622 + }, + { + "rel_time": -3, + "coef": 0.17895423334833896, + "se": 0.6467334612792702, + "ci_low": -1.1083365206178435, + "ci_high": 1.4662449873145214 + }, + { + "rel_time": -2, + "coef": -0.020739466561547057, + "se": 0.559734439210234, + "ci_low": -1.1348629987606007, + "ci_high": 1.0933840656375067 + }, + { + "rel_time": -1, + "coef": 0.0, + "se": 0.0, + "ci_low": 0.0, + "ci_high": 0.0 + }, + { + "rel_time": 0, + "coef": -4.438542547221294, + "se": 0.6841551135912444, + "ci_low": -5.800319236899004, + "ci_high": -3.0767658575435837 + }, + { + "rel_time": 1, + "coef": -5.4012082194069535, + "se": 0.6881833855255733, + "ci_low": -6.771002983803212, + "ci_high": -4.031413455010695 + }, + { + "rel_time": 2, + "coef": -5.031560056431842, + "se": 0.6884702945405902, + "ci_low": -6.401925898937359, + "ci_high": -3.6611942139263256 + }, + { + "rel_time": 3, + "coef": -5.259498559870104, + "se": 0.6475211640626454, + "ci_low": -6.548357197007054, + "ci_high": -3.9706399227331532 + } + ] + }, + "identified": true + }, + { + "method": "did_twfe", + "outcome": "night_ed_rate", + "estimate": -5.1846792435963724, + "se": 0.3875863716004593, + "ci_low": -5.95615061843084, + "ci_high": -4.413207868761905, + "p_value": 0.0, + "n_obs": 640, + "n_clusters": 80, + "assumptions_checked": { + "n_adoption_cohorts": 1 + }, + "warnings": [], + "triggers": [], + "verdict": "identified", + "extra": {}, + "identified": true + } +] \ No newline at end of file diff --git a/cases/_template/README.md b/cases/_template/README.md new file mode 100644 index 0000000..9008e36 --- /dev/null +++ b/cases/_template/README.md @@ -0,0 +1,29 @@ +# 케이스 템플릿 + +```bash +git switch -c group3/plan +cp -r cases/_template cases/group3-youth-rent # <조>-<주제>, 소문자-하이픈 +``` + +복사한 뒤 이 README는 조의 메모로 바꿔도 됩니다. + +## 4단계 + +| 단계 | 파일 | 할 일 | 완료 기준 | +|---|---|---|---| +| 1. 질문 정의 | `plan.yaml` | 질문·처치·대조·시점·지표·추정법·가정·반증·중단조건·데이터 라이선스 | **결과를 보기 전에** PR 병합 | +| 2. 수집 | `fetch.py` | 공공데이터 API/파일 → `data/raw/` → 정제 패널 `data/processed/panel.csv` | 키만 있으면 누구나 재실행 가능 | +| 3. 추정 | `estimate.py` | `core.estimators`로 효과 추정 + `refutations` 실행 → `figures/*.png` | 그림·수치 재현 가능, `abstention` 판정 | +| 4. 리포트 | `report.md` | 결과·한계·시사점 | `make app`에서 카드로 보임 | + +```bash +python cases/group3-youth-rent/fetch.py +python cases/group3-youth-rent/estimate.py +make app +``` + +## 규칙 + +- `data/raw/`는 git에 올라가지 않습니다 (`.gitignore`). 재배포 가능한 **작은 정제 데이터만** `data/processed/`에 커밋하세요 (라이선스 확인, 수 MB 이하). +- API 키는 `.env`에만. 코드에 직접 쓰지 마세요. +- `plan.yaml`을 결과 확인 후 바꿨다면 커밋 메시지와 `report.md`에 이유를 남기세요. diff --git a/cases/_template/estimate.py b/cases/_template/estimate.py new file mode 100644 index 0000000..964f620 --- /dev/null +++ b/cases/_template/estimate.py @@ -0,0 +1,44 @@ +"""Step 3 - estimate the effect defined in plan.yaml. + +Run: python cases//estimate.py +Reads data/processed/panel.csv, writes figures/*.png and prints the estimate. +""" + +from __future__ import annotations + +from pathlib import Path + +CASE_DIR = Path(__file__).resolve().parent +PANEL = CASE_DIR / "data" / "processed" / "panel.csv" +FIG_DIR = CASE_DIR / "figures" + +try: # keep the template importable even if core is not installed yet + from core.pipeline import run_plan + from core.report import render_report # noqa: F401 (see _example_night_clinic) + from core.schema.plan import load_plan +except ImportError: # pragma: no cover + run_plan = None + load_plan = None + + +def main() -> None: + if load_plan is None or run_plan is None: + raise SystemExit("core is not installed. Run `make install` at the repo root.") + if not PANEL.exists(): + raise SystemExit(f"{PANEL} not found. Run fetch.py first.") + + import pandas as pd + + plan = load_plan(CASE_DIR / "plan.yaml") + panel = pd.read_csv(PANEL) + FIG_DIR.mkdir(exist_ok=True) + + # run_plan: estimator in plan.estimator.method -> refutations -> abstention verdict + results = run_plan(plan, panel) + for r in results: + print(r) + # TODO: figures + report.md — copy the pattern in cases/_example_night_clinic/estimate.py + + +if __name__ == "__main__": + main() diff --git a/cases/_template/fetch.py b/cases/_template/fetch.py new file mode 100644 index 0000000..542c50a --- /dev/null +++ b/cases/_template/fetch.py @@ -0,0 +1,52 @@ +"""Step 2 - fetch public data for this case. + +Run: python cases//fetch.py +Writes raw files to data/raw/ (git-ignored) and a tidy panel to data/processed/. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +CASE_DIR = Path(__file__).resolve().parent +RAW_DIR = CASE_DIR / "data" / "raw" +PROCESSED_DIR = CASE_DIR / "data" / "processed" + +try: # core is evolving; keep the template importable even if its API changes. + from core.schema.plan import load_plan +except ImportError: # pragma: no cover + load_plan = None + + +def read_plan() -> dict: + """Load plan.yaml via core if available, else as plain YAML.""" + path = CASE_DIR / "plan.yaml" + if load_plan is not None: + return load_plan(path) + import yaml + + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def main() -> None: + plan = read_plan() + RAW_DIR.mkdir(parents=True, exist_ok=True) + PROCESSED_DIR.mkdir(parents=True, exist_ok=True) + + sources = plan.get("data_sources", []) if isinstance(plan, dict) else plan.data_sources + for src in sources: + name = src["name"] if isinstance(src, dict) else src.name + key_env = ( + src.get("api_key_env") if isinstance(src, dict) else getattr(src, "api_key_env", None) + ) + if key_env and not os.getenv(key_env): + raise SystemExit(f"{key_env} is not set. Copy .env.example to .env and fill it in.") + # TODO: call a core adapter (e.g. core.adapters.kosis) and save to RAW_DIR + print(f"[fetch] TODO: {name}") + + # TODO: clean raw -> tidy panel (one row per `unit`), save PROCESSED_DIR / "panel.csv" + + +if __name__ == "__main__": + main() diff --git a/cases/_template/plan.yaml b/cases/_template/plan.yaml new file mode 100644 index 0000000..866bffe --- /dev/null +++ b/cases/_template/plan.yaml @@ -0,0 +1,75 @@ +# 분석 계획서 (사전 등록) — 데이터를 열어보기 "전에" 작성하고 먼저 PR 하세요. +# 스키마: core/schema/plan.py (정의되지 않은 필드는 검증 오류가 납니다) +# 작성 요령: docs/strategy/plan-guide.md | 완성 예시: cases/_example_night_clinic/plan.yaml + +case_id: groupN-topic # 폴더명과 동일 (소문자-하이픈) +title: "정책 효과 분석 제목" +question: > + X 정책이 도입된 지역/집단에서, 도입되지 않은 곳 대비 Y가 변했는가? +synthetic_data: false # 합성 데이터면 true (라이선스도 synthetic) + +# 분석 단위와 시간축: 한 행 = 단위 x 시점 +unit: {name: 시군구, id_col: region_id} +time: {col: year, freq: year, start: 2015, end: 2019} # freq: year|quarter|month|week|day + +# 처치 / 대조 +treatment: + definition: "정책 X가 T년에 시행된 지역" + group_col: treated # 처치집단 여부(0/1) 컬럼 + treat_time: 2017 # 동시 도입일 때 + # first_treat_col: first_treat # 시차 도입이면 treat_time 대신 사용 +control: + definition: "분석 기간 중 정책 X가 시행되지 않은 지역" + rationale: "왜 이 집단이 '정책이 없었다면'의 대리가 되는가" + +# 결과 지표 (primary 1개) +outcomes: + - name: 주요 결과 지표 + col: outcome + definition: "분자/분모/기간까지 조작적으로 정의" + unit: "건/천명" + primary: true + expected_direction: unknown # increase | decrease | unknown + +# 추정 방법: did | event_study | its +estimator: + method: event_study + cluster_col: region_id + ref_period: -1 + window: [-4, 3] + alpha: 0.05 + +# 식별 가정과 점검 방법 (check: pretrend_test | placebo_time | manual | none) +assumptions: + - name: 평행추세 + description: "정책이 없었다면 두 집단의 추세는 같았을 것" + check: pretrend_test + - name: 동시 정책 없음 + description: "같은 시점에 결과에 영향을 준 다른 정책이 없음" + check: manual + +# 반박 검정 (kind: placebo_time | placebo_outcome | drop_unit) +refutations: + - kind: placebo_time + params: {shift: 2} + +# 결론 보류 규칙 — 해당하면 효과를 '주장하지 않는다' +# when: pretrend_rejected | few_clusters | staggered_adoption | placebo_significant | ci_crosses_zero | short_pre_period +abstention: + - when: pretrend_rejected + verdict: not_identified + - when: staggered_adoption + verdict: not_identified + - when: ci_crosses_zero + verdict: conditional + note: "효과 없음이 아니라 '판별 불가'로 보고" + +thresholds: {pretrend_alpha: 0.10, min_clusters: 20, min_pre_periods: 3} + +# 데이터 출처 — license 필수 (KOGL-1~4 | CC-BY-4.0 | CC0 | synthetic | other) +data_sources: + - name: "데이터셋 이름" + provider: "기관명 (예: 통계청 KOSIS)" + url: "https://..." + license: KOGL-1 + adapter: kosis # core.adapters 의 어댑터 이름 (없으면 삭제) diff --git a/cases/_template/report.md b/cases/_template/report.md new file mode 100644 index 0000000..f72bd17 --- /dev/null +++ b/cases/_template/report.md @@ -0,0 +1,22 @@ +# (케이스 제목) + +> 상태: 계획 단계 · 조: groupN · 최종 수정: YYYY-MM-DD + +## 질문 +plan.yaml의 question을 옮겨 적습니다. + +## 데이터 +| 데이터 | 기관 | 기간·단위 | 라이선스 | +|---|---|---|---| +| | | | | + +## 방법 +추정 방법과 핵심 가정. + +## 결과 +![효과](figures/effect.png) + +## 강건성·반증 +placebo, 민감도 결과. `abstain_if` 해당 여부. + +## 한계와 정책 시사점 diff --git a/core/REQUIREMENTS.md b/core/REQUIREMENTS.md new file mode 100644 index 0000000..1670b89 --- /dev/null +++ b/core/REQUIREMENTS.md @@ -0,0 +1,41 @@ +# core/ 의존성 (Data 담당 → pyproject 병합용) + +Python 3.11.15 에서 설치·테스트한 버전입니다. pyproject 에는 `>=` 하한으로 넣고, lock 은 uv 로 관리 권장. + +| 패키지 | 검증 버전 | 용도 | +|---|---|---| +| pydantic | 2.13.3 | plan.yaml 스키마 | +| PyYAML | 6.0.3 | plan.yaml 로드 | +| pandas | 3.0.2 | 데이터 처리 | +| numpy | 2.4.4 | | +| scipy | 1.17.1 | Wald/정규 검정 | +| pyfixest | 0.60.0 | DiD/TWFE/event study, 군집-강건 SE | +| statsmodels | 0.15.0 | ITS (Newey-West HAC) | +| matplotlib | 3.10.9 | 그림 | +| requests | 2.33.1 | KOSIS 어댑터 | +| pytest (dev) | 9.1.1 | tests/core | + +```toml +dependencies = [ + "pydantic>=2.7", "pyyaml>=6", "pandas>=2.2", "numpy>=1.26", "scipy>=1.11", + "pyfixest>=0.60", "statsmodels>=0.14", "matplotlib>=3.8", "requests>=2.31", +] +[project.optional-dependencies] +dev = ["pytest>=8"] +``` + +- **현재 root pyproject 와의 차이 (병합 요청)**: ① `scipy` 누락 → 추가 필요, ② `pyfixest>=0.25` → `>=0.60` 권장 + (event study 결합검정에서 `fit._vcov` 사용, 0.60 에서만 검증). `pandas<3` 핀은 OK — pandas 2.3.3 에서도 16개 테스트 통과. +- pytest 는 `tests/core/conftest.py` 가 repo 루트를 `sys.path` 에 넣으므로 설치 없이 동작합니다. + pyproject 에 `[tool.pytest.ini_options] pythonpath = ["."]` 를 넣어도 됩니다. +- 선택: `dowhy`(반박검정 확장), Callaway–Sant'Anna 용 `csdid`/`differences` — 아직 미도입. + +## 모듈 지도 +``` +core/schema/plan.py plan.yaml 스키마 (Plan, load_plan) +core/adapters/ BaseAdapter · KosisAdapter · LocalFileAdapter · SyntheticAdapter +core/estimators/ did · event_study · placebo_time · its → EffectResult, apply_abstention +core/pipeline.py run_plan(plan, df): 추정 → 반박검정 → 보류판정 +core/report/ raw_trends · event_study_plot · its_plot · render_report(markdown) +core/agent/ validate_plan · run_estimate (LangGraph 래핑용 얇은 인터페이스) +``` diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..083e2b6 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,5 @@ +"""policy-effect-analytics-agent 공용 코어. + +schema(plan.yaml) → adapters(수집) → estimators(추정·진단) → report(리포트) +pipeline.run_plan 이 plan 하나로 전체 추정 단계를 실행한다. +""" diff --git a/core/adapters/__init__.py b/core/adapters/__init__.py new file mode 100644 index 0000000..09dddc0 --- /dev/null +++ b/core/adapters/__init__.py @@ -0,0 +1,19 @@ +"""공공데이터 어댑터. 새 소스는 BaseAdapter 를 상속해 fetch() 만 구현하면 된다.""" + +from .base import BaseAdapter, MissingAPIKey, SourceMeta +from .kosis import KosisAdapter +from .local import LocalFileAdapter +from .synthetic import SyntheticAdapter, simulate_panel + +REGISTRY = {"kosis": KosisAdapter, "local": LocalFileAdapter, "synthetic": SyntheticAdapter} + +__all__ = [ + "BaseAdapter", + "SourceMeta", + "MissingAPIKey", + "KosisAdapter", + "LocalFileAdapter", + "SyntheticAdapter", + "simulate_panel", + "REGISTRY", +] diff --git a/core/adapters/base.py b/core/adapters/base.py new file mode 100644 index 0000000..559b3cf --- /dev/null +++ b/core/adapters/base.py @@ -0,0 +1,61 @@ +"""공공데이터 어댑터 기본 클래스. + +모든 어댑터는 `fetch(**query) -> pandas.DataFrame` 을 구현하고, 출처·라이선스를 +`meta` 로 노출한다. 결과는 `save()` 로 case 의 data/ 아래에 CSV + 출처 JSON 으로 저장해 +재현성을 확보한다 (원본 API 응답이 바뀌어도 분석은 스냅샷으로 재현). +""" + +from __future__ import annotations + +import json +import os +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path + +import pandas as pd + + +@dataclass +class SourceMeta: + name: str + provider: str + license: str # plan.py 의 License 값 (KOGL-1 등) + url: str | None = None + + +class MissingAPIKey(RuntimeError): + pass + + +class BaseAdapter(ABC): + meta: SourceMeta + api_key_env: str | None = None # 필요한 환경변수 이름 (예: KOSIS_API_KEY) + + def api_key(self) -> str: + key = os.getenv(self.api_key_env or "", "") + if not key: + raise MissingAPIKey( + f"환경변수 {self.api_key_env} 가 필요합니다 (.env 에 설정, 커밋 금지)." + ) + return key + + @abstractmethod + def fetch(self, **query) -> pd.DataFrame: + """원천 데이터를 tidy DataFrame 으로 반환.""" + + def save(self, df: pd.DataFrame, path: str | Path, query: dict | None = None) -> Path: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(path, index=False, encoding="utf-8") + prov = { + **asdict(self.meta), + "query": query or {}, + "rows": len(df), + "fetched_at": datetime.now(UTC).isoformat(timespec="seconds"), + } + path.with_suffix(".source.json").write_text( + json.dumps(prov, ensure_ascii=False, indent=2), "utf-8" + ) + return path diff --git a/core/adapters/kosis.py b/core/adapters/kosis.py new file mode 100644 index 0000000..2e78f8b --- /dev/null +++ b/core/adapters/kosis.py @@ -0,0 +1,77 @@ +"""KOSIS(국가통계포털) OpenAPI 어댑터. + +문서: https://kosis.kr/openapi/ (통계자료 > 통계표 선택 방식 `statisticsParameterData.do`) +필요: 환경변수 KOSIS_API_KEY (KOSIS 공유서비스에서 무료 발급) +라이선스: KOSIS 제공 통계는 대부분 공공누리 제1유형 — 통계표별로 반드시 확인하세요. + +예: + KosisAdapter().fetch(orgId="101", tblId="DT_1B040A3", itmId="T20", + objL1="ALL", prdSe="Y", startPrdDe="2015", endPrdDe="2023") +""" + +from __future__ import annotations + +import pandas as pd +import requests + +from .base import BaseAdapter, SourceMeta + +URL = "https://kosis.kr/openapi/Param/statisticsParameterData.do" + + +class KosisAdapter(BaseAdapter): + api_key_env = "KOSIS_API_KEY" + meta = SourceMeta(name="KOSIS 통계표", provider="통계청 KOSIS", license="KOGL-1", url=URL) + + def fetch( + self, + *, + orgId: str, + tblId: str, + itmId: str = "ALL", + objL1: str = "ALL", + prdSe: str = "Y", + startPrdDe: str | None = None, + endPrdDe: str | None = None, + timeout: int = 30, + **extra, + ) -> pd.DataFrame: + params = { + "method": "getList", + "apiKey": self.api_key(), + "format": "json", + "jsonVD": "Y", + "orgId": orgId, + "tblId": tblId, + "itmId": itmId, + "objL1": objL1, + "prdSe": prdSe, + "startPrdDe": startPrdDe, + "endPrdDe": endPrdDe, + **extra, + } + r = requests.get( + URL, params={k: v for k, v in params.items() if v is not None}, timeout=timeout + ) + r.raise_for_status() + data = r.json() + if ( + isinstance(data, dict) and "err" in data + ): # KOSIS 는 오류도 200 + {"err": .., "errMsg": ..} + raise RuntimeError(f"KOSIS 오류 {data.get('err')}: {data.get('errMsg')}") + return self.tidy(pd.DataFrame(data)) + + @staticmethod + def tidy(raw: pd.DataFrame) -> pd.DataFrame: + """KOSIS 응답 → (region_code, region, item, period, value) 롱포맷.""" + cols = { + "C1": "region_code", + "C1_NM": "region", + "ITM_NM": "item", + "PRD_DE": "period", + "DT": "value", + } + out = raw.rename(columns=cols) + out = out[[c for c in cols.values() if c in out.columns]].copy() + out["value"] = pd.to_numeric(out["value"], errors="coerce") # '-', 'X'(비밀보호) → NaN + return out diff --git a/core/adapters/local.py b/core/adapters/local.py new file mode 100644 index 0000000..3dbe6c8 --- /dev/null +++ b/core/adapters/local.py @@ -0,0 +1,28 @@ +"""로컬/다운로드 파일 어댑터 (CSV·XLSX). + +공공데이터포털의 '파일데이터'는 대부분 API 없이 CSV 로 내려받는다. 이 어댑터로 읽으면 +한글 인코딩(cp949/euc-kr) 자동 판별 + 출처 메타 기록을 통일할 수 있다. +""" + +from __future__ import annotations + +from pathlib import Path + +import pandas as pd + +from .base import BaseAdapter, SourceMeta + + +class LocalFileAdapter(BaseAdapter): + def __init__(self, path: str | Path, meta: SourceMeta): + self.path, self.meta = Path(path), meta + + def fetch(self, **read_kwargs) -> pd.DataFrame: + if self.path.suffix.lower() in {".xlsx", ".xls"}: + return pd.read_excel(self.path, **read_kwargs) + for enc in ("utf-8-sig", "cp949"): # 공공데이터 CSV 는 cp949 가 흔함 + try: + return pd.read_csv(self.path, encoding=enc, **read_kwargs) + except UnicodeDecodeError: + continue + raise UnicodeDecodeError("utf-8/cp949", b"", 0, 1, f"인코딩 판별 실패: {self.path}") diff --git a/core/adapters/synthetic.py b/core/adapters/synthetic.py new file mode 100644 index 0000000..8b6d144 --- /dev/null +++ b/core/adapters/synthetic.py @@ -0,0 +1,59 @@ +"""합성 패널 생성기 — 테스트·예제·교육용. 실데이터 아님! + +참값(true effect)을 알고 있는 데이터로 추정기가 제대로 복원하는지, 경고가 제대로 +뜨는지 검증한다. `pretrend_slope` 를 주면 처치집단에만 사전 추세 차이를 심어 +평행추세 위반 상황을 재현할 수 있다. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from .base import BaseAdapter, SourceMeta + + +def simulate_panel( + n_units: int = 80, + n_treated: int = 30, + start: int = 2012, + end: int = 2019, + treat_time: int = 2016, + effect: float = -5.0, + pretrend_slope: float = 0.0, + staggered: bool = False, + base: float = 50.0, + noise: float = 2.0, + seed: int = 0, +) -> pd.DataFrame: + rng = np.random.default_rng(seed) + years = np.arange(start, end + 1) + units = np.arange(n_units) + treated = units < n_treated + ft = np.where(treated, treat_time, np.nan) + if staggered: # 처치집단을 3개 코호트로 분할 + ft = np.where(treated, treat_time + (units % 3) - 1, np.nan) + alpha = rng.normal(base, 5, n_units) # 단위 고정효과 + gamma = np.cumsum(rng.normal(0.5, 0.3, len(years))) # 공통 연도 충격 + rows = [] + for i in units: + for j, t in enumerate(years): + d = int(treated[i] and t >= ft[i]) + y = ( + alpha[i] + + gamma[j] + + effect * d + + (pretrend_slope * (t - treat_time) if treated[i] else 0.0) + + rng.normal(0, noise) + ) + rows.append((f"R{i:03d}", t, int(treated[i]), ft[i], y)) + return pd.DataFrame(rows, columns=["region_id", "year", "treated", "first_treat", "y"]) + + +class SyntheticAdapter(BaseAdapter): + meta = SourceMeta( + name="합성 패널 (simulate_panel)", provider="core.adapters.synthetic", license="synthetic" + ) + + def fetch(self, **kw) -> pd.DataFrame: + return simulate_panel(**kw) diff --git a/core/agent/__init__.py b/core/agent/__init__.py new file mode 100644 index 0000000..d55d427 --- /dev/null +++ b/core/agent/__init__.py @@ -0,0 +1,31 @@ +"""에이전트 툴 인터페이스 (스텁). W4~5 에 Develop 이 LangGraph tool 로 래핑. + +규약: 각 툴은 JSON 직렬화 가능한 입력/출력만 사용한다. + - validate_plan(path) -> {"ok": bool, "errors": [...]} + - run_estimate(plan_path, data_path) -> EffectResult.to_dict() +LLM 은 plan.yaml 작성까지만 하고, 수치 계산은 반드시 core.estimators 를 호출한다. +""" + +from __future__ import annotations + +import pandas as pd +from pydantic import ValidationError + +from ..schema.plan import load_plan + + +def validate_plan(path: str) -> dict: + try: + load_plan(path) + return {"ok": True, "errors": []} + except ValidationError as e: + return { + "ok": False, + "errors": [f"{'.'.join(map(str, x['loc']))}: {x['msg']}" for x in e.errors()], + } + + +def run_estimate(plan_path: str, data_path: str) -> list[dict]: + from ..pipeline import run_plan # 지연 import + + return [r.to_dict() for r in run_plan(load_plan(plan_path), pd.read_csv(data_path))] diff --git a/core/estimators/__init__.py b/core/estimators/__init__.py new file mode 100644 index 0000000..88f4463 --- /dev/null +++ b/core/estimators/__init__.py @@ -0,0 +1,8 @@ +"""효과 추정 모듈. 모든 추정기는 EffectResult 를 반환한다.""" + +from .diagnostics import apply_abstention +from .did import did, event_study, placebo_time +from .its import its +from .result import EffectResult + +__all__ = ["did", "event_study", "placebo_time", "its", "apply_abstention", "EffectResult"] diff --git a/core/estimators/diagnostics.py b/core/estimators/diagnostics.py new file mode 100644 index 0000000..8afa387 --- /dev/null +++ b/core/estimators/diagnostics.py @@ -0,0 +1,68 @@ +"""자동 경고 레이어 + 보류(abstention) 판정. + +추정치는 항상 '조건부 주장'이다. 여기서는 기계적으로 잡을 수 있는 위험 신호를 +EffectResult.warnings / triggers 에 기록하고, plan.yaml 의 abstention 규칙에 따라 +verdict(identified / conditional / not_identified)를 정한다. +""" + +from __future__ import annotations + +import pandas as pd + +from .result import EffectResult + +_ORDER = {"identified": 0, "conditional": 1, "not_identified": 2} + + +def check_clusters(res: EffectResult, n_clusters: int, min_clusters: int = 20) -> None: + if n_clusters < min_clusters: + res.warn( + f"클러스터 수 {n_clusters}개 < {min_clusters}: 군집-강건 표준오차가 과소추정될 수 " + "있습니다. wild cluster bootstrap(pyfixest `wildboottest`) 또는 randomization " + "inference 를 병행하세요.", + "few_clusters", + ) + + +def check_staggered(res: EffectResult, first_treat_by_unit: pd.Series) -> None: + timings = first_treat_by_unit.dropna().unique() + res.assumptions_checked["n_adoption_cohorts"] = int(len(timings)) + if len(timings) > 1: + res.warn( + f"도입 시점이 {len(timings)}개(시차 도입)입니다. TWFE 는 이질적 효과 하에서 편향될 수 " + "있습니다(Goodman-Bacon 2021). Callaway & Sant'Anna(2021) 또는 pyfixest `did2s`/" + "`lpdid` 등을 사용하세요.", + "staggered_adoption", + ) + + +def apply_abstention(res: EffectResult, rules, thresholds=None) -> EffectResult: + """plan.abstention 규칙을 적용해 verdict 를 결정 (가장 보수적인 판정이 이긴다). + + rules: list[AbstentionRule] (또는 동일 필드를 가진 dict) + """ + min_pre = getattr(thresholds, "min_pre_periods", 3) + pre = res.assumptions_checked.get("pre_periods") + if pre is not None and pre < min_pre: + res.warn(f"사전 기간 {pre}기 < {min_pre}: 추세 비교 근거가 부족합니다.", "short_pre_period") + if res.ci_low <= 0 <= res.ci_high: + res.warn( + "신뢰구간이 0을 포함합니다. '효과 없음'이 아니라 '효과 크기를 판별할 수 없음'으로 " + "해석하세요.", + "ci_crosses_zero", + ) + placebo = res.assumptions_checked.get("placebo_time") + if placebo and not placebo["passed"]: + res.warn( + f"가짜 도입시점 검정이 유의(p={placebo['p_value']:.3f}): 도입 전부터 차이가 " + "벌어졌을 가능성.", + "placebo_significant", + ) + + verdict = "identified" + for r in rules: + r = r if isinstance(r, dict) else r.model_dump() + if r["when"] in res.triggers and _ORDER[r["verdict"]] > _ORDER[verdict]: + verdict = r["verdict"] + res.verdict = verdict + return res diff --git a/core/estimators/did.py b/core/estimators/did.py new file mode 100644 index 0000000..3e2c110 --- /dev/null +++ b/core/estimators/did.py @@ -0,0 +1,201 @@ +"""이중차분(DiD) 계열 추정기 — pyfixest 기반. + +- did(): TWFE DiD y ~ D | unit + time (2x2 및 동시 도입 다기간 모두 커버) +- event_study(): 상대시점 더미 기반 동적효과 + 사전추세 결합 Wald 검정 +- placebo_time(): 사전 구간만 잘라 가짜 도입시점으로 재추정 (반박 검정) + +주의: 도입 시점이 단위마다 다른 '시차 도입(staggered)'에서 TWFE 는 이미 처치된 +단위를 통제군으로 쓰면서 편향될 수 있다 (Goodman-Bacon 2021). 이 경우 경고를 띄우며, +Callaway & Sant'Anna(2021) 등 이질효과-강건 추정을 권장한다. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pyfixest as pf +from scipy import stats + +from .diagnostics import check_clusters, check_staggered +from .result import EffectResult + + +def _first_treat(df, unit, time, group_col, first_treat_col, treat_time) -> pd.Series: + """단위별 최초 처치 시점 (미처치 = NaN).""" + if first_treat_col: + return df[first_treat_col].astype(float) + return np.where(df[group_col] == 1, float(treat_time), np.nan) + + +def _fit(df, fml, cluster): + vcov = {"CRV1": cluster} if cluster else "hetero" + return pf.feols(fml, data=df, vcov=vcov) + + +def did( + df: pd.DataFrame, + y: str, + unit: str, + time: str, + group_col: str, + treat_time=None, + first_treat_col: str | None = None, + cluster: str | None = None, + covariates: list[str] = (), + alpha: float = 0.05, + min_clusters: int = 20, +) -> EffectResult: + """TWFE DiD. cluster 기본값은 unit (정책이 단위 수준에서 배정되므로).""" + cluster = cluster or unit + d = df.copy() + d["_ft"] = _first_treat(d, unit, time, group_col, first_treat_col, treat_time) + d["_D"] = ((d[time] >= d["_ft"]) & d["_ft"].notna()).astype(int) + rhs = " + ".join(["_D", *covariates]) + fit = _fit(d, f"{y} ~ {rhs} | {unit} + {time}", cluster) + ci = fit.confint(alpha=alpha).loc["_D"] + n_cl = d[cluster].nunique() + res = EffectResult( + method="did_twfe", + outcome=y, + estimate=float(fit.coef()["_D"]), + se=float(fit.se()["_D"]), + ci_low=float(ci.iloc[0]), + ci_high=float(ci.iloc[1]), + p_value=float(fit.pvalue()["_D"]), + n_obs=int(fit._N), + n_clusters=int(n_cl), + ) + check_clusters(res, n_cl, min_clusters) + check_staggered(res, d.groupby(unit)["_ft"].first()) + return res + + +def event_study( + df: pd.DataFrame, + y: str, + unit: str, + time: str, + group_col: str, + treat_time=None, + first_treat_col: str | None = None, + cluster: str | None = None, + ref_period: int = -1, + window: tuple[int, int] = (-4, 4), + pretrend_alpha: float = 0.10, + alpha: float = 0.05, + min_clusters: int = 20, +) -> EffectResult: + """상대시점 더미 TWFE. 창 밖 상대시점은 양끝으로 binning. + + 반환 EffectResult.estimate = 사후 계수들의 평균(단순평균), + extra['coefs'] = 상대시점별 계수표, assumptions_checked['parallel_pretrends'] = 결합검정. + """ + cluster = cluster or unit + lo, hi = window + d = df.copy() + d["_ft"] = _first_treat(d, unit, time, group_col, first_treat_col, treat_time) + rel = (d[time] - d["_ft"]).clip(lo, hi) + names = {} + for k in range(lo, hi + 1): + if k == ref_period: + continue + nm = f"ev_m{-k}" if k < 0 else f"ev_p{k}" + d[nm] = ((rel == k) & d["_ft"].notna()).astype(int) + names[nm] = k + names = {n: k for n, k in names.items() if d[n].any()} # 관측 없는 상대시점 제거 + fit = _fit(d, f"{y} ~ {' + '.join(names)} | {unit} + {time}", cluster) + coef, se, ci = fit.coef(), fit.se(), fit.confint(alpha=alpha) + tab = pd.DataFrame( + { + "rel_time": list(names.values()), + "coef": coef[list(names)].values, + "se": se[list(names)].values, + "ci_low": ci.loc[list(names)].iloc[:, 0].values, + "ci_high": ci.loc[list(names)].iloc[:, 1].values, + } + ) + tab = pd.concat( + [ + tab, + pd.DataFrame( + [{"rel_time": ref_period, "coef": 0.0, "se": 0.0, "ci_low": 0.0, "ci_high": 0.0}] + ), + ] + ).sort_values("rel_time") + + # 사전추세 결합 Wald 검정 (H0: 모든 사전 계수 = 0). 가장 먼 bin 은 포함. + pre = [n for n, k in names.items() if k < 0] + post = [n for n, k in names.items() if k >= 0] + idx = [list(coef.index).index(n) for n in pre] + V = fit._vcov[np.ix_(idx, idx)] + b = coef[pre].values + wald = float(b @ np.linalg.pinv(V) @ b) + p_pre = float(stats.chi2.sf(wald, df=len(pre))) + + # 사후 평균효과와 그 SE (선형결합) + pidx = [list(coef.index).index(n) for n in post] + w = np.full(len(post), 1 / len(post)) + est = float(w @ coef[post].values) + se_avg = float(np.sqrt(w @ fit._vcov[np.ix_(pidx, pidx)] @ w)) + z = stats.norm.ppf(1 - alpha / 2) + n_cl = d[cluster].nunique() + res = EffectResult( + method="event_study", + outcome=y, + estimate=est, + se=se_avg, + ci_low=est - z * se_avg, + ci_high=est + z * se_avg, + p_value=float(2 * stats.norm.sf(abs(est / se_avg))), + n_obs=int(fit._N), + n_clusters=int(n_cl), + extra={"coefs": tab}, + ) + res.assumptions_checked["parallel_pretrends"] = { + "test": "joint Wald (chi2)", + "stat": wald, + "df": len(pre), + "p_value": p_pre, + "passed": p_pre >= pretrend_alpha, + } + if p_pre < pretrend_alpha: + res.warn( + f"사전추세 결합검정 p={p_pre:.3f} < {pretrend_alpha}: 평행추세 가정이 의심됩니다. " + "효과를 인과적으로 해석하지 마세요.", + "pretrend_rejected", + ) + n_pre = int( + d.loc[d["_ft"].notna(), time] + .lt(d.loc[d["_ft"].notna(), "_ft"]) + .groupby(d.loc[d["_ft"].notna(), unit]) + .sum() + .min() + ) + res.assumptions_checked["pre_periods"] = n_pre + check_clusters(res, n_cl, min_clusters) + check_staggered(res, d.groupby(unit)["_ft"].first()) + return res + + +def placebo_time( + df: pd.DataFrame, + y: str, + unit: str, + time: str, + group_col: str, + treat_time, + shift: int = 2, + cluster: str | None = None, + alpha: float = 0.10, +) -> dict: + """실제 도입 이전 구간만 사용해 도입시점을 shift 만큼 앞당긴 가짜 DiD. + 유의하면(p= alpha, + } diff --git a/core/estimators/its.py b/core/estimators/its.py new file mode 100644 index 0000000..3697a10 --- /dev/null +++ b/core/estimators/its.py @@ -0,0 +1,80 @@ +"""단절 시계열(Interrupted Time Series) — 단일 지역/전국 단위 정책용. + +모형 (segmented regression): + y_t = b0 + b1*t + b2*post_t + b3*(t - T0)*post_t [+ 계절 더미] + e_t + b2 = 도입 직후 수준 변화(level change, 주 추정치), b3 = 기울기 변화(slope change) +표준오차: Newey-West HAC (자기상관 대응). +한계: 통제집단이 없으므로 같은 시점의 다른 사건(동시 정책, 경기변동)과 구분 불가. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import statsmodels.api as sm +from statsmodels.stats.stattools import durbin_watson + +from .result import EffectResult + + +def its( + df: pd.DataFrame, + y: str, + time: str, + treat_time, + seasonal_period: int | None = None, + maxlags: int | None = None, + alpha: float = 0.05, + min_pre: int = 8, +) -> EffectResult: + d = df.sort_values(time).reset_index(drop=True) + t = np.arange(len(d), dtype=float) + post = (d[time] >= treat_time).astype(float).values + t0 = t[post.argmax()] if post.any() else np.nan + X = pd.DataFrame({"const": 1.0, "trend": t, "level": post, "slope": (t - t0) * post}) + if seasonal_period: + season = pd.get_dummies( + t.astype(int) % seasonal_period, prefix="s", drop_first=True, dtype=float + ) + X = pd.concat([X, season], axis=1) + maxlags = maxlags if maxlags is not None else max(1, int(len(d) ** 0.25)) + fit = sm.OLS(d[y].astype(float).values, X).fit(cov_type="HAC", cov_kwds={"maxlags": maxlags}) + ci = fit.conf_int(alpha=alpha).loc["level"] + res = EffectResult( + method="its", + outcome=y, + estimate=float(fit.params["level"]), + se=float(fit.bse["level"]), + ci_low=float(ci[0]), + ci_high=float(ci[1]), + p_value=float(fit.pvalues["level"]), + n_obs=int(fit.nobs), + n_clusters=None, + extra={ + "slope_change": float(fit.params["slope"]), + "slope_change_p": float(fit.pvalues["slope"]), + "fitted": fit.fittedvalues.values, + "counterfactual": ( + fit.fittedvalues + - fit.params["level"] * X["level"] + - fit.params["slope"] * X["slope"] + ).values, + }, + ) + n_pre = int((1 - post).sum()) + res.assumptions_checked["pre_periods"] = n_pre + dw = float(durbin_watson(fit.resid)) + res.assumptions_checked["durbin_watson"] = dw + if n_pre < min_pre: + res.warn( + f"사전 관측치 {n_pre}개 < {min_pre}: 사전 추세 추정이 불안정합니다.", "short_pre_period" + ) + if dw < 1.0 or dw > 3.0: + res.warn( + f"Durbin-Watson={dw:.2f}: 강한 자기상관. maxlags 를 늘리거나 ARIMA 오차를 고려하세요." + ) + res.warn( + "ITS 는 통제집단이 없어 동시기 다른 사건과 효과를 구분할 수 없습니다. " + "가능하면 비교 지역을 추가해 DiD 로 확장하세요." + ) + return res diff --git a/core/estimators/result.py b/core/estimators/result.py new file mode 100644 index 0000000..e2c11ab --- /dev/null +++ b/core/estimators/result.py @@ -0,0 +1,64 @@ +"""모든 추정기가 공통으로 반환하는 결과 객체.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Literal + +import pandas as pd + +Verdict = Literal["identified", "conditional", "not_identified"] + + +@dataclass +class EffectResult: + method: str + outcome: str + estimate: float + se: float + ci_low: float + ci_high: float + p_value: float + n_obs: int + n_clusters: int | None = None + assumptions_checked: dict[str, Any] = field(default_factory=dict) # 이름 -> {passed, stat, ...} + warnings: list[str] = field(default_factory=list) + triggers: list[str] = field(default_factory=list) # 발생한 abstention 트리거 코드 + verdict: Verdict = "identified" + extra: dict[str, Any] = field(default_factory=dict) # 예: event study 계수표 + + @property + def identified(self) -> bool: + return self.verdict == "identified" + + def warn(self, msg: str, trigger: str | None = None) -> None: + if msg not in self.warnings: + self.warnings.append(msg) + if trigger and trigger not in self.triggers: + self.triggers.append(trigger) + + def to_dict(self) -> dict: + d = asdict(self) + d["identified"] = self.identified + d["extra"] = { + k: ( + v.to_dict("records") + if isinstance(v, pd.DataFrame) + else v.tolist() + if hasattr(v, "tolist") + else v + ) + for k, v in self.extra.items() + } + return d + + def summary_ko(self) -> str: + label = {"identified": "식별됨", "conditional": "조건부", "not_identified": "식별 불가"}[ + self.verdict + ] + s = ( + f"[{self.method}] {self.outcome}: 효과 {self.estimate:.3f} " + f"(95% CI {self.ci_low:.3f} ~ {self.ci_high:.3f}, p={self.p_value:.3g}, " + f"N={self.n_obs}, 클러스터={self.n_clusters}) → 판정: {label}" + ) + return s + "".join(f"\n - 경고: {w}" for w in self.warnings) diff --git a/core/pipeline.py b/core/pipeline.py new file mode 100644 index 0000000..8f2290d --- /dev/null +++ b/core/pipeline.py @@ -0,0 +1,56 @@ +"""plan.yaml 하나로 추정 → 반박검정 → 보류판정까지 실행. + +케이스의 estimate.py 는 보통 `run_plan(plan, df)` 한 줄이면 충분하다. +시간 컬럼은 정수(연도, 또는 월/분기를 0,1,2… 로 인덱싱한 값)여야 한다. +""" + +from __future__ import annotations + +import pandas as pd + +from .estimators import EffectResult, apply_abstention, did, event_study, its, placebo_time +from .schema.plan import Plan + + +def run_plan(plan: Plan, df: pd.DataFrame) -> list[EffectResult]: + e, tr, th = plan.estimator, plan.treatment, plan.thresholds + common = dict( + unit=plan.unit.id_col, + time=plan.time.col, + group_col=tr.group_col, + treat_time=tr.treat_time, + first_treat_col=tr.first_treat_col, + cluster=e.cluster_col, + alpha=e.alpha, + min_clusters=th.min_clusters, + ) + results = [] + for o in plan.outcomes: + if e.method == "its": + r = its(df, o.col, plan.time.col, tr.treat_time, alpha=e.alpha) + elif e.method == "did": + r = did(df, o.col, covariates=e.covariates, **common) + else: + r = event_study( + df, + o.col, + ref_period=e.ref_period, + window=e.window, + pretrend_alpha=th.pretrend_alpha, + **common, + ) + for ref in plan.refutations: + if ref.kind == "placebo_time" and e.method != "its" and tr.treat_time is not None: + r.assumptions_checked["placebo_time"] = placebo_time( + df, + o.col, + plan.unit.id_col, + plan.time.col, + tr.group_col, + tr.treat_time, + shift=ref.params.get("shift", 2), + cluster=e.cluster_col, + alpha=th.pretrend_alpha, + ) + results.append(apply_abstention(r, plan.abstention, th)) + return results diff --git a/core/report/__init__.py b/core/report/__init__.py new file mode 100644 index 0000000..77253b9 --- /dev/null +++ b/core/report/__init__.py @@ -0,0 +1,4 @@ +from .markdown import render_report, results_table +from .plots import event_study_plot, its_plot, raw_trends + +__all__ = ["render_report", "results_table", "raw_trends", "event_study_plot", "its_plot"] diff --git a/core/report/markdown.py b/core/report/markdown.py new file mode 100644 index 0000000..ff6139b --- /dev/null +++ b/core/report/markdown.py @@ -0,0 +1,78 @@ +"""EffectResult + Plan → 마크다운 리포트. + +원칙: 판정(verdict)과 경고를 추정치보다 먼저 보여준다 (과잉해석 방지, W6). +""" + +from __future__ import annotations + +from ..estimators.result import EffectResult + +VERDICT_KO = { + "identified": "✅ 식별됨 (가정 하에서)", + "conditional": "⚠️ 조건부", + "not_identified": "⛔ 식별 불가", +} + + +def _p(p: float) -> str: + return "<0.001" if p < 0.001 else f"{p:.3f}" + + +def results_table(results: list[EffectResult]) -> str: + rows = [ + "| 방법 | 결과변수 | 추정치 | 95% CI | p | N | 클러스터 | 판정 |", + "|---|---|---:|---|---:|---:|---:|---|", + ] + for r in results: + rows.append( + f"| {r.method} | {r.outcome} | {r.estimate:.3f} | [{r.ci_low:.3f}, {r.ci_high:.3f}] " + f"| {_p(r.p_value)} | {r.n_obs} | {r.n_clusters or '-'} | {VERDICT_KO[r.verdict]} |" + ) + return "\n".join(rows) + + +def render_report( + plan, results: list[EffectResult], figures: dict[str, str], checks: dict | None = None +) -> str: + worst = max( + results, key=lambda r: ["identified", "conditional", "not_identified"].index(r.verdict) + ) + L = [f"# {plan.title}", ""] + if plan.synthetic_data: + L += [ + "> **⚠️ 합성(synthetic) 데이터입니다. 아래 수치는 실제 정책 효과가 아니며, " + "파이프라인 시연용입니다.**", + "", + ] + L += [ + f"**분석 질문**: {plan.question}", + "", + f"**종합 판정**: {VERDICT_KO[worst.verdict]}", + "", + "## 1. 설계", + "", + f"- 분석 단위: {plan.unit.name} (`{plan.unit.id_col}`), 기간 {plan.time.start}–{plan.time.end} ({plan.time.freq})", + f"- 처치: {plan.treatment.definition}", + f"- 통제: {plan.control.definition} — {plan.control.rationale}", + f"- 주 결과변수: {plan.primary_outcome.name} — {plan.primary_outcome.definition}", + f"- 추정 방법: `{plan.estimator.method}` (cluster: `{plan.estimator.cluster_col or plan.unit.id_col}`)", + "", + "## 2. 결과", + "", + results_table(results), + "", + ] + for name, path in figures.items(): + L += [f"![{name}]({path})", ""] + L += ["## 3. 가정 점검 및 반박 검정", "", "| 가정 | 점검 방법 | 결과 |", "|---|---|---|"] + checks = checks or {} + for a in plan.assumptions: + L.append(f"| {a.name} | {a.check} | {checks.get(a.name, '수동 검토 필요')} |") + L += ["", "## 4. 경고 및 보류 조건", ""] + warns = sorted({w for r in results for w in r.warnings}) + L += [f"- {w}" for w in warns] or [ + "- (자동 경고 없음 — 그래도 가정은 검증된 것이 아니라 '반박되지 않은' 것입니다)" + ] + L += ["", "## 5. 데이터 출처", "", "| 이름 | 제공 | 라이선스 | URL |", "|---|---|---|---|"] + L += [f"| {d.name} | {d.provider} | {d.license} | {d.url or '-'} |" for d in plan.data_sources] + return "\n".join(L) + "\n" diff --git a/core/report/plots.py b/core/report/plots.py new file mode 100644 index 0000000..ae5bd89 --- /dev/null +++ b/core/report/plots.py @@ -0,0 +1,79 @@ +"""리포트용 그림. 한글 폰트가 없는 CI 환경을 고려해 그림 라벨은 영문으로 둔다.""" + +from __future__ import annotations + +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import pandas as pd # noqa: E402 + +TREAT, CTRL, GREY = "#D1495B", "#00798C", "#8D99AE" + + +def _finish(ax, out: str | Path): + ax.spines[["top", "right"]].set_visible(False) + ax.grid(axis="y", alpha=0.3) + fig = ax.get_figure() + fig.tight_layout() + fig.savefig(out, dpi=150) + plt.close(fig) + return Path(out) + + +def raw_trends( + df: pd.DataFrame, y: str, time: str, group_col: str, treat_time, out, ylabel: str | None = None +): + """처치/통제 집단 평균 추이 (가공 전 데이터 그대로).""" + m = df.groupby([time, group_col])[y].mean().unstack() + fig, ax = plt.subplots(figsize=(7, 4)) + ax.plot(m.index, m[1], "o-", color=TREAT, label="Treated") + ax.plot(m.index, m[0], "s--", color=CTRL, label="Control") + ax.axvline(treat_time - 0.5, color=GREY, ls=":", label="Policy start") + ax.set(xlabel=time, ylabel=ylabel or y, title="Raw trends: treated vs control (group means)") + ax.legend(frameon=False) + return _finish(ax, out) + + +def event_study_plot(coefs: pd.DataFrame, out, ylabel: str = "Effect vs t=-1"): + fig, ax = plt.subplots(figsize=(7, 4)) + pre = coefs["rel_time"] < 0 + for mask, c in ((pre, CTRL), (~pre, TREAT)): + s = coefs[mask] + ax.errorbar( + s["rel_time"], + s["coef"], + yerr=[s["coef"] - s["ci_low"], s["ci_high"] - s["coef"]], + fmt="o", + color=c, + capsize=3, + ) + ax.axhline(0, color="black", lw=0.8) + ax.axvline(-0.5, color=GREY, ls=":") + ax.set( + xlabel="Periods relative to policy (endpoints binned)", + ylabel=ylabel, + title="Event study (95% CI, cluster-robust)", + ) + return _finish(ax, out) + + +def its_plot(df: pd.DataFrame, y: str, time: str, treat_time, fitted, counterfactual, out): + d = df.sort_values(time) + fig, ax = plt.subplots(figsize=(7, 4)) + ax.plot(d[time], d[y], "o", color=GREY, ms=4, label="Observed") + ax.plot(d[time], fitted, color=TREAT, label="Fitted") + post = d[time] >= treat_time + ax.plot( + d[time][post], + counterfactual[post.values], + "--", + color=CTRL, + label="Counterfactual (pre-trend)", + ) + ax.axvline(treat_time, color=GREY, ls=":") + ax.set(xlabel=time, ylabel=y, title="Interrupted time series") + ax.legend(frameon=False) + return _finish(ax, out) diff --git a/core/schema/__init__.py b/core/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/schema/plan.py b/core/schema/plan.py new file mode 100644 index 0000000..4e664c8 --- /dev/null +++ b/core/schema/plan.py @@ -0,0 +1,167 @@ +"""분석계획(plan.yaml) 스키마. + +케이스마다 `plan.yaml` 하나로 "무엇을, 어떤 가정 아래, 어떤 방법으로 추정하고, +어떤 조건이면 답을 보류(abstain)하는가"를 선언한다. 에이전트(W3~)는 이 스키마를 +채우는 것이 목표이고, 추정 모듈(W4~5)은 이 스키마만 읽고 실행한다. + +사용: + from core.schema.plan import load_plan + plan = load_plan("cases/_example_night_clinic/plan.yaml") +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, model_validator + +# 공공누리(KOGL) 유형 + 기타. 1유형(출처표시)만 상업적 이용·변경 모두 허용. +License = Literal[ + "KOGL-1", # 공공누리 제1유형: 출처표시 + "KOGL-2", # 제2유형: 출처표시 + 상업적 이용금지 + "KOGL-3", # 제3유형: 출처표시 + 변경금지 + "KOGL-4", # 제4유형: 출처표시 + 상업적 이용금지 + 변경금지 + "CC-BY-4.0", + "CC0", + "synthetic", # 합성 데이터 (실데이터 아님) + "other", +] + +Method = Literal["did", "event_study", "its"] + +# 기계적으로 평가 가능한 보류(abstention) 트리거 목록. +# core.estimators.diagnostics.apply_abstention 이 EffectResult 에 적용한다. +AbstainTrigger = Literal[ + "pretrend_rejected", # 사전추세 결합검정 p < pretrend_alpha + "few_clusters", # 클러스터 수 < min_clusters + "staggered_adoption", # 도입시점이 여러 개인데 TWFE 사용 + "placebo_significant", # 가짜 도입시점 검정이 유의 + "ci_crosses_zero", # 신뢰구간이 0을 포함 (효과 '없음'이 아니라 '불확실') + "short_pre_period", # 사전 기간 관측치 부족 +] + + +class _Base(BaseModel): + model_config = ConfigDict(extra="forbid") # 오타 필드 즉시 오류 + + +class Unit(_Base): + name: str = Field(..., description="분석 단위 (예: 시군구)") + id_col: str + + +class TimeSpec(_Base): + col: str + freq: Literal["year", "quarter", "month", "week", "day"] = "year" + start: int | str + end: int | str + + +class Treatment(_Base): + definition: str = Field(..., description="처치(정책)의 조작적 정의") + group_col: str = Field(..., description="처치집단 여부(0/1) 컬럼") + first_treat_col: str | None = Field( + None, description="단위별 최초 처치 시점 컬럼 (미처치=결측). 시차도입이면 필수" + ) + treat_time: int | str | None = Field(None, description="단일 도입 시점 (동시 도입일 때)") + + @model_validator(mode="after") + def _need_timing(self): + if self.treat_time is None and self.first_treat_col is None: + raise ValueError("treat_time 또는 first_treat_col 중 하나는 필요합니다") + return self + + +class Control(_Base): + definition: str + rationale: str = Field(..., description="왜 이 집단이 반사실(counterfactual)로 적절한가") + + +class Outcome(_Base): + name: str + col: str + definition: str + unit: str | None = None + primary: bool = False + expected_direction: Literal["increase", "decrease", "unknown"] = "unknown" + + +class EstimatorSpec(_Base): + method: Method + cluster_col: str | None = None + covariates: list[str] = [] + ref_period: int = Field(-1, description="event study 기준 상대시점") + window: tuple[int, int] = Field((-4, 4), description="event study 상대시점 범위 (양끝 binning)") + alpha: float = 0.05 + + +class Assumption(_Base): + name: str + description: str + check: Literal["pretrend_test", "placebo_time", "manual", "none"] = "manual" + + +class Refutation(_Base): + kind: Literal["placebo_time", "placebo_outcome", "drop_unit"] + params: dict = {} + + +class AbstentionRule(_Base): + when: AbstainTrigger + verdict: Literal["not_identified", "conditional"] = "conditional" + note: str = "" + + +class DataSource(_Base): + name: str + provider: str = Field(..., description="예: KOSIS, 공공데이터포털, 서울 열린데이터광장") + url: str | None = None + license: License + adapter: str | None = Field(None, description="core.adapters 의 어댑터 이름") + notes: str = "" + + +class Thresholds(_Base): + pretrend_alpha: float = 0.10 + min_clusters: int = 20 + min_pre_periods: int = 3 + + +class Plan(_Base): + case_id: str + title: str + question: str + synthetic_data: bool = Field( + False, description="합성 데이터면 반드시 true — 리포트에 경고 표시" + ) + unit: Unit + time: TimeSpec + treatment: Treatment + control: Control + outcomes: list[Outcome] = Field(..., min_length=1) + estimator: EstimatorSpec + assumptions: list[Assumption] = Field(..., min_length=1) + refutations: list[Refutation] = [] + abstention: list[AbstentionRule] = Field(..., min_length=1) + thresholds: Thresholds = Thresholds() + data_sources: list[DataSource] = Field(..., min_length=1) + + @model_validator(mode="after") + def _consistency(self): + if self.synthetic_data != any(d.license == "synthetic" for d in self.data_sources): + raise ValueError("synthetic_data 플래그와 data_sources.license='synthetic' 이 불일치") + if self.estimator.method == "its" and self.treatment.treat_time is None: + raise ValueError("ITS 는 단일 treat_time 이 필요합니다") + return self + + @property + def primary_outcome(self) -> Outcome: + return next((o for o in self.outcomes if o.primary), self.outcomes[0]) + + +def load_plan(path: str | Path) -> Plan: + """YAML 파일을 읽어 검증된 Plan 을 반환. 스키마 위반 시 pydantic.ValidationError.""" + with open(path, encoding="utf-8") as f: + return Plan.model_validate(yaml.safe_load(f)) diff --git a/docs/ops/github-onboarding.md b/docs/ops/github-onboarding.md new file mode 100644 index 0000000..b923711 --- /dev/null +++ b/docs/ops/github-onboarding.md @@ -0,0 +1,84 @@ +# GitHub 온보딩 (처음 쓰는 분용) + +명령어는 그대로 복사해서 실행하세요. `group3`, `youth-rent`만 자기 조/주제로 바꾸면 됩니다. + +## 0. 준비 (1회) + +1. GitHub 계정 생성, 프로필에 실명 또는 닉네임 설정 +2. Git 설치 확인: `git --version` (없으면 https://git-scm.com) +3. GitHub CLI 설치 (권장): https://cli.github.com → `gh auth login` (GitHub.com → HTTPS → 브라우저 로그인) +4. 커밋 작성자 설정: + ```bash + git config --global user.name "홍길동" + git config --global user.email "GitHub에 등록한 이메일" + ``` + +## 1. 초대 수락 + +- 이메일 또는 https://github.com/orgs/CausalInferenceLab/invitation 에서 **Join** 클릭 +- 확인: https://github.com/CausalInferenceLab/policy-effect-analytics-agent 에 `Code` 버튼과 브랜치 생성 권한이 보이면 성공 +- 초대 메일이 없으면 GitHub ID를 운영진에게 알려주세요. + +## 2. 내려받기 (clone) 와 환경 설치 + +```bash +gh repo clone CausalInferenceLab/policy-effect-analytics-agent +cd policy-effect-analytics-agent +uv venv -p 3.11 && source .venv/bin/activate # Windows: .venv\Scripts\activate +make install # make 없으면: uv pip install -e ".[dev]" +cp .env.example .env # API 키 입력 (커밋되지 않음) +make check # 통과하면 준비 끝 +``` + +## 3. 브랜치 만들기 + +`main`에서 직접 작업하지 않습니다. 항상 최신 `main`에서 새 브랜치를 만드세요. + +```bash +git switch main +git pull +git switch -c group3/plan +``` + +## 4. 작업하고 커밋하기 + +```bash +cp -r cases/_template cases/group3-youth-rent # 첫 작업일 때만 +# ... plan.yaml 수정 ... +git status # 무엇이 바뀌었는지 확인 +git add cases/group3-youth-rent +git commit -m "plan(group3-youth-rent): 질문과 처치·대조 정의" +git push -u origin group3/plan # 두 번째부터는 git push +``` + +- `git add .` 대신 **자기 폴더만** add 하는 습관을 들이세요 (`.env`, 원자료 실수 방지). + +## 5. PR 올리기 + +```bash +gh pr create --fill --base main # 또는 GitHub 웹에서 "Compare & pull request" +gh pr create --draft --fill # 아직 작업 중이면 Draft +``` + +1. PR 본문 체크리스트를 채웁니다. +2. CI(초록 체크)를 기다립니다. 빨간 X면 `Details`를 눌러 로그를 확인하고, 고쳐서 다시 push 하면 PR이 자동 갱신됩니다. +3. 조원 또는 멘토 1명 **Approve** 후 **Squash and merge**. + +## 6. 병합 후 정리 + +```bash +git switch main +git pull +git branch -d group3/plan +``` + +## 자주 막히는 곳 + +| 증상 | 해결 | +|---|---| +| `Permission denied` / 403 on push | 초대 수락 확인, `gh auth login` 다시 | +| `rejected ... fetch first` | `git pull --rebase` 후 다시 push | +| 충돌(conflict) | 충돌 파일에서 `<<<<<<<` 구간 정리 → `git add 파일` → `git rebase --continue` | +| `.env`를 실수로 커밋 | push 전이면 `git reset HEAD~1`. **push 했다면 즉시 키를 재발급**하고 운영진에게 알림 | +| main에 커밋해버림 | `git switch -c group3/fix` 로 브랜치를 만든 뒤 push (main은 보호되어 push 안 됨) | +| CI의 ruff 에러 | 로컬에서 `make format` 후 다시 커밋 | diff --git a/docs/ops/monitoring.md b/docs/ops/monitoring.md new file mode 100644 index 0000000..067fe28 --- /dev/null +++ b/docs/ops/monitoring.md @@ -0,0 +1,63 @@ +# 활동 모니터링 (멘토·PM용) + +원칙: **커밋·PR이 곧 출석**입니다. 조별로 주 1회 이상 의미 있는 커밋과 PR 흐름이 보이면 건강한 상태입니다. + +## 1. 로컬 스크립트 (네트워크 불필요) + +```bash +git fetch --all --prune # 병합 전 브랜치까지 포함하려면 먼저 +python scripts/weekly_activity.py # 최근 7일, 모든 브랜치 +python scripts/weekly_activity.py --days 14 --ref origin/main # main에 반영된 것만 +make activity +``` + +출력: `cases/<조>`별 커밋 수·작성자 수·변경 파일 수·추가/삭제 줄 수. 활동 없는 조는 `<- no activity` 표시. + +## 2. `gh` CLI 한 줄 명령 + +```bash +R=CausalInferenceLab/policy-effect-analytics-agent +SINCE=$(date -d '7 days ago' +%F 2>/dev/null || date -v-7d +%F) # Linux || macOS + +# 이번 주 열린/병합된 PR +gh pr list -R $R --state all --search "created:>=$SINCE" --limit 100 +gh pr list -R $R --state merged --search "merged:>=$SINCE" + +# 리뷰 대기 중인 PR (오래된 순) +gh pr list -R $R --search "is:open review:required sort:created-asc" + +# 조별 브랜치의 PR (브랜치 prefix = 조) +gh pr list -R $R --state all --json headRefName,title,state,author \ + --jq '.[] | select(.headRefName|startswith("group3/")) | [.state,.author.login,.title] | @tsv' + +# 이번 주 main 커밋 작성자별 수 +gh api "repos/$R/commits?since=${SINCE}T00:00:00Z&per_page=100" --paginate \ + --jq '.[].author.login' | sort | uniq -c | sort -rn + +# 특정 조 폴더의 커밋 +gh api "repos/$R/commits?path=cases/group3-youth-rent&since=${SINCE}T00:00:00Z" \ + --jq '.[] | [.commit.author.date[:10], .author.login, .commit.message] | @tsv' + +# CI 실패 현황 +gh run list -R $R --status failure --limit 20 + +# 케이스 제안 이슈 +gh issue list -R $R --label case-proposal --state all +``` + +## 3. "건강함"의 기준 + +| 지표 | 건강 | 주의 (멘토링 필요) | +|---|---|---| +| 조별 주간 커밋 | 3개 이상, 2명 이상 작성자 | 0개 또는 1명만 커밋 | +| PR 흐름 | 주 1개 이상 병합 | 7일 넘게 열린 PR, 리뷰 없음 | +| CI | 병합 전 초록 | 같은 PR에서 3회 이상 연속 실패 | +| 사전 등록 | 2주차에 `plan.yaml` 병합 | 결과 그림이 plan.yaml보다 먼저 커밋 | +| 데이터 위생 | `data_sources.license` 기재 | 대용량/원자료 커밋, `.env` 흔적 | + +## 4. 주간 루틴 (15분) + +1. `git fetch --all && make activity` → 조용한 조 확인 +2. `gh pr list ... review:required` → 오래된 PR 리뷰 배정 +3. `gh run list --status failure` → 반복 실패 조에 도움 요청 코멘트 +4. 결과를 주간 공지(노션/채널)에 3줄 요약 diff --git a/docs/strategy/korea-cases.md b/docs/strategy/korea-cases.md new file mode 100644 index 0000000..7f2fcc6 --- /dev/null +++ b/docs/strategy/korea-cases.md @@ -0,0 +1,117 @@ +# 국내 정책효과 분석 사례 + 재현용 해외 교과서 사례 + +> 목적: 멘티 조가 "누가 이미 이 질문을 어떤 방법·데이터로 풀었나"를 5분 안에 파악하고, **오픈데이터만으로 4주 안에 재현·확장 가능한지** 판단하도록 돕는다. +> 작성: Strategy, 2026-09-24. 링크는 모두 직접 확인한 것. "초록 미확인"은 제목/서지만 확인하고 본문을 열지 못한 경우(KCI·DBpia는 자동 접근 차단). + +## 0. 한눈에 보기 + +| # | 정책 | 식별전략 | 결과 요약 | 오픈데이터 재현성 | +|---|---|---|---|---| +| K1 | 안전속도5030 vs 민식이법 | DiD | 5030 효과 유의, 민식이법 유의하지 않음 | ◎ (TAAS·KOSIS) | +| K2 | 안전속도5030 (서울 구간) | 비교그룹 사전·사후 (Hauer) | 전체사고 약 10%↓ | ◎ (TAAS) | +| K3 | 윤창호법 (음주운전 처벌 강화) | 사전·사후 + 전년 동기 비교 | 3개월 단기효과만 유의 | ◎ (KOSIS/TAAS) | +| K4 | 대형마트 의무휴업 평일 전환 | 지역 간 비교 회귀 (DiD형) | 마트↑, 온라인↓, 전통시장 부정효과 없음 | △ (카드데이터 비공개 → 서울 상권 추정매출로 대체) | +| K5 | 지역화폐 도입 | 삼중차분(DDD) | 소매업 전체 매출 효과 없음 | ✕ (SBDC 마이크로데이터) | +| K6 | 1차 긴급재난지원금 | 카드매출 전후·업종 비교 | 투입액의 26.2~36.1% 매출 증가 | ✕ (카드사 데이터) | +| K7 | 토지거래허가구역 지정(잠실 등) | DiD / 사례비교 | (초록 미확인) | ◎ (국토부 실거래가 API) | +| K8 | 연세로 대중교통전용지구 | 합성통제(SCM) | (초록 미확인) | △ (서울 상권 매출은 2021년 이후만 제공) | +| K9 | 청주·청원 통합 | 합성통제(SCM) | (초록 미확인) | ○ (KOSIS·지방재정365) | +| K10 | 지자체 출산지원금 | MGWR (횡단면 상관) | 지역별로 부호가 다름 | ◎ (KOSIS) — **인과 설계가 아님** | +| K11 | 지자체 혼인장려금 | (초록 미확인) | (초록 미확인) | ◎ (KOSIS) | +| K12 | 서울 녹색교통지역 5등급 차량 운행제한 | (초록 미확인) | 서울시 발표: 5등급 통행량 감소 | ◎ (에어코리아) | + +기호: ◎ 결과·처치 모두 오픈데이터, ○ 일부 수작업 수집 필요, △ 대체 지표 필요, ✕ 승인 필요 마이크로데이터/민간데이터. + +--- + +## 1. 국내 사례 상세 + +### K1. 도로 제한속도 규제 효과: 민식이법 vs 안전속도5030 — ★재현 1순위 +- 출처: 김기만·배관표 (2024), 「도로속도제한의 규제효과 분석: 민식이법과 안전속도5030의 비교」, 『규제연구』 33(2). [PDF](https://journal.kci.go.kr/ksrs2002/archive/articlePdf?artiId=ART003165741) · [KCI](https://www.kci.go.kr/kciportal/landing/article.kci?arti_id=ART003165741) +- 식별: 이중차분(DiD). 분석기간 2017–2019 vs 2021–2023 (2020년 제외). +- 데이터: 경찰청, 한국도로교통공단, 국토부 교통통계. +- 결과: 5030은 사고 10,851건, 사망 164명, 부상 18,830명 감소로 추정. 민식이법은 통계적으로 유의한 효과를 찾지 못함. +- 쓸모: **같은 데이터로 "효과 있음"과 "식별·검정력 부족"이 함께 나오는 사례**라서, 에이전트의 기권(abstention) 로직을 시연하기 좋다. 2020년(코로나)을 뺀 선택 자체가 가정이므로 민감도 분석으로 확장할 수 있다. +- 관련: 「어린이보호구역 내 과속단속카메라 설치법(민식이법)의 효과」 [KCI](https://www.kci.go.kr/kciportal/ci/sereArticleSearch/ciSereArtiView.kci?sereArticleSearchBean.artiId=ART003297075) (초록 미확인) + +### K2. 안전속도5030 정책의 교통사고 영향 사전·사후 분석 +- 출처: 한상진 (2022), 서울대 석사학위논문. [S-Space](https://s-space.snu.ac.kr/handle/10371/188539) +- 식별: 비교그룹 사전·사후(Hauer 1997 방식)와 카이제곱 검정. +- 데이터: TAAS 도로구간별 사고(전체, 차대차, 차대사람). +- 결과: 전체·차대차 사고 약 10% 감소. 차대사람 사고 약 38% 감소는 표본이 적어 신뢰성이 낮음. 더 엄격한 검정에서는 종로 구간만 유의. +- 쓸모: 구간(도로) 단위 분석, **희소 사건(사망·보행자 사고)의 검정력 문제**를 보여 준다. + +### K3. 윤창호법의 음주운전 억제효과 — 입문용 +- 출처: 박철현 (2022), 「윤창호법의 음주운전 억제효과」, 『사회통합연구』 3(2). [e-sir](https://www.e-sir.org/archive/view_article?pid=sir-3-2-97) +- 식별: 시행 전후 3개월·6개월을 **전년 같은 기간**과 비교(준실험). 제1·제2 윤창호법 두 시점. +- 데이터: 경찰청 교통사고 통계(KOSIS). +- 결과: 3개월 단기 감소(제1법 52%, 제2법 61%)는 유의. 6개월 효과는 유의하지 않음 → 억제효과가 일시적. +- 확장: 월별 단절시계열(ITS) + 비음주 사고를 대조 시계열로 쓰는 comparative ITS, 시군구 패널. + +### K4. 대형마트 의무휴업 평일 전환 효과 (KDI FOCUS, 2026.5) +- 출처: KDI FOCUS 「의무휴업일 평일 전환이 시사하는 유통정책의 전환 방향」 [KDI](https://www.kdi.re.kr/research/focusView?pub_no=19187) · 요약 기사 [다음/조선](https://v.daum.net/v/20260521123417873) +- 식별: 평일 전환 지자체(대구·청주·서울 일부 등 약 30곳)와 주말 휴업 유지 지역의 매출 추이 비교(DiD 형태의 회귀). +- 결과: 대형마트 매출 2.8~7.9%↑, 온라인 매출 감소(대구 −2.89%), 전통시장은 유의한 부정효과 없음(일부 최대 +15.4%). +- 재현성: 카드 매출 원자료는 비공개. **서울 자치구 전환(서초 2024.1~, 동대문, 중구 2024.11~)은 서울 상권분석서비스 추정매출(2021~, 분기)로 다시 풀 수 있다** → topic-guide T2. +- 참고(원 규제의 효과): 「대형마트 의무휴업이 중소서비스업체에 미치는 효과: 신용카드매출 데이터를 활용한 분석」 [KCI](https://www.kci.go.kr/kciportal/ci/sereArticleSearch/ciSereArtiView.kci?sereArticleSearchBean.artiId=ART002505193) (초록 미확인) + +### K5. 지역화폐 도입이 지역경제에 미친 영향 (한국조세재정연구원, 2020) +- 출처: 송경호·이환웅, KIPF 수시연구과제. [PDF](https://www.kipf.re.kr/uloads/kiPublish/202012/FILE_202102020125031083.pdf) · [발간 페이지](https://www.kipf.re.kr/kor/Publication/All/kiPublish/ALL/view.do?serialNo=526520) +- 식별: 삼중차분(지역화폐 도입 여부 × 시기 × 지역 내 수혜업종), 미도입 지자체를 통제군으로 사용. +- 데이터: 통계청 SBDC 기업등록부DB(2011–2018) 전수 + 지자체 발행액. +- 결과: 소매업 전체 매출 증가 효과는 확인되지 않음. 슈퍼마켓·식료품점에서만 유의. +- 재현성: ✕ (SBDC 원격접근 승인 필요). **"같은 정책에 상반된 보고서가 나온 사례"**로 가정·데이터 선택의 중요성을 보여 줄 때 쓴다([머니투데이 논쟁 정리](https://www.mt.co.kr/economy/2020/09/24/2020092117520680414)). + +### K6. 1차 긴급재난지원금 효과 (KDI, 2020) +- 출처: KDI FOCUS 「1차 긴급재난지원금 정책의 효과와 시사점」 [KDI](https://www.kdi.re.kr/research/focusView?pub_no=16851) · [정책브리핑 요약](https://www.korea.kr/news/policyFocusView.do?newsId=148881598&pkgId=49500742) +- 식별: 지급 전후, 사용 가능 업종과 불가 업종의 카드매출 비교(업종 간 DiD 구조) + 가구 설문. +- 결과: 카드매출 약 4조 원 증가(투입액의 26.2~36.1%). 사용 가능 업종 매출 증가율이 −4.0%에서 +7.1%로 올라 11.1%p 차이. +- 재현성: ✕ (카드사 데이터). 업종 간 대조 설계 아이디어만 가져올 것. + +### K7. 토지거래허가구역 지정 효과 (서울 잠실·삼성·대치·청담) +- 출처(초록 미확인): 「토지거래허가구역 지정이 매매가와 전세가에 미치는 영향: 잠실동 아파트 사례분석」 [교보스콜라](https://scholar.kyobobook.co.kr/article/detail/4010071233597) · 「토지거래허가구역 지정의 풍선효과 분석: 잠실·삼성·대치·청담동 사례」 [DBpia](https://www.dbpia.co.kr/journal/articleDetail?nodeId=NODE12208115) · 「토지거래허가제가 인근지역에 미치는 풍선효과」 [KIPF](https://www.kipf.re.kr/cmm/fms/FileDown.do;jsessionid=BF32ED215F10DBBE31C068DC7D1F2238?atchFileId=FILE_000000024724Mq7&fileSn=0) · 「서울시 토지거래허가제의 지가 안정화 효과」 [부동산연구](https://www.ejrea.org/archive/view_article?pid=jrea-11-2-1) +- 재현성: ◎ 국토부 아파트 매매 실거래가 API가 무료·자동승인([data.go.kr 15126468](https://www.data.go.kr/data/15126468/openapi.do)). 2025년 2월 해제 후 3월 재지정·확대([KB 정리](https://kbthink.com/main/asset-management/wealth-manage-tip/kbthink-original/202504/land-transaction-permission-areas.html))로 **켜짐·꺼짐·확대가 모두 있는 자연실험**이 생겼다 → topic-guide T3. + +### K8. 연세로 대중교통전용지구의 상권 효과 (SCM) +- 출처: 「서울시 연세로 대중교통전용지구 정책의 상권 활성화 효과 분석: 통제집단합성법을 활용하여」, 『국토계획』. [KPAJ](https://kpaj.or.kr/_PR/view/?aidx=40522&bidx=3654) (초록 미확인). 정책 배경 [서울정책아카이브](https://seoulsolution.kr/node/3015), 2024년 해제 결정 [서울시](https://news.seoul.go.kr/traffic/archives/513420) +- 관련: 「통제집단합성법을 활용한 차 없는 거리 정책의 도시 활력 증진 효과 분석」 [한양대 repository](https://repository.hanyang.ac.kr/handle/20.500.11754/177190) (초록 미확인) +- 재현성: △ 서울 상권분석서비스 추정매출은 **2026.7.3부터 2021년 이후 자료만 제공**([OA-15572](https://data.seoul.go.kr/dataList/OA-15572/S/1/datasetView.do)). 따라서 2014년 지정 효과는 재현할 수 없고, **해제(2024년 결정) 효과**만 볼 수 있다. + +### K9. 청주·청원 통합 효과 (SCM) +- 출처: 「기초지자체 통합 효과 분석: 청주-청원 통합 사례를 중심으로」 [KCI](https://www.kci.go.kr/kciportal/landing/article.kci?arti_id=ART002794493) (초록 미확인) +- 쓸모: **처치 단위 1개 + 행정경계 변경**이 함께 있는 SCM의 전형이다. 통합 전 시계열을 새 경계로 합산해야 하는 문제(행정경계 변경 함정)를 연습할 수 있다. + +### K10. 출산지원금이 지역 출산력에 미치는 영향의 공간적 변이 (보건사회연구, 2022) — "인과 아님" 반면교사 +- 출처: 장인수·정찬우 (2022), 『보건사회연구』 42(4). [PDF](https://www.kihasa.re.kr/hswr/assets/pdf/1379/journal-42-4-305.pdf) +- 방법: 228개 시군구, 2019년 지원금 × 2020년 출산율의 MGWR(횡단면). +- 결과: 대체로 양(+)의 상관이지만 지역에 따라 부호가 다름. +- 쓸모: **횡단면 상관은 정책효과가 아니다.** 같은 KOSIS 데이터를 시군구×연도 패널과 지원금 인상 시점 기반 staggered DiD로 다시 설계하는 것이 좋은 확장 과제다 → topic-guide T7. 선행 패널 연구: [KDI EIEC 요약](https://eiec.kdi.re.kr/policy/domesticView.do?ac=0000185938), [KCI](https://www.kci.go.kr/kciportal/ci/sereArticleSearch/ciSereArtiView.kci?sereArticleSearchBean.artiId=ART002407474) (초록 미확인) + +### K11. 지방자치단체 혼인장려금 정책이 혼인 건수에 미치는 영향 +- 출처: 『지방행정연구』 계열 논문 [PDF](http://www.kala.kr/data/file/articlesearch/3754092228_xFluc9Mm_01_EC8690ED98B8EC84B1_EAB980EC8898EC9881.pdf) (리다이렉트 오류로 본문 미확인) +- 재현성: 결과(시군구 혼인건수)는 KOSIS. 처치(도입 시점·금액)는 조례에서 직접 수집해야 한다. + +### K12. 서울 녹색교통지역 5등급 차량 운행제한 +- 정책: 사대문 안 16.7㎢, 5등급 차량 06~21시 운행제한, 2019.7 시범 운영 후 **2019.12 과태료 부과 시작**, 저감장치 개발이 안 된 차종은 2020.12.31까지 유예([서울시](https://news.seoul.go.kr/traffic/greentraffic)). +- 선행: 「서울특별시 자동차 운행제한 정책에 따른 대기질 개선 효과 분석」 [ResearchGate](https://www.researchgate.net/publication/389635332_seoulteugbyeolsi_jadongcha_unhaengjehan_jeongchaeg-e_ttaleun_daegijil_gaeseon_hyogwa_bunseog) (초록 미확인, 429 오류). 서울시 발표: 2년 만에 5등급 통행 58.6% 감소([서울신문](https://www.seoul.co.kr/news/society/2022/01/25/20220125500110)). 이는 준수율(1단계 결과)이고, 대기질(최종 결과) 효과는 아니다. +- 재현성: ◎ 에어코리아 최종확정 측정자료 2001–2026 연도별 다운로드([에어코리아](https://www.airkorea.or.kr/web/last_amb_hour_data?pMENU_NO=123)) → topic-guide T1. + +### 참고: 기관별 평가 체계 (사례 탐색용) +- KDI 공공투자관리센터 재정사업 심층평가 보고서 목록: [PIMAC](https://pimac.kdi.re.kr/study/study_list.jsp?classcd=F5) / 심층평가 지침 [KDI](https://kdi.re.kr/research/subjects_view.jsp?pub_no=10067) +- KIPF 재정사업 심층평가(예: 2021 창업지원 사업군): [KIPF repository](https://repository.kipf.re.kr/handle/201201/8740) +- 대부분 부처 행정 DB(고용보험 등)를 쓰므로 ✕이다. 다만 **평가 질문을 어떻게 정의했는지**와 성과지표 체계는 plan.yaml의 `question`/`outcome`을 작성할 때 참고할 만하다. + +--- + +## 2. 해외 재현용 교과서 사례 (벤치마크 맵에 없는 것만) + +이미 정리된 벤치마크 맵은 거버넌스·도구 중심이다. 아래는 **에이전트 회귀 테스트(golden case)**로 쓸 수 있는, 정답이 알려진 공개 데이터·코드 사례다. + +| 사례 | 방법 | 공개 데이터/코드 | 추가 가치 | +|---|---|---|---| +| Card & Krueger (1994) NJ/PA 최저임금 | 2×2 DiD | [David Card data sets](https://davidcard.berkeley.edu/data_sets.html) | 가장 단순한 DiD 정답 케이스. estimator 단위 테스트용 | +| Abadie·Diamond·Hainmueller (2010) 캘리포니아 Prop 99 | SCM + placebo-in-space | [Hainmueller Synth](https://web.stanford.edu/~jhain/synthpage.html) | 처치 단위 1개(K9·K12와 같은 구조). 순열추론 기준값 | +| Callaway & Sant'Anna (2021) `mpdta` 카운티 최저임금 | staggered DiD | [`did` 패키지](https://bcallaway11.github.io/did/) | **시차 도입 시 TWFE 편향** 시연. T2·T4·T7이 모두 시차 도입이다 | +| Cheng & Hoekstra (2013) Castle doctrine | staggered DiD, 이벤트스터디 | [Mixtape 9장](https://mixtape.scunning.com/09-difference_in_differences) | 주(state) 패널 + 사전추세 그림. 한국어 멘티용 교재로도 적합 | + +권장: `tests/golden/`에 Card-Krueger와 Prop 99를 넣고, 에이전트가 알려진 추정치를 허용 오차 안에서 재현하는지 CI로 확인한다(Develop 팀과 협의). diff --git a/docs/strategy/plan-guide.md b/docs/strategy/plan-guide.md new file mode 100644 index 0000000..74b6e89 --- /dev/null +++ b/docs/strategy/plan-guide.md @@ -0,0 +1,164 @@ +# 분석계획(plan.yaml) 작성 가이드 + +> 원칙: **데이터를 열기 전에** 확정하고 커밋한다(사전등록). 데이터를 본 뒤 바꾼 항목은 `amendments`에 날짜와 사유를 남긴다. 에이전트는 plan.yaml에 없는 추정을 "탐색적(exploratory)"으로만 보고해야 한다. +> 필드 구성은 GSA OES Analysis Plan의 섹션과 1:1로 대응된다(아래 표). 스키마 구현은 `core/schema/plan.py`(Develop)가 맡고, 이 문서는 **내용 기준**을 정한다. + +## 1. 필수 필드와 결정 기준 + +| 필드 | 조가 결정할 것 | 최소 기준 | OES 대응 섹션 | +|---|---|---|---| +| `question` | 한 문장의 인과 질문 | "X가 Y를 (얼마나) 바꿨나" 형식. 추정대상(estimand: ATT/ATE/효과 시점)을 명시 | Project description / Hypotheses | +| `unit` | 분석 단위 × 시간 단위 | 처치가 배정되는 단위보다 작으면 클러스터링 수준을 따로 적는다 | Data structure | +| `treatment` | 무엇을, 누구에게, 언제 | 날짜 단위 시점, 발표일과 시행일 구분, 강도(dose) 변화 시점, 출처 URL | Intervention | +| `control` | 대조군 정의와 제외 규칙 | 오염(spillover) 우려 단위는 제외하거나 별도 표시. 후보 풀을 사전에 고정 | Comparison / Sample | +| `period` | 사전·사후 창, 제외 구간 | 사전 시점 ≥ 8(월/분기) 권장. 제외 구간(예: 코로나)은 사유와 함께 적는다 | Data | +| `outcome` | 1차 결과 1개, 2차 결과 ≤ 3개 | 변수명, 출처 데이터셋 ID·URL, 집계식, 단위 | Outcome measures | +| `covariates` | 통제변수 | **처치 이후 값이 처치 영향을 받는 변수는 금지**(나쁜 통제) | Statistical models | +| `estimator` | 주 추정량 1개와 강건성 추정량 | 시차 도입이면 TWFE를 주 추정량으로 쓰지 않는다. SE 방식과 클러스터 수를 적는다 | Statistical models / Inference | +| `assumptions` | 식별 가정 목록 | 가정마다 **검증 방법 + 합격 기준**을 짝지어 적는다 | Balance checks / Limitations | +| `refutation` | 반증 테스트 | placebo 시점, placebo 결과, placebo 단위, 표본 민감도 중 최소 2개 | Robustness | +| `abstain_if` | 기권 조건 | 기계가 판정할 수 있는 조건. 충족되면 효과 수치 대신 "식별 불가 + 사유"를 출력 | Inference criteria / Limitations | +| `concurrent_policies` | 동시 정책 | 날짜, 적용 범위, 처리 방법(통제·제외·한계 기술) | Limitations | +| `reporting` | 보고 규칙 | 신뢰구간과 효과크기를 함께 보고. 다중비교 보정 방식. 금지 표현 | Inference criteria | +| `amendments` | 변경 이력 | 날짜, 변경 내용, 사유, 데이터 열람 전/후 여부 | Deviations | + +### 가정 → 검증 매핑 (자주 쓰는 것) +| 가정 | 검증 | 합격 기준 예시 | +|---|---|---| +| 평행추세 | 이벤트스터디 사전 계수의 결합검정 + 그림 | 사전 계수 결합 p ≥ 0.10, 사전 계수 최대 절대값 < 사후 효과의 50% | +| 사전 반응 없음(no anticipation) | 발표일 기준 추정과 시행일 기준 추정 비교 | 발표~시행 구간 계수가 유의하지 않음 | +| SUTVA / 파급 없음 | 대조군을 거리 링별로 나눠 추정 | 인접 링과 원거리 링의 추정치 차이가 유의하지 않음 | +| 동시 정책 없음 | 동시 정책 목록을 만들고 해당 기간을 제외하거나 통제 | 제외 전후 추정치 부호가 유지됨 | +| SCM 적합도 | 사전 RMSPE, 가중치 집중도 | 처치 단위의 사전 RMSPE가 placebo 분포 하위 50% 안에 있음 | +| 측정 일관성 | 결과 변수의 정의·단위·경계 변경 이력 점검 | 변경이 있으면 crosswalk 또는 구간 분리 | + +### 기권(abstain) 조건 작성 요령 +- **판정 가능해야 한다**: "추세가 다르면"(✕) → "사전 계수 결합검정 p < 0.10"(○) +- 최소 세트: ① 사전추세 실패 ② 처치 단위 또는 클러스터 수 부족(예: 처치 클러스터 < 2 이면 순열추론만 허용, 대조 < 5 이면 기권) ③ 결과 결측률 과다 ④ 제거할 수 없는 동시 충격 ⑤ 반증 테스트 실패(placebo 효과가 주 효과의 50% 이상). +- 기권은 실패가 아니다. 리포트에는 "무엇 때문에, 어떤 데이터가 있으면 식별 가능한지"를 적는다. + +--- + +## 2. 예시: T1 녹색교통지역 5등급 운행제한 → 도심 NO₂ + +```yaml +# cases/green_zone_seoul/plan.yaml +meta: + case_id: green_zone_seoul + version: 1 + registered_at: 2026-10-05 # 데이터 열람 전 커밋 시점 + authors: [조이름] + status: preregistered # preregistered | amended | final + +question: + text: "서울 녹색교통지역(사대문 안) 5등급 차량 운행제한은 제한 시간대(06-21시) 도심 측정소의 NO2 농도를 낮췄는가?" + estimand: ATT # 처치 측정소의 평균 처치효과 + effect_window: "2019-12 ~ 2021-12, 월별 동적효과 + 기간 평균" + hypothesis: "NO2 감소(단측 아님, 양측 검정)" + +unit: + observation: station_hour # 측정소 × 시간 + treatment_assignment: station # 처치 배정 단위 + cluster: station + +treatment: + name: 5등급 차량 운행제한(녹색교통지역) + definition: "측정소 좌표가 녹색교통지역 경계(16.7km2) 내부" + boundary_source: "서울시 녹색교통지역 경계 (https://news.seoul.go.kr/traffic/greentraffic) - 공간파일 확보 여부 확인 필요" + timeline: + announced: null # 1주차에 서울시 보도자료로 확정 + pilot_start: 2019-07 + enforcement_start: 2019-12-01 # 과태료 부과 시작(월 확인됨, 일자는 확인 필요) + intensity_change: 2021-01-01 # 저감장치 미개발 차종 유예 종료(2020-12-31) + hours_active: "06:00-21:00, 주말·공휴일 포함" + +control: + pool: "서울 내 녹색교통지역 밖 도로변대기·도시대기 측정소" + exclude: + - rule: "녹색교통지역 경계로부터 1km 이내 측정소" + reason: "우회 교통 파급(SUTVA)" + fixed_before_data: true + +period: + pre: [2017-01-01, 2019-06-30] + pilot: [2019-07-01, 2019-11-30] # 추정에서 제외, 사전 반응 점검용 + post: [2019-12-01, 2021-12-31] + excluded: [] # 코로나는 제외하지 않고 민감도 분석으로 처리 + +outcome: + primary: + name: no2_ppm + source: "에어코리아 최종확정 측정자료 (https://www.airkorea.or.kr/web/last_amb_hour_data?pMENU_NO=123)" + aggregation: "시간값 그대로, -999는 결측" + secondary: + - {name: pm25_ugm3, note: "2차 생성 비중이 커서 효과가 희석될 것으로 예상"} + - {name: co_ppm} + +covariates: + include: + - {name: temp, source: "기상청 ASOS 서울(108) 시간자료"} + - {name: wind_speed, source: "ASOS"} + - {name: wind_dir_sector, source: "ASOS"} + - {name: humidity, source: "ASOS"} + - {name: precip, source: "ASOS"} + forbidden: ["교통량(처치의 매개변수)"] + +estimator: + primary: + method: DDD + spec: "log(no2) ~ treat_station x post x active_hour | station^hour_of_day + date^hour_of_day + weather" + library: pyfixest + se: "측정소 클러스터 + wild cluster bootstrap (처치 클러스터 수 적음)" + robustness: + - {method: DiD_event_study, note: "제한 시간대만, 월별 계수"} + - {method: SCM, library: CausalPy, unit: "처치 측정소 평균의 일평균 시계열"} + - {method: permutation_inference, note: "대조 측정소에 가짜 처치를 배정해 순위 p값"} + +assumptions: + - id: parallel_trends + check: "이벤트스터디 사전 24개월 계수 결합검정 + 그림" + pass_if: "joint p >= 0.10" + - id: no_anticipation + check: "시범운영 기간(2019-07~11) 계수" + pass_if: "유의하지 않거나 본 효과의 50% 미만" + - id: no_spillover + check: "1~3km 링 측정소를 처치로 둔 추정" + pass_if: "링 효과가 유의하지 않음" + - id: night_hours_valid_placebo + check: "비제한 시간(21-06시)의 DiD" + pass_if: "효과가 제한 시간 효과의 1/3 미만" + note: "야간에도 5등급 차량이 줄면(차량 폐차 등) DDD는 과소추정됨 - 한계로 기술" + +concurrent_policies: + - {name: 미세먼지 계절관리제, start: 2019-12-01, scope: 전국, handling: "대조군에도 적용되므로 DiD로 상쇄. 도심 이질효과는 한계로 기술"} + - {name: 코로나19 사회적 거리두기, start: 2020-02, scope: 전국(도심 업무지구 영향 클 수 있음), handling: "2020년 제외 민감도 분석"} + - {name: 수도권 5등급 차량 계절제 운행제한, start: 2020-12, scope: "수도권 전역(계절관리제 기간)", handling: "대조군에도 적용. 발효일 확인 필요"} + +refutation: + - {type: placebo_time, spec: "2018-12-01을 가짜 시행일로 둔 추정", pass_if: "p >= 0.10"} + - {type: placebo_outcome, spec: "O3(교통 감소 시 오히려 증가할 수 있음) - 부호 점검용", pass_if: "NO2와 같은 방향의 감소가 아님"} + - {type: sample, spec: "2020년 제외 / 도로변 측정소만 / 도시대기만"} + +abstain_if: + conditions: + - "treated_stations < 1 after data cleaning" + - "parallel_trends.joint_p < 0.10 AND scm.pre_rmspe_rank > 0.5" + - "primary outcome missing share > 0.30 in treated stations (pre or post)" + - "placebo_time effect >= 0.5 * primary effect" + - "permutation p-value unavailable (control stations < 5)" + message: "현재 데이터로는 녹색교통지역 효과를 계절관리제·코로나 영향과 분리해 식별할 수 없습니다. 필요한 추가 자료: {missing}" + +reporting: + primary_metric: "NO2 % 변화 (exp(b)-1), 95% CI" + multiple_comparisons: "2차 결과(PM2.5, CO)에 Holm 보정" + forbidden_phrases: ["정책 덕분에", "입증되었다", "완전히 해소"] + must_include: ["사전추세 그림", "동시 정책 목록", "기권 조건 판정 결과", "데이터 출처·라이선스"] + +amendments: [] +``` + +## 3. 체크: 에이전트가 plan.yaml로 해야 하는 일 +1. `fetch.py`는 `outcome.source`와 `covariates`에 적힌 것만 받는다(데이터 스누핑 방지). +2. `estimate.py`는 `estimator.primary`를 먼저 실행하고, `assumptions`, `refutation`, `abstain_if`를 순서대로 판정한다. +3. 리포트 생성기는 `abstain_if`가 하나라도 참이면 효과 수치를 헤드라인에 올리지 않는다. +4. plan과 실제 실행이 다르면 `amendments`가 없는 한 실패 처리한다(CI). diff --git a/docs/strategy/topic-guide.md b/docs/strategy/topic-guide.md new file mode 100644 index 0000000..95266ca --- /dev/null +++ b/docs/strategy/topic-guide.md @@ -0,0 +1,115 @@ +# 주제 선정 가이드 (조별 case 후보 8선) + +> 기준: ① 처치 시점·대상이 공개 자료로 명확하다. ② 결과지표가 **오픈데이터**이고 API나 파일로 받을 수 있다. ③ 대조군을 만들 수 있다. ④ 4주 안에 fetch → estimate → report까지 갈 수 있다. +> 검증 표기: ✅ 이번에 데이터 페이지를 직접 열어 확인함 / ⚠️ 존재는 확인했지만 세부(기간·컬럼·ID)는 미확인 / ❓ 미확인이므로 조가 1주차에 확인할 것. +> 사례 근거는 [korea-cases.md](korea-cases.md) 참조. + +## 요약표 + +| ID | 주제 | 방법 | 난이도 | 추천 | +|---|---|---|---|---| +| T1 | 녹색교통지역 5등급 운행제한 → 도심 대기질 | DiD + DDD(시간대) / SCM | ★★ | **Top** | +| T2 | 대형마트 의무휴업 평일 전환 → 서울 상권 매출 | staggered DiD | ★★★ | **Top** | +| T3 | 토지거래허가구역 지정·해제·재지정 → 아파트 가격·거래량 | DiD / 이벤트스터디 | ★★ | **Top** | +| T4 | 안전속도5030 → 시군구 교통사고 | staggered DiD / ITS | ★★ | 추천 | +| T5 | 민식이법 → 스쿨존 어린이 사고 | DiD (→ 기권 시연) | ★★★ | 교육용 | +| T6 | 윤창호법 → 음주운전 사고 | 비교 ITS | ★ | 입문 | +| T7 | 지자체 출산·혼인장려금 → 출생·혼인 | staggered DiD | ★★★ | 도전 | +| T8 | 미세먼지 계절관리제 → PM2.5 | 기상보정 ITS | ★★★ | 고급(식별 약함) | + +--- + +## T1. 서울 녹색교통지역 5등급 차량 운행제한 → 도심 대기질 ★★ [Top] +- **질문**: 사대문 안 5등급 차량 운행제한이 도심 도로변 NO₂·PM2.5를 얼마나 낮췄나? +- **처치/대조**: 처치 = 녹색교통지역(종로·중구 사대문 안) 안의 도로변·도시대기 측정소. 대조 = 서울의 다른 도로변 측정소(외곽 순환로 인접 측정소는 우회 교통 효과가 있으므로 별도 표시). +- **시점**: 2019.7 시범, **2019.12 과태료 부과(처치 시작)**, 2020.12.31 유예 종료(처치 강도 증가) — [서울시](https://news.seoul.go.kr/traffic/greentraffic). 분석 창은 2017.1–2021.12. +- **결과지표**: 시간별 NO₂(1차, 자동차 배출에 민감), PM2.5(2차), 보조로 CO. +- **데이터** + - 에어코리아 최종확정 측정자료(시간별, 2001–2026, 연도별 파일) ✅ [링크](https://www.airkorea.or.kr/web/last_amb_hour_data?pMENU_NO=123) + - 측정소 정보(유형·좌표) ⚠️ [에어코리아 측정소 정보](https://www.airkorea.or.kr/web/stationInfo?pMENU_NO=93). 사대문 안 측정소 수와 위치는 ❓ + - 기상 공변량: 기상청 ASOS 시간자료(기상자료개방포털) ❓ +- **추천 방법**: 측정소×시간 패널 TWFE DiD(pyfixest; 측정소 FE + 날짜×시간 FE + 기상). **핵심 강화: 제한 시간(06–21시)과 비제한 시간(21–06시)을 비교하는 DDD.** 처치 측정소가 적으므로 SCM(CausalPy)과 순열추론(placebo-in-space)을 병행한다. +- **함정** + - **동시 정책**: 미세먼지 계절관리제가 같은 2019.12에 시작됐다. 다만 전국 정책이라 대조군에도 적용되므로 DiD로 상쇄된다. 이질적 효과(도심에서 더 강함)가 있으면 상쇄되지 않는다. + - **코로나19(2020.2~)**: 도심 업무지구의 교통량이 외곽보다 크게 줄었을 수 있다 → 2020년 제외 또는 교통량 통제로 민감도 분석. + - 처치 측정소 1~3개 → 클러스터 표준오차를 쓸 수 없다. 순열추론이 필수다. + - 우회 교통으로 대조군이 오염되는 문제(SUTVA 위반). +- **왜 Top**: 처치 경계와 날짜가 명확하고, 결과가 시간 단위 오픈데이터이며, "시간대 DDD"라는 설계 아이디어를 배우기 좋다. + +## T2. 대형마트 의무휴업 평일 전환 → 서울 상권 매출 ★★★ [Top] +- **질문**: 자치구가 대형마트 의무휴업일을 일요일에서 평일로 바꾼 뒤, 주변 전통시장·골목상권 매출이 줄었나? +- **처치/대조**: 처치 = 전환한 자치구(서초 2024.1~ [이투데이](https://www.etoday.co.kr/news/view/2313812), 동대문 [정책브리핑](https://www.korea.kr/news/policyNewsView.do?newsId=148927743), 중구 2024.11 발표 [파이낸셜뉴스](https://www.fnnews.com/news/202411151022368527) 등) 안의 전통시장·골목상권. 대조 = 아직 전환하지 않은 자치구의 같은 유형 상권. **전체 전환 일자 목록은 ❓** — 1주차에 자치구 고시·보도자료로 수집한다. +- **시점**: 2021Q1–최신 분기. +- **결과지표**: 상권별·업종별 분기 추정매출액, 주말/주중 매출 비중. +- **데이터** + - 서울시 상권분석서비스(추정매출-상권) ✅ [OA-15572](https://data.seoul.go.kr/dataList/OA-15572/S/1/datasetView.do): 분기 단위, 2021년 이후만 제공, 2024년에 공간단위가 표준단위로 바뀜, 공공누리 1유형, API 있음. + - (보조) 일반음식점 인허가 개·폐업일자 ⚠️ [data.go.kr 15045016](https://www.data.go.kr/data/15045016/fileData.do) +- **추천 방법**: Callaway–Sant'Anna staggered DiD(`differences`/`did`). 벤치마크는 KDI FOCUS 결과([KDI](https://www.kdi.re.kr/research/focusView?pub_no=19187))와 비교한다. +- **함정** + - **시차 도입** → TWFE 편향. CS·Sun-Abraham 추정량을 사용한다. + - **자기선택**: 마트 상권이 약한 구가 먼저 전환했을 수 있다 → 사전추세와 이벤트스터디로 확인. + - **측정**: 추정매출은 카드 기반 모델 추정치다. 2024년 상권 단위 변경 때문에 패널을 연결(crosswalk)해야 한다. + - 주말 매출은 요일 구성(분기별 일요일 수)에 영향을 받는다. +- **왜 Top**: 국책연구원 결과가 있어 결과를 "대조 채점"할 수 있고, 정책 논쟁이 현재 진행 중이며, 시차 DiD를 배우는 데 이상적이다. + +## T3. 토지거래허가구역 지정·해제·재지정 → 아파트 가격·거래량 ★★ [Top] +- **질문**: 토허제는 지정 지역의 거래량과 가격을 낮췄나? 인접 지역으로 풍선효과가 있었나? +- **처치/대조**: (a) 2020.6 지정된 잠실·삼성·대치·청담 vs 인접 법정동. (b) 2025.2 해제 → 2025.3 강남·서초·송파·용산 전역 재지정([KB](https://kbthink.com/main/asset-management/wealth-manage-tip/kbthink-original/202504/land-transaction-permission-areas.html), [아시아경제 연장](https://www.asiae.co.kr/article/2025091715292951954)) vs 마포·성동 등 미지정 구. 정확한 효력 발생일은 서울시 공고로 ❓ 확인한다. +- **결과지표**: 월별 거래 건수, 단지 FE를 넣은 ㎡당 실거래가(헤도닉), 신고가 비중. +- **데이터**: 국토부 아파트 매매 실거래가(상세) API ✅ [15126468](https://www.data.go.kr/data/15126468/openapi.do) — 시군구코드(5자리)×계약년월 조회, 자동승인, 개발계정 트래픽 10,000건(초과 시 활용사례를 내고 증설 신청). 법정동코드 ❓ [code.go.kr](https://www.code.go.kr). +- **추천 방법**: 법정동(또는 단지)×월 DiD + 이벤트스터디. 2025년 on/off/확대 구간은 각각 별도 이벤트로 추정한다. +- **함정** + - **표본 선택**: 거래가 급감하면 "거래된 집"만 관측된다 → 가격 효과가 편향된다. 거래량을 1차 결과로 삼고, 가격은 단지 FE와 반복매매로 보강한다. + - **동시 정책**: 2020.6.17 부동산대책(규제지역 확대), 금리 변화, 대출 규제. + - **풍선효과 = SUTVA 위반**: 인접 동을 대조군으로 쓰면 효과가 과대 추정된다 → 거리 링(ring)별로 대조군을 분리한다. + - **예상(anticipation)**: 해제·지정 발표일과 효력일 사이에 선반영된다 → 발표일 기준 분석도 병행한다. + - 계약일과 신고일의 시차(신고기한 30일)로 최근 월 자료가 과소 집계된다. + +## T4. 안전속도5030 → 시군구 교통사고 ★★ +- **질문**: 도시부 제한속도 50/30km/h 하향이 보행자 사상자를 줄였나? +- **처치/대조**: 전국 시행 2021.4.17([정책브리핑](https://www.korea.kr/news/policyNewsView.do?newsId=148891484)). 조기 도입 도시와 지방부(비도시) 도로를 대조로 쓴다. **지자체별 조기 시행 일자는 ❓** 확인 필요. +- **결과지표**: 시군구×월(또는 연) 보행자 사고·사망·중상. +- **데이터** + - TAAS 교통사고분석시스템 ⚠️ [taas.koroad.or.kr](https://taas.koroad.or.kr/) (시군구 통계 다운로드 기능의 세부 ❓) + - 도로교통공단 최근5년 교통사고 통계 ✅ [15070339](https://www.data.go.kr/data/15070339/fileData.do) (전국 연 단위, 5행 — 집계용일 뿐 패널로는 부족) + - KOROAD Open API 지자체별 대상사고통계 ⚠️ [portal](https://opendata.koroad.or.kr/api/selectSttDataSet.do) +- **추천 방법**: 포아송/음이항 FE 이벤트스터디(pyfixest `fepois`). 선행 DiD 결과([규제연구 2024](https://journal.kci.go.kr/ksrs2002/archive/articlePdf?artiId=ART003165741))와 대조한다. +- **함정**: 코로나19 교통량 변화, 민식이법(2020.3)과 겹침, 희소 사건(사망)의 검정력 부족, 제한속도 표지 교체의 점진성. **"사고다발지역" API는 결과로 선택된 표본이므로 결과지표로 쓰지 말 것**(예: 보행노인 다발지역 API는 기준 건수 이상인 지점만 수록 — [15057666](https://www.data.go.kr/en/data/15057666/openapi.do)). + +## T5. 민식이법 → 스쿨존 어린이 사고 ★★★ (교육용: 기권 시연) +- **질문**: 2020.3.25 시행된 민식이법이 어린이보호구역 어린이 사고를 줄였나? +- **처치/대조**: 스쿨존 안 vs 밖의 어린이 사고. 또는 단속카메라 설치 스쿨존 vs 미설치 스쿨존. +- **데이터**: 전국어린이보호구역표준데이터 ✅ [15012891](https://www.data.go.kr/data/15012891/standard.do) — 위경도, CCTV 설치 여부·대수, 반기 갱신. 단, **지정일·설치일 컬럼은 확인되지 않음 ❓**(시점 식별에 치명적). 사고는 TAAS ⚠️. +- **핵심 함정**: 시행 시점이 **코로나19 등교 중단(2020.3~)과 정확히 겹친다.** 어린이 통행 자체가 줄었으므로 노출량(exposure)을 통제할 수 없으면 식별이 불가능하다. 선행 DiD도 유의하지 않았다([규제연구 2024](https://journal.kci.go.kr/ksrs2002/archive/articlePdf?artiId=ART003165741)). +- **용도**: 에이전트가 **"식별 불가"를 선언하는 golden case**로 쓴다. 조 주제로 고른다면 "2021~ 등교 정상화 이후 카메라 설치 시차"로 질문을 좁혀야 한다. + +## T6. 윤창호법 → 음주운전 사고 ★ (입문) +- **질문**: 제1(2018.12.18)·제2(2019.6.25) 윤창호법이 음주운전 사고를 지속적으로 줄였나? +- **처치/대조**: 전국 단일 처치이므로 공간 대조군이 없다. 대조 시계열 = 비음주 교통사고, 또는 전년 같은 달. +- **데이터**: KOSIS/경찰청 교통사고 통계 ⚠️ ([KOSIS 교통사고 사망자 지표](https://kosis.kr/visual/nsportalStats/detailContents.do?listId=M&statJipyoId=3715&vStatJipyoId=4878)). 음주 사고 월별 표의 ID는 ❓. TAAS ⚠️. +- **방법**: 계절성을 넣은 분절회귀 ITS(statsmodels/CausalPy `InterruptedTimeSeries`) + 비교 ITS. 선행 결과(단기만 유의, [e-sir](https://www.e-sir.org/archive/view_article?pid=sir-3-2-97))를 재현한 뒤 기간을 늘려 확장한다. +- **함정**: 결과가 "적발된" 음주사고라서 **단속 강도 변화가 측정을 바꾼다**. 두 처치가 6개월 간격이라 효과가 겹친다. 2020 코로나 이후 단속 방식(비접촉 감지기)이 바뀌었다. +- **왜 입문**: 데이터 1개, 시계열 1개로 파이프라인 전체를 1주 안에 돌릴 수 있다. + +## T7. 지자체 출산·혼인장려금 → 출생·혼인 ★★★ (도전) +- **질문**: 시군구가 출산장려금(또는 혼인장려금)을 신설·인상하면 출생아 수가 늘어나나, 아니면 출생 신고지만 옮겨 오나? +- **처치/대조**: 금액 신설·인상 시군구 vs 미변경 시군구(시차 도입, 연속 처치). +- **데이터**: 결과 = KOSIS 인구동향조사 시군구별 출생아수·혼인건수 ⚠️ (통계표 ID ❓. 인구는 [DT_1B040A3](https://kosis.kr/statHtml/statHtml.do?orgId=101&tblId=DT_1B040A3)). 처치 = 지자체 조례(자치법규정보시스템) ❓ → **처치 데이터셋 구축이 과제의 절반**이다. +- **방법**: 이진 처치는 staggered DiD, 금액 변화는 연속 처치 DiD(`did` contdid 또는 dose-response). 선행 횡단면 연구([보건사회연구 2022](https://www.kihasa.re.kr/hswr/assets/pdf/1379/journal-42-4-305.pdf))와 비교해 "상관 vs 인과"를 대비시킨다. +- **함정**: 역인과(출산율이 떨어진 곳이 도입), 주소지 이전으로 인한 전입 효과(전출 지역에 음(−)의 파급), 작은 군의 분산, 2020~ 전국 현금정책(첫만남이용권 2022 등)과 겹침, 행정경계 변경(군위군 2023 대구 편입 등). + +## T8. 미세먼지 계절관리제 → PM2.5 ★★★ (고급, 식별 약함) +- **질문**: 12~3월 계절관리제가 기상 조건을 보정한 PM2.5를 낮췄나? +- **데이터**: 에어코리아 ✅(T1과 같음). 정부 추진결과 [에어코리아](https://airkorea.or.kr/portal/web/link/?pMENU_NO=143). +- **방법**: 기상 정규화(랜덤포레스트/GBM 디웨더링) 후 ITS. 대조 = 비관리 기간(4~11월)이지만 계절이 교란한다. +- **함정**: 전국 동시 시행이라 공간 대조가 없다. 국외 유입(중국 배출 변화), 코로나19, 기상 연변동이 크다. [서울신문](https://www.seoul.co.kr/news/society/enviroment/2021/04/04/20210404500043)은 "기상이 변수"라고 지적했다. +- **권고**: 단독 주제로는 비추천이다. T1의 부속 민감도 분석으로 쓴다. + +--- + +## 선택 체크리스트 (조별 1주차) +1. 처치 시점을 **날짜 단위로** 표에 적을 수 있는가? (못 적으면 탈락) +2. 결과 데이터를 실제로 1회 호출하거나 다운로드해 사전 기간 최소 8개 시점(월/분기)을 확보했는가? +3. 대조군 후보가 3개 이상이고, 사전추세 그림을 그릴 수 있는가? +4. 같은 시기의 다른 정책을 3개 이상 적었는가? (동시정책 목록) +5. "이 결과가 나오면 식별 불가라고 말한다"는 조건을 plan.yaml에 적었는가? → [plan-guide.md](plan-guide.md) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7a88ad6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "policy-effect-analytics-agent" +version = "0.1.0" +description = "Agentic AI x Data: define public problems, estimate policy effects, automate reporting (NIPA OpenUp track 3)." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "가짜연구소 Causal Inference Team" }] +dependencies = [ + "pandas>=2.1,<3", + "numpy>=1.26,<3", + "pydantic>=2.6,<3", + "pyyaml>=6.0", + "matplotlib>=3.8", + "statsmodels>=0.14", + "pyfixest>=0.60", + "scipy>=1.11", + "streamlit>=1.35", + "requests>=2.31", +] + +[project.optional-dependencies] +agent = ["langgraph>=0.2", "langchain-core>=0.3"] +causal = ["dowhy>=0.12"] +dev = ["pytest>=8", "ruff>=0.6", "pre-commit>=3.7"] + +[project.urls] +Repository = "https://github.com/CausalInferenceLab/policy-effect-analytics-agent" + +[tool.hatch.build.targets.wheel] +packages = ["core"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +extend-exclude = ["data", ".venv"] + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-q" diff --git a/scripts/weekly_activity.py b/scripts/weekly_activity.py new file mode 100644 index 0000000..85ba521 --- /dev/null +++ b/scripts/weekly_activity.py @@ -0,0 +1,101 @@ +"""Print commits and files changed per cases/ over the last N days. + +Uses local `git log` only (no network). Run `git fetch --all` first to include +branches that have not been merged yet. + + python scripts/weekly_activity.py # last 7 days, all refs + python scripts/weekly_activity.py --days 14 --ref origin/main +""" + +from __future__ import annotations + +import argparse +import subprocess +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +SEP = "\x1e" + + +@dataclass +class Stat: + commits: set[str] = field(default_factory=set) + authors: set[str] = field(default_factory=set) + files: set[str] = field(default_factory=set) + added: int = 0 + deleted: int = 0 + + +def area_of(path: str) -> str: + parts = path.split("/") + if parts[0] == "cases" and len(parts) > 2: + return f"cases/{parts[1]}" + return parts[0] if len(parts) > 1 else "(root)" + + +def collect(repo: Path, days: int, ref: str | None) -> dict[str, Stat]: + cmd = [ + "git", + "-C", + str(repo), + "log", + f"--since={days}.days.ago", + "--no-merges", + "--numstat", + f"--format={SEP}%h\t%an", + ] + cmd += [ref] if ref else ["--all"] + out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout + + stats: dict[str, Stat] = defaultdict(Stat) + for block in out.split(SEP)[1:]: + lines = block.strip().splitlines() + if not lines: + continue + sha, _, author = lines[0].partition("\t") + for line in lines[1:]: + cols = line.split("\t") + if len(cols) != 3: + continue + add, delete, path = cols + if " => " in path: # rename: keep destination + path = path.split(" => ")[-1].strip("{}") + s = stats[area_of(path)] + s.commits.add(sha) + s.authors.add(author) + s.files.add(path) + s.added += int(add) if add.isdigit() else 0 + s.deleted += int(delete) if delete.isdigit() else 0 + return dict(stats) + + +def main(argv: list[str] | None = None) -> None: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[1]) + ap.add_argument("--days", type=int, default=7) + ap.add_argument("--ref", default=None, help="branch/ref to scan (default: all refs)") + args = ap.parse_args(argv) + + stats = collect(args.repo, args.days, args.ref) + groups = sorted( + p.name + for p in (args.repo / "cases").glob("*") + if p.is_dir() and not p.name.startswith(("_", ".")) + ) + for g in groups: # show silent groups explicitly + stats.setdefault(f"cases/{g}", Stat()) + + print(f"# Activity, last {args.days} days ({args.ref or 'all refs'})") + print(f"{'area':<36}{'commits':>8}{'authors':>8}{'files':>7}{'+/-':>14}") + for area in sorted(stats, key=lambda a: (not a.startswith("cases/"), a)): + s = stats[area] + flag = " <- no activity" if not s.commits else "" + print( + f"{area:<36}{len(s.commits):>8}{len(s.authors):>8}{len(s.files):>7}" + f"{f'+{s.added}/-{s.deleted}':>14}{flag}" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/core/conftest.py b/tests/core/conftest.py new file mode 100644 index 0000000..480e823 --- /dev/null +++ b/tests/core/conftest.py @@ -0,0 +1,5 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) # pyproject 설치 전에도 `import core` 가능하도록 diff --git a/tests/core/test_estimators.py b/tests/core/test_estimators.py new file mode 100644 index 0000000..14366be --- /dev/null +++ b/tests/core/test_estimators.py @@ -0,0 +1,63 @@ +import numpy as np +import pandas as pd +import pytest + +from core.adapters import simulate_panel +from core.estimators import apply_abstention, did, event_study, its, placebo_time + +KW = dict(y="y", unit="region_id", time="year", group_col="treated", treat_time=2016) +RULES = [ + {"when": "pretrend_rejected", "verdict": "not_identified"}, + {"when": "staggered_adoption", "verdict": "not_identified"}, + {"when": "few_clusters", "verdict": "conditional"}, +] + + +@pytest.mark.parametrize("seed", [0, 1, 2]) +def test_did_recovers_true_effect(seed): + df = simulate_panel(effect=-5.0, seed=seed) + r = did(df, **KW) + assert r.ci_low <= -5.0 <= r.ci_high + assert r.warnings == [] and r.n_clusters == 80 + + +def test_event_study_clean_passes_pretrend(): + r = event_study(simulate_panel(effect=-5.0, seed=3), **KW) + assert r.assumptions_checked["parallel_pretrends"]["passed"] + assert r.ci_low <= -5.0 <= r.ci_high + assert apply_abstention(r, RULES).verdict == "identified" + assert set(r.extra["coefs"]["rel_time"]) == set(range(-4, 4)) + + +def test_pretrend_violation_triggers_warning_and_abstention(): + df = simulate_panel(effect=0.0, pretrend_slope=1.5, seed=4) + r = event_study(df, **KW) + assert r.assumptions_checked["parallel_pretrends"]["p_value"] < 0.1 + assert "pretrend_rejected" in r.triggers + assert apply_abstention(r, RULES).verdict == "not_identified" + assert not r.identified + assert not placebo_time(df, **KW)["passed"] + + +def test_few_clusters_warning(): + r = did(simulate_panel(n_units=12, n_treated=5, seed=5), **KW) + assert "few_clusters" in r.triggers + assert apply_abstention(r, RULES).verdict == "conditional" + + +def test_staggered_adoption_warning(): + df = simulate_panel(staggered=True, seed=6) + r = did( + df, y="y", unit="region_id", time="year", group_col="treated", first_treat_col="first_treat" + ) + assert "staggered_adoption" in r.triggers + assert apply_abstention(r, RULES).verdict == "not_identified" + + +def test_its_recovers_level_change(): + rng = np.random.default_rng(7) + t = np.arange(48) + y = 100 + 0.3 * t - 8.0 * (t >= 30) + rng.normal(0, 1, 48) + r = its(pd.DataFrame({"m": t, "y": y}), "y", "m", treat_time=30) + assert r.ci_low <= -8.0 <= r.ci_high + assert any("통제집단" in w for w in r.warnings) diff --git a/tests/core/test_plan_and_case.py b/tests/core/test_plan_and_case.py new file mode 100644 index 0000000..c2411d5 --- /dev/null +++ b/tests/core/test_plan_and_case.py @@ -0,0 +1,50 @@ +import copy +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from core.agent import validate_plan +from core.schema.plan import Plan, load_plan + +ROOT = Path(__file__).resolve().parents[2] + +CASE = ROOT / "cases" / "_example_night_clinic" + + +def _raw(): + return yaml.safe_load((CASE / "plan.yaml").read_text(encoding="utf-8")) + + +def test_example_plan_is_valid(): + p = load_plan(CASE / "plan.yaml") + assert p.synthetic_data and p.primary_outcome.col == "night_ed_rate" + assert validate_plan(str(CASE / "plan.yaml"))["ok"] + + +@pytest.mark.parametrize( + "mutate", + [ + lambda d: d.pop("abstention"), # 보류 조건 필수 + lambda d: d["estimator"].update(method="magic"), # 알 수 없는 방법 + lambda d: d["data_sources"][0].update(license="free"), # 라이선스 enum + lambda d: d.update(synthetic_data=False), # 합성 플래그 불일치 + lambda d: d["treatment"].pop("treat_time"), # 도입 시점 없음 + lambda d: d.update(typo_field=1), # 미정의 필드 + ], +) +def test_invalid_plans_rejected(mutate): + d = copy.deepcopy(_raw()) + mutate(d) + with pytest.raises(ValidationError): + Plan.model_validate(d) + + +def test_example_case_runs_end_to_end(tmp_path): + for script in ("fetch.py", "estimate.py"): + subprocess.run([sys.executable, str(CASE / script)], check=True, cwd=ROOT) + report = (CASE / "report.md").read_text(encoding="utf-8") + assert "합성(synthetic)" in report and (CASE / "figures" / "event_study.png").exists() diff --git a/tests/test_repo_layout.py b/tests/test_repo_layout.py new file mode 100644 index 0000000..364602c --- /dev/null +++ b/tests/test_repo_layout.py @@ -0,0 +1,93 @@ +"""Smoke tests for repo scaffolding: template, app discovery, activity script. No network.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATE = ROOT / "cases" / "_template" + +# Keys shared by the template skeleton and core.schema.plan.Plan. +REQUIRED_PLAN_KEYS = { + "title", + "question", + "unit", + "treatment", + "control", + "outcomes", + "estimator", + "assumptions", + "data_sources", +} + + +def _load(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod # dataclasses need the module registered + spec.loader.exec_module(mod) + return mod + + +def test_template_files_exist(): + for name in ["plan.yaml", "fetch.py", "estimate.py", "report.md", "README.md"]: + assert (TEMPLATE / name).is_file(), name + + +def test_template_plan_has_fields_and_licenses(): + plan = yaml.safe_load((TEMPLATE / "plan.yaml").read_text(encoding="utf-8")) + assert REQUIRED_PLAN_KEYS <= plan.keys() + assert all(src.get("license") for src in plan["data_sources"]) + + +def test_template_validates_against_core_schema(): + pytest.importorskip("pydantic") + try: + from core.schema.plan import load_plan + except ImportError: + pytest.skip("core.schema.plan not available") + load_plan(TEMPLATE / "plan.yaml") + + +def test_template_scripts_import(): + _load(TEMPLATE / "fetch.py", "tmpl_fetch") + _load(TEMPLATE / "estimate.py", "tmpl_estimate") + + +def test_app_discovers_cases(tmp_path): + app = _load(ROOT / "app" / "streamlit_app.py", "streamlit_app") + case = tmp_path / "group1-demo" + (case / "figures").mkdir(parents=True) + (case / "plan.yaml").write_text("title: Demo\nquestion: q?\n", encoding="utf-8") + (case / "report.md").write_text("# Demo", encoding="utf-8") + (case / "figures" / "effect.png").write_bytes(b"") + (tmp_path / "_template").mkdir() + (tmp_path / "_template" / "plan.yaml").write_text("title: T\n", encoding="utf-8") + + cases = app.discover_cases(tmp_path) + assert [c.slug for c in cases] == ["group1-demo"] + assert cases[0].title == "Demo" and cases[0].report and len(cases[0].figures) == 1 + assert len(app.discover_cases(tmp_path, include_templates=True)) == 2 + # the real repo has at least the template + assert app.discover_cases(include_templates=True) + + +def test_weekly_activity_runs_on_temp_repo(tmp_path): + act = _load(ROOT / "scripts" / "weekly_activity.py", "weekly_activity") + git = ["git", "-C", str(tmp_path), "-c", "user.name=t", "-c", "user.email=t@t"] + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + f = tmp_path / "cases" / "group1-demo" / "plan.yaml" + f.parent.mkdir(parents=True) + f.write_text("a: 1\n") + subprocess.run([*git, "add", "."], check=True) + subprocess.run([*git, "commit", "-qm", "plan(group1-demo): init"], check=True) + + stats = act.collect(tmp_path, days=7, ref=None) + assert len(stats["cases/group1-demo"].commits) == 1 + assert stats["cases/group1-demo"].added == 1