From 9983f0edd48006cb42e6bd9253f4a205a94f06b7 Mon Sep 17 00:00:00 2001 From: Jinsoo Shin <59598545+jsshin2022@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:13:18 +0900 Subject: [PATCH] chore: initial project scaffold (core pipeline, case template, app, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/: plan schema (pydantic), DiD/event-study/ITS estimators with pre-trend, placebo and abstention verdicts, adapters (KOSIS, local, synthetic), report - cases/_template + cases/_example_night_clinic (SYNTHETIC data demo) - app/streamlit_app.py case browser - docs/strategy (국내 사례, 주제 가이드, plan 작성 가이드), docs/ops (onboarding, monitoring) - CI (ruff + pytest), PR/issue templates, CODEOWNERS placeholder Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EMnmZPBD5PQJW1UQAARN7q --- .env.example | 15 + .github/CODEOWNERS | 27 + .github/ISSUE_TEMPLATE/bug.yml | 30 + .github/ISSUE_TEMPLATE/case-proposal.yml | 80 +++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/PULL_REQUEST_TEMPLATE.md | 16 + .github/workflows/ci.yml | 40 ++ .gitignore | 41 ++ .pre-commit-config.yaml | 14 + CODE_OF_CONDUCT.md | 14 + CONTRIBUTING.md | 66 ++ Makefile | 23 + README.md | 85 +++ app/streamlit_app.py | 149 ++++ cases/_example_night_clinic/README.md | 31 + cases/_example_night_clinic/data/README.md | 14 + cases/_example_night_clinic/data/panel.csv | 641 ++++++++++++++++++ .../data/panel.source.json | 17 + cases/_example_night_clinic/estimate.py | 78 +++ cases/_example_night_clinic/fetch.py | 45 ++ .../figures/event_study.png | Bin 0 -> 32706 bytes .../figures/raw_trends.png | Bin 0 -> 70923 bytes cases/_example_night_clinic/plan.yaml | 73 ++ cases/_example_night_clinic/report.md | 45 ++ cases/_example_night_clinic/results.json | 113 +++ cases/_template/README.md | 29 + cases/_template/estimate.py | 44 ++ cases/_template/fetch.py | 52 ++ cases/_template/plan.yaml | 75 ++ cases/_template/report.md | 22 + core/REQUIREMENTS.md | 41 ++ core/__init__.py | 5 + core/adapters/__init__.py | 19 + core/adapters/base.py | 61 ++ core/adapters/kosis.py | 77 +++ core/adapters/local.py | 28 + core/adapters/synthetic.py | 59 ++ core/agent/__init__.py | 31 + core/estimators/__init__.py | 8 + core/estimators/diagnostics.py | 68 ++ core/estimators/did.py | 201 ++++++ core/estimators/its.py | 80 +++ core/estimators/result.py | 64 ++ core/pipeline.py | 56 ++ core/report/__init__.py | 4 + core/report/markdown.py | 78 +++ core/report/plots.py | 79 +++ core/schema/__init__.py | 0 core/schema/plan.py | 167 +++++ docs/ops/github-onboarding.md | 84 +++ docs/ops/monitoring.md | 63 ++ docs/strategy/korea-cases.md | 117 ++++ docs/strategy/plan-guide.md | 164 +++++ docs/strategy/topic-guide.md | 115 ++++ pyproject.toml | 49 ++ scripts/weekly_activity.py | 101 +++ tests/core/conftest.py | 5 + tests/core/test_estimators.py | 63 ++ tests/core/test_plan_and_case.py | 50 ++ tests/test_repo_layout.py | 93 +++ 60 files changed, 3917 insertions(+) create mode 100644 .env.example create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/case-proposal.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/streamlit_app.py create mode 100644 cases/_example_night_clinic/README.md create mode 100644 cases/_example_night_clinic/data/README.md create mode 100644 cases/_example_night_clinic/data/panel.csv create mode 100644 cases/_example_night_clinic/data/panel.source.json create mode 100644 cases/_example_night_clinic/estimate.py create mode 100644 cases/_example_night_clinic/fetch.py create mode 100644 cases/_example_night_clinic/figures/event_study.png create mode 100644 cases/_example_night_clinic/figures/raw_trends.png create mode 100644 cases/_example_night_clinic/plan.yaml create mode 100644 cases/_example_night_clinic/report.md create mode 100644 cases/_example_night_clinic/results.json create mode 100644 cases/_template/README.md create mode 100644 cases/_template/estimate.py create mode 100644 cases/_template/fetch.py create mode 100644 cases/_template/plan.yaml create mode 100644 cases/_template/report.md create mode 100644 core/REQUIREMENTS.md create mode 100644 core/__init__.py create mode 100644 core/adapters/__init__.py create mode 100644 core/adapters/base.py create mode 100644 core/adapters/kosis.py create mode 100644 core/adapters/local.py create mode 100644 core/adapters/synthetic.py create mode 100644 core/agent/__init__.py create mode 100644 core/estimators/__init__.py create mode 100644 core/estimators/diagnostics.py create mode 100644 core/estimators/did.py create mode 100644 core/estimators/its.py create mode 100644 core/estimators/result.py create mode 100644 core/pipeline.py create mode 100644 core/report/__init__.py create mode 100644 core/report/markdown.py create mode 100644 core/report/plots.py create mode 100644 core/schema/__init__.py create mode 100644 core/schema/plan.py create mode 100644 docs/ops/github-onboarding.md create mode 100644 docs/ops/monitoring.md create mode 100644 docs/strategy/korea-cases.md create mode 100644 docs/strategy/plan-guide.md create mode 100644 docs/strategy/topic-guide.md create mode 100644 pyproject.toml create mode 100644 scripts/weekly_activity.py create mode 100644 tests/core/conftest.py create mode 100644 tests/core/test_estimators.py create mode 100644 tests/core/test_plan_and_case.py create mode 100644 tests/test_repo_layout.py 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 0000000000000000000000000000000000000000..56a39d60e39c7a9675a6f7cbaec2d3c70ff8362b GIT binary patch literal 32706 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2e5267697bba5dc90232b82c39a762306c30d4d0 GIT binary patch literal 70923 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

FbUO!LpDC?~Hh)A( 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 + 달빛어린이병원(야간·휴일 소아 경증 진료기관)이 지정된 시군구에서, + 지정되지 않은 시군구 대비 소아 야간 경증 응급실 방문율이 감소했는가? +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