From 0e9570f1f745fa79b42366e581270e928bb1a41d Mon Sep 17 00:00:00 2001 From: Jinsoo Shin <59598545+jsshin2022@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:06:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(discovery):=20issue=20=E2=86=92=20poli?= =?UTF-8?q?cy=20=E2=86=92=20dataset=20=E2=86=92=20effect,=20with=20T3=20sa?= =?UTF-8?q?mple=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/discovery: match social text to catalog policies (keyword score, optional LLM pick among candidates), recommend curated datasets plus optional live data.go.kr search; design comes from the catalog, not the LLM - catalog/policies.yaml: 8 policies with dates, datasets, licenses, pitfalls - cases/t3-land-permit-2025: pre-registered plan (2025.3.24 expansion, 25 Seoul districts x month, 2024-01..2025-09); simulation until an API key is set; MOLIT real-transaction adapter (not yet verified live) - estimators: randomization inference when treated units < 10. With 4 of 25 treated, CRV1 pre-trend tests rejected a true null 50-80% of the time; RI brings 95% CI coverage to ~95% in simulation - app: "이슈 → 데이터 → 효과" page Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EMnmZPBD5PQJW1UQAARN7q --- README.md | 17 + app/pages/0_Issue_to_Effect.py | 158 ++++ cases/t3-land-permit-2025/README.md | 32 + cases/t3-land-permit-2025/fetch.py | 77 ++ cases/t3-land-permit-2025/plan.yaml | 88 ++ catalog/policies.yaml | 187 +++++ core/adapters/__init__.py | 9 +- core/adapters/molit.py | 111 +++ core/agent/guard.py | 1 + core/agent/nodes.py | 10 +- core/discovery/__init__.py | 29 + core/discovery/catalog.py | 72 ++ core/discovery/match.py | 70 ++ core/discovery/pipeline.py | 51 ++ core/discovery/search.py | 97 +++ core/estimators/did.py | 61 +- core/estimators/ri.py | 134 +++ core/report/plots.py | 9 +- core/schema/plan.py | 1 + tests/core/test_ri.py | 38 + tests/discovery/test_discovery.py | 36 + tests/fixtures/datago/search_apt_trade.html | 874 ++++++++++++++++++++ tests/test_app_issue.py | 27 + 23 files changed, 2176 insertions(+), 13 deletions(-) create mode 100644 app/pages/0_Issue_to_Effect.py create mode 100644 cases/t3-land-permit-2025/README.md create mode 100644 cases/t3-land-permit-2025/fetch.py create mode 100644 cases/t3-land-permit-2025/plan.yaml create mode 100644 catalog/policies.yaml create mode 100644 core/adapters/molit.py create mode 100644 core/discovery/__init__.py create mode 100644 core/discovery/catalog.py create mode 100644 core/discovery/match.py create mode 100644 core/discovery/pipeline.py create mode 100644 core/discovery/search.py create mode 100644 core/estimators/ri.py create mode 100644 tests/core/test_ri.py create mode 100644 tests/discovery/test_discovery.py create mode 100644 tests/fixtures/datago/search_apt_trade.html create mode 100644 tests/test_app_issue.py diff --git a/README.md b/README.md index 67a8548..e94d302 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ cases/ <조-주제>/ 조별 케이스 (plan.yaml, fetch.py, estimate.py, report.md, figures/) app/streamlit_app.py 케이스 브라우저 (API 키 없이 실행) docs/ops/ 조별 운영 가이드, GitHub 온보딩, 모니터링 +catalog/ 정책 × 데이터셋 카탈로그 (이슈 → 데이터 연결) docs/strategy/ 문제 정의·전략 문서 scripts/ 운영 스크립트 (weekly_activity.py 등) tests/ 테스트 @@ -74,6 +75,22 @@ cp -r cases/_template cases/group3-youth-rent # 폴더명: <조>-<주제>, 소 | 6 | 리포트·플랫폼 | `report.md`, Streamlit 반영 | | 7 | 발표·회고·공개 정리 | 최종 PR 머지, 릴리스 태그 | +## 이슈 → 데이터 → 효과 + +소셜 반응(뉴스 제목·SNS 글) 한 건에서 출발해 정책을 찾고, 공공데이터를 추천하고, 효과 분석까지 잇습니다. + +1. **정책 식별**: `catalog/policies.yaml`(정책 × 데이터셋 카탈로그)에서 키워드로 찾습니다. LLM은 후보 중에서 고르는 보조 역할만 합니다. +2. **데이터셋 추천**: 카탈로그에 검증해 둔 데이터셋과 공공데이터포털 실시간 검색 결과를 보여줍니다. +3. **분석 설계**: 카탈로그의 설계(이중차분·합성통제·단절 시계열)로 정합니다. LLM이 고르지 않습니다. +4. **효과 분석**: 사전 등록된 `plan.yaml`로 아래 Flow를 실행합니다. + +```bash +make app # 사이드바 '이슈 → 데이터 → 효과' +python -c "from core.discovery import discover; r=discover('토허제 확대하고 집값 잡혔나'); print(r.top.name, r.next_step)" +``` + +샘플: [`cases/t3-land-permit-2025`](cases/t3-land-permit-2025/) (현재 시뮬레이션 데이터, API 키 발급 후 실데이터로 전환) + ## Flow — 6단계 에이전트 흐름 `core.agent`가 케이스 하나를 아래 6단계로 실행하고 `cases/<케이스>/flow_log.json`에 단계별 결과를 남깁니다. 앱의 **Flow** 페이지(`make app` → 사이드바 Flow)에서 실행하거나 저장된 로그를 볼 수 있습니다. diff --git a/app/pages/0_Issue_to_Effect.py b/app/pages/0_Issue_to_Effect.py new file mode 100644 index 0000000..55a21f2 --- /dev/null +++ b/app/pages/0_Issue_to_Effect.py @@ -0,0 +1,158 @@ +"""이슈 → 데이터 → 효과: 소셜 반응 한 건을 정책·데이터셋·효과 분석까지 잇는 화면.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import streamlit as st + +APP_DIR = Path(__file__).resolve().parents[1] +for p in (APP_DIR, APP_DIR.parent): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +import flow_view as fv # noqa: E402 + +from core.discovery import discover # noqa: E402 + +EXAMPLES = { # 직접 작성한 예시 문장 (실제 게시글 인용 아님) + "부동산": "토허제 강남3구·용산까지 확대하고 나서 집값 진짜 잡힌 거 맞음? 옆 동네만 올랐다는 얘기도 있던데", + "교통안전": "민식이법 시행되고 스쿨존 어린이 사고가 줄었다는 게 사실인가요?", + "속도제한": "5030 속도 줄이고 나서 보행자 교통사고 줄었다는데 체감이 안 됨", + "대기질": "사대문 안 5등급 차 운행제한하고 도심 공기 좋아졌나?", +} + +st.set_page_config(page_title="이슈 → 데이터 → 효과", layout="wide") +st.title("이슈 → 데이터 → 효과") +st.caption( + "소셜 반응(뉴스 제목·SNS 글)에서 정책을 찾고 → 공공데이터를 추천하고 → 효과를 분석합니다." +) + +with st.sidebar: + st.markdown("**예시 입력** (직접 작성한 문장)") + for label, text in EXAMPLES.items(): + if st.button(label, use_container_width=True): + st.session_state["issue"] = text + live = st.checkbox( + "공공데이터포털 실시간 검색", + value=False, + help="data.go.kr 검색 결과를 함께 보여줍니다(네트워크 필요).", + ) + use_llm = st.toggle("LLM 보조", value=False, disabled=not fv.llm_available()) + +text = st.text_area( + "① 소셜 반응", key="issue", height=90, placeholder="예: 토허제 확대하고 강남 집값 잡혔나요?" +) +if not text.strip(): + st.info("왼쪽 예시를 누르거나 뉴스 제목·SNS 글을 붙여 넣으세요.") + st.stop() + +res = discover(text, live_search=live, use_llm=use_llm) +if not res.matches: + st.warning(res.next_step) + st.stop() + +# ② 정책 식별 ------------------------------------------------------------------------- +st.subheader("② 정책 식별") +idx = st.radio( + "후보", + range(len(res.matches)), + format_func=lambda i: ( + f"{res.matches[i].policy.id} · {res.matches[i].policy.name} (점수 {res.matches[i].score:.0f})" + ), + horizontal=True, + label_visibility="collapsed", +) +m = res.matches[idx] +p = m.policy +c = st.columns([3, 2]) +with c[0]: + st.markdown(f"**{p.name}** \n{p.summary}") + st.caption(m.reason) + if p.sources: + st.markdown("출처: " + " · ".join(f"[{i + 1}]({u})" for i, u in enumerate(p.sources))) +with c[1]: + dates = [ + f"발표 {p.announced}" if p.announced else "", + f"시행 {p.effective}" if p.effective else "", + ] + st.markdown(f"**시점** {' / '.join(d for d in dates if d) or '확인 필요'}") + st.markdown(f"**분석 단위** {p.unit}") + st.markdown( + f"**설계** {p.design_ko} — " + + (f":green[{p.support}]" if p.support == "지원" else f":orange[{p.support}]") + ) +if p.pitfalls: + with st.expander(f"식별상 함정 {len(p.pitfalls)}개", expanded=True): + for x in p.pitfalls: + st.markdown(f"- {x}") + +# ③ 데이터셋 추천 --------------------------------------------------------------------- +st.subheader("③ 추천 데이터셋") +access_ko = {"api_key": "API 키(자동승인)", "file": "파일", "manual": "수동 수집"} +st.dataframe( + [ + { + "데이터셋": d.name, + "제공": d.provider, + "용도": {"outcome": "결과", "treatment": "처치", "covariate": "공변량"}[d.role], + "단위": d.granularity, + "접근": access_ko[d.access], + "라이선스": d.license, + "링크": d.url, + } + for d in p.datasets + ], + column_config={"링크": st.column_config.LinkColumn()}, + hide_index=True, + use_container_width=True, +) +if live and idx == 0: + st.caption(res.live_status) + if res.live_hits: + st.dataframe( + [ + { + "공공데이터포털 검색": h.title, + "유형": h.kind, + "제공": h.provider, + "수정일": h.modified, + "링크": h.url, + } + for h in res.live_hits + ], + column_config={"링크": st.column_config.LinkColumn()}, + hide_index=True, + use_container_width=True, + ) + +# ④ 분석 ------------------------------------------------------------------------------ +st.subheader("④ 효과 분석") +case = fv.ROOT / p.sample_case if p.sample_case else None +if case is None or not (case / "plan.yaml").exists(): + st.info( + res.next_step + if idx == 0 + else "이 정책은 아직 사전 등록된 분석계획이 없습니다. cases/_template 으로 시작하세요." + ) + st.stop() + +plan = fv.load_plan_dict(case) +st.markdown( + f"사전 등록된 분석계획: `{p.sample_case}/plan.yaml` \n> {plan.get('question', '').strip()}" +) +run = st.button("효과 분석 실행", type="primary") +log = None +if run: + with st.spinner("수집 → 품질 점검 → 추정(무작위화 추론) → 가드 → 리포트 ..."): + try: + log = fv.run_flow_for(case, allow_uncommitted=False, use_llm=use_llm) + except Exception as exc: # noqa: BLE001 + st.exception(exc) +else: + log = fv.load_flow_log(case) + if log: + st.caption("저장된 실행 결과를 표시합니다. 다시 돌리려면 '효과 분석 실행'.") +if log: + fv.render_flow(st, case, log, plan) diff --git a/cases/t3-land-permit-2025/README.md b/cases/t3-land-permit-2025/README.md new file mode 100644 index 0000000..5e8edd9 --- /dev/null +++ b/cases/t3-land-permit-2025/README.md @@ -0,0 +1,32 @@ +# T3. 토지거래허가구역 확대 지정(2025.3.24) → 아파트 거래 + +> ⚠️ **현재 결과는 시뮬레이션 데이터입니다.** API 키 발급 전 파이프라인 시연용이며, 수치는 실제 정책 효과가 아닙니다. + +**흐름**: 소셜 반응("토허제 확대하고 집값 잡혔나?") → `catalog/policies.yaml` T3 → 이 분석계획 → Flow ①~⑥ + +| 항목 | 내용 | +|---|---| +| 처치 | 서초·강남·송파·용산구 (2025.3.24 전역 지정, 첫 처치 월 2025-04) | +| 대조 | 서울 나머지 21개 자치구 | +| 기간 | 2024-01 ~ 2025-09 (2025-03 전환월 제외, 2025.10.20 서울 전역 지정 이전까지) | +| 결과 | 아파트 매매 거래건수(로그, 1차), ㎡당 가격 중위값(로그) | +| 추정 | 이벤트 스터디 + **무작위화 추론** (처치 구가 4개뿐이라 군집-강건 표준오차를 쓰지 않음) | + +## 실행 + +```bash +make flow CASE=cases/t3-land-permit-2025 # 또는 앱의 '이슈 → 데이터 → 효과' 화면 +``` + +## 실데이터로 전환 + +1. [공공데이터포털 15126468](https://www.data.go.kr/data/15126468/openapi.do)에서 활용신청(자동승인) → `.env`에 `DATA_GO_KR_API_KEY` +2. `plan.yaml`에서 `data_sources` 순서를 바꿔 `molit`을 첫 번째로, `synthetic_data: false` → 이 변경만 PR로 커밋 +3. `rm data/panel.csv && make flow CASE=cases/t3-land-permit-2025` + +`core/adapters/molit.py`는 공식 명세대로 작성했지만 아직 실제 호출로 검증하지 않았습니다. 첫 실행 때 응답 필드명을 확인하세요. + +## 주의할 점 +- 2025.2.12 잠실·삼성·대치·청담 해제 직후 거래가 급등했습니다. 이 선반영은 사전추세 검정과 가짜 시점 검정으로 확인합니다. +- 마포·성동 등 인접 구로 수요가 옮겨 가면 대조군이 오염되어 효과가 과대 추정됩니다. +- 거래 신고 기한이 30일이라 최근 월은 과소 집계됩니다. diff --git a/cases/t3-land-permit-2025/fetch.py b/cases/t3-land-permit-2025/fetch.py new file mode 100644 index 0000000..a210011 --- /dev/null +++ b/cases/t3-land-permit-2025/fetch.py @@ -0,0 +1,77 @@ +"""② 수집: 서울 25개 자치구 × 월(2024-01 ~ 2025-09) 패널 → data/panel.csv + +- plan.yaml 의 첫 번째 data_source 가 molit 이고 DATA_GO_KR_API_KEY 가 있으면 실데이터를 받는다. +- 그렇지 않으면 **시뮬레이션** 패널을 만든다(실데이터 아님, 파이프라인 시연용). +- 2025-03(month_idx 14)은 지정 효력일(3.24)이 월 중간이라 전환월로 보고 뺀다. +""" + +import os +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import yaml + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[1])) + +from core.adapters.base import SourceMeta # noqa: E402 +from core.adapters.molit import SEOUL_GU, MolitAptTradeAdapter, to_gu_month_panel # noqa: E402 +from core.adapters.synthetic import SyntheticAdapter # noqa: E402 + +START, END = "2024-01", "2025-09" +TREATED = {"11650", "11680", "11710", "11170"} # 서초·강남·송파·용산 +TRANSITION = 14 # 2025-03 + + +def simulate(seed: int = 7, effect: float = -0.40) -> pd.DataFrame: + """자치구 고정효과 + 공통 월 충격 + 처치 후 거래량 효과(임의값). 가격 효과는 0.""" + rng = np.random.default_rng(seed) + months = pd.period_range(START, END, freq="M") + shock = np.cumsum(rng.normal(0.02, 0.05, len(months))) + rows = [] + for code in SEOUL_GU: + a_t, a_p = rng.normal(5.0, 0.5), rng.normal(16.3, 0.3) + for i, m in enumerate(months): + d = code in TREATED and i >= 15 + rows.append( + { + "gu_code": code, + "ym": m.strftime("%Y%m"), + "month_idx": i, + "log_trades": a_t + shock[i] + effect * d + rng.normal(0, 0.08), + "log_price_m2": a_p + 0.3 * shock[i] + rng.normal(0, 0.02), + } + ) + return pd.DataFrame(rows) + + +def main(): + plan = yaml.safe_load((HERE / "plan.yaml").read_text(encoding="utf-8")) + real = plan["data_sources"][0].get("adapter") == "molit" and os.getenv("DATA_GO_KR_API_KEY") + if real: + ad = MolitAptTradeAdapter() + panel = to_gu_month_panel(ad.fetch(START, END), START) + panel["log_trades"] = np.log1p(panel["trades"]) + panel["log_price_m2"] = np.log(panel["price_m2_median"]) + meta, query = ad.meta, {"start": START, "end": END} + else: + panel = simulate() + meta = SourceMeta( + "시뮬레이션 자치구×월 패널 (실데이터 아님)", "core.adapters.synthetic", "synthetic" + ) + query = {"seed": 7, "note": "API 키 없음 → 시뮬레이션"} + panel["gu_name"] = panel["gu_code"].map(SEOUL_GU) + panel["treated"] = panel["gu_code"].isin(TREATED).astype(int) + panel = panel[panel["month_idx"] != TRANSITION].sort_values(["gu_code", "month_idx"]) + ad = SyntheticAdapter() + ad.meta = meta + out = ad.save(panel.reset_index(drop=True), HERE / "data" / "panel.csv", query) + print( + f"{'실데이터' if real else '시뮬레이션'} 패널 저장: {out.relative_to(HERE)} ({len(panel)}행)" + ) + + +if __name__ == "__main__": + main() diff --git a/cases/t3-land-permit-2025/plan.yaml b/cases/t3-land-permit-2025/plan.yaml new file mode 100644 index 0000000..3b3c903 --- /dev/null +++ b/cases/t3-land-permit-2025/plan.yaml @@ -0,0 +1,88 @@ +# 사전 등록 분석계획 — 데이터를 보기 전에 작성·커밋했습니다. +# 소셜 반응 예: "토허제 강남3구·용산까지 확대하고 집값 잡혔나? 옆 동네만 올랐다던데" +# → catalog/policies.yaml T3 → 이 계획. +# +# ⚠️ 지금은 API 키가 없어 시뮬레이션 데이터로 돌립니다(synthetic_data: true). +# 실데이터 전환: DATA_GO_KR_API_KEY 설정 → data_sources 에서 molit 을 첫 번째로 올리고 +# synthetic_data: false 로 바꾼 뒤 이 변경만 담은 PR 로 커밋(분석 설계는 그대로). +case_id: t3-land-permit-2025 +title: "토지거래허가구역 확대 지정(2025.3.24)이 강남·서초·송파·용산 아파트 거래에 미친 효과" +question: > + 2025년 3월 24일 강남·서초·송파·용산구 전체 아파트를 토지거래허가구역으로 지정한 뒤, + 서울의 나머지 21개 자치구와 비교해 이 4개 구의 아파트 매매 거래건수와 ㎡당 가격이 달라졌는가? +synthetic_data: true + +unit: {name: 자치구, id_col: gu_code} +time: {col: month_idx, freq: month, start: 0, end: 20} # 0 = 2024-01, 20 = 2025-09 + +treatment: + definition: "2025.3.24 토지거래허가구역 전역 지정 자치구(서초·강남·송파·용산). 첫 처치 월 = 2025-04 (month_idx 15)" + group_col: treated + treat_time: 15 +control: + definition: "서울 나머지 21개 자치구 (2025.9까지 미지정)" + rationale: > + 같은 금리·대출 규제·서울 전체 수요 충격을 받으므로 지정이 없었을 때의 추세를 대리한다. + 2025.10.20부터 서울 전역이 지정(10·15 대책)되어 그 이후는 대조군이 사라지므로 분석 기간에서 뺀다. + +outcomes: + - name: 아파트 매매 거래건수(로그) + col: log_trades + definition: "log(1 + 자치구·계약월별 아파트 매매 건수), 해제 거래 제외" + unit: "log 건" + primary: true + expected_direction: decrease + - name: ㎡당 거래가격 중위값(로그) + col: log_price_m2 + definition: "log(자치구·계약월별 거래가격 ÷ 전용면적의 중위값)" + unit: "log 원/㎡" + expected_direction: unknown + +estimator: + method: event_study + cluster_col: gu_code + ref_period: -2 # 2025-03(전환월, month_idx 14)은 데이터에서 제외하므로 2025-02 를 기준으로 + window: [-8, 5] + alpha: 0.05 + +assumptions: + - name: 평행추세 + description: "지정이 없었다면 4개 구와 나머지 21개 구의 거래 추세는 같았을 것" + check: pretrend_test + - name: 선반영 없음 + description: "2025.2.12 잠실·삼성·대치·청담 해제 직후 급등이 사전추세를 흔들 수 있음 → 가짜 시점 검정" + check: placebo_time + - name: 파급효과 없음(SUTVA) + description: "수요가 마포·성동 등 인접 구로 옮겨가면 대조군이 오염되어 효과가 과대 추정됨" + check: manual + +refutations: + - kind: placebo_time + params: {shift: 3} + +abstention: + - when: pretrend_rejected + verdict: not_identified + note: "해제(2025.2) 선반영으로 사전추세가 다르면 지정 효과로 보고하지 않는다" + - when: placebo_significant + verdict: conditional + - when: ci_crosses_zero + verdict: conditional + note: "효과 없음이 아니라 '판별 불가'로 보고" + - when: few_clusters + verdict: conditional + +thresholds: {pretrend_alpha: 0.10, min_clusters: 20, min_pre_periods: 3} + +data_sources: + - name: "시뮬레이션 자치구×월 패널 (fetch.py, seed=7) — 실데이터 아님" + provider: core.adapters.synthetic + license: synthetic + notes: "API 키 발급 전 파이프라인 시연용. 효과 크기는 임의로 넣은 값" + - name: "국토교통부_아파트 매매 실거래가 상세 자료" + provider: "국토교통부 (공공데이터포털 15126468)" + url: "https://www.data.go.kr/data/15126468/openapi.do" + license: KOGL-1 + adapter: molit + query: {start: "2024-01", end: "2025-09"} + notes: "DATA_GO_KR_API_KEY 필요(자동승인). 실데이터 전환 시 첫 번째로 올린다" diff --git a/catalog/policies.yaml b/catalog/policies.yaml new file mode 100644 index 0000000..7486d69 --- /dev/null +++ b/catalog/policies.yaml @@ -0,0 +1,187 @@ +# 정책 × 데이터셋 카탈로그 +# 소셜 반응(뉴스·SNS 글) → 정책 식별 → 추천 데이터셋 → 분석계획 초안을 잇는 기준 목록입니다. +# 근거: docs/strategy/topic-guide.md, korea-cases.md. 날짜는 출처 링크로 확인한 값만 적습니다. +# +# design (core.discovery 의 규칙 기반 설계 판별 결과와 같은 어휘) +# did_simultaneous : 처치 단위 다수 + 동시 도입 → core 지원 +# did_staggered : 처치 단위 다수 + 시차 도입 → 추정 모듈 준비 중(W4), 지금은 보류 판정 +# scm : 처치 단위 1~5개 → 준비 중(W5) +# its : 단일 시계열 → core 지원 +# access: api_key(자동승인 API 키 필요) | file(파일 다운로드) | manual(웹에서 수동 수집) + +version: 1 +policies: + - id: T3 + name: 토지거래허가구역 확대 지정 (강남·서초·송파·용산 전역) + keywords: [토지거래허가, 토허제, 토허구역, 토허, 강남3구, 용산, 잠삼대청, 아파트, 집값, 거래량, 실거래가, 풍선효과] + summary: 2025.3.19 발표, 3.24부터 강남·서초·송파·용산구 전체 아파트를 토지거래허가구역으로 지정. + announced: 2025-03-19 + effective: 2025-03-24 + sources: + - https://www.ajunews.com/view/20250324064239711 + - https://land.seoul.go.kr/land/other/appointStatusSeoul.do + unit: 서울 자치구 × 월 + treated_units: [서초구, 강남구, 송파구, 용산구] + control_units: 서울 나머지 21개 자치구 + outcomes: [아파트 매매 거래건수, ㎡당 거래가격 중위값] + design: did_simultaneous + datasets: + - id: "15126468" + name: 국토교통부_아파트 매매 실거래가 상세 자료 + provider: 국토교통부 + url: https://www.data.go.kr/data/15126468/openapi.do + access: api_key + license: KOGL-1 + granularity: 시군구(5자리) × 계약년월, 거래 단위 + role: outcome + search_terms: [아파트 매매 실거래가, 토지거래허가] + pitfalls: + - "대조군 오염: 10·15 대책으로 2025.10.20부터 서울 전역이 토허구역 → 분석은 2025.9까지만." + - "선반영: 2025.2.12 잠실·삼성·대치·청담 해제 직후 거래·가격 급등 → 사전추세 점검 필수, 2025.3은 전환월로 제외." + - "풍선효과(SUTVA 위반): 마포·성동 등 인접 구가 대조군이면 효과가 과대 추정될 수 있음." + - "신고 시차: 계약 후 30일 내 신고 → 최근 월 과소 집계." + - "표본 선택: 거래가 줄면 '거래된 집'만 관측 → 가격보다 거래량을 1차 결과로." + sample_case: cases/t3-land-permit-2025 + + - id: T1 + name: 녹색교통지역 5등급 차량 운행제한 + keywords: [녹색교통, 5등급, 운행제한, 사대문, 도심, 대기질, 미세먼지, NO2, 이산화질소, 배출가스] + summary: 서울 사대문 안 5등급 차량 운행제한. 2019.12 과태료 부과 시작. + effective: 2019-12-01 + sources: ["https://news.seoul.go.kr/traffic/greentraffic"] + unit: 측정소 × 시간 + treated_units: [녹색교통지역 안 측정소(1~3개)] + control_units: 서울 다른 도로변 측정소 + outcomes: [NO2, PM2.5] + design: scm + datasets: + - id: airkorea-final-hourly + name: 에어코리아 최종확정 측정자료(시간별) + provider: 한국환경공단 + url: https://www.airkorea.or.kr/web/last_amb_hour_data?pMENU_NO=123 + access: file + license: KOGL-1 + granularity: 측정소 × 시간 + role: outcome + search_terms: [대기오염 측정, 미세먼지 측정정보] + pitfalls: ["처치 측정소 1~3개 → 클러스터 표준오차 불가, 합성통제·순열추론 필요", "코로나19(2020.2~) 교통량 급변", "미세먼지 계절관리제와 같은 시기 시작"] + + - id: T2 + name: 대형마트 의무휴업 평일 전환 + keywords: [대형마트, 의무휴업, 평일, 일요일, 전통시장, 골목상권, 상권, 매출, 유통산업발전법] + summary: 자치구별로 대형마트 의무휴업일을 일요일에서 평일로 전환(서초 2024.1~ 등, 시차 도입). + unit: 상권 × 분기 + treated_units: [전환 자치구의 상권] + control_units: 미전환 자치구의 같은 유형 상권 + outcomes: [상권 추정매출, 주말 매출 비중] + design: did_staggered + datasets: + - id: OA-15572 + name: 서울시 상권분석서비스(추정매출-상권) + provider: 서울특별시 + url: https://data.seoul.go.kr/dataList/OA-15572/S/1/datasetView.do + access: api_key + license: KOGL-1 + granularity: 상권 × 분기 (2021~) + role: outcome + search_terms: [상권 추정매출, 대형마트] + pitfalls: ["시차 도입 → TWFE 편향, CS 추정량 필요", "2024년 상권 공간단위 변경", "자치구별 전환일 목록을 직접 구축해야 함"] + + - id: T4 + name: 안전속도5030 + keywords: [안전속도, "5030", 제한속도, 속도하향, 보행자, 교통사고, 사망자] + summary: 도시부 제한속도 50km/h(이면도로 30km/h) 전국 시행 2021.4.17. + effective: 2021-04-17 + sources: ["https://www.korea.kr/news/policyNewsView.do?newsId=148891484"] + unit: 시군구 × 월 + outcomes: [보행자 사고, 사망·중상] + design: did_staggered + datasets: + - id: koroad-opendata + name: 도로교통공단 교통사고 통계 Open API + provider: 도로교통공단 + url: https://opendata.koroad.or.kr/api/selectSttDataSet.do + access: api_key + license: KOGL-1 + granularity: 지자체 × 기간 + role: outcome + search_terms: [교통사고 통계, 보행자 교통사고] + pitfalls: ["조기 도입 지자체 일자 확인 필요", "민식이법(2020.3)·코로나19와 겹침", "사고다발지역 API는 결과로 선택된 표본이라 결과지표로 쓰면 안 됨"] + + - id: T5 + name: 민식이법 (어린이보호구역) + keywords: [민식이법, 스쿨존, 어린이보호구역, 어린이 교통사고, 단속카메라] + summary: 2020.3.25 시행. 코로나19 등교 중단과 시점이 겹쳐 식별이 어려운 교육용 사례. + effective: 2020-03-25 + unit: 스쿨존 + outcomes: [어린이 교통사고] + design: did_simultaneous + datasets: + - id: "15012891" + name: 전국어린이보호구역표준데이터 + provider: 행정안전부 + url: https://www.data.go.kr/data/15012891/standard.do + access: file + license: KOGL-1 + granularity: 보호구역(위경도, CCTV) + role: treatment + search_terms: [어린이보호구역] + pitfalls: ["코로나19 등교 중단과 동시 → 노출량 통제 불가, '식별 불가'가 정답에 가까움"] + + - id: T6 + name: 윤창호법 (음주운전 처벌 강화) + keywords: [윤창호법, 음주운전, 음주, 혈중알코올, 단속] + summary: 제1 윤창호법 2018.12.18, 제2 윤창호법 2019.6.25 시행. + effective: 2019-06-25 + unit: 전국 × 월 + outcomes: [음주운전 교통사고] + design: its + datasets: + - id: kosis-traffic + name: 경찰청 교통사고 통계 (KOSIS) + provider: 통계청 KOSIS + url: https://kosis.kr/visual/nsportalStats/detailContents.do?listId=M&statJipyoId=3715&vStatJipyoId=4878 + access: api_key + license: KOGL-1 + granularity: 전국 × 월 + role: outcome + search_terms: [음주운전 교통사고] + pitfalls: ["적발 기반 지표 → 단속 강도 변화가 측정을 바꿈", "두 법 시행이 6개월 간격"] + + - id: T7 + name: 지자체 출산·혼인장려금 + keywords: [출산장려금, 출산지원금, 혼인장려금, 저출산, 출생아, 혼인, 인구] + summary: 시군구별 장려금 신설·인상(시차 도입, 금액 연속 처치). + unit: 시군구 × 연 + outcomes: [출생아 수, 혼인 건수] + design: did_staggered + datasets: + - id: DT_1B040A3 + name: 인구동향조사 시군구별 (KOSIS) + provider: 통계청 KOSIS + url: https://kosis.kr/statHtml/statHtml.do?orgId=101&tblId=DT_1B040A3 + access: api_key + license: KOGL-1 + granularity: 시군구 × 연 + role: outcome + search_terms: [출생아수 시군구, 출산장려금] + pitfalls: ["처치(조례) 데이터셋을 직접 구축해야 함", "역인과: 출산율이 떨어진 곳이 먼저 도입", "주소 이전 효과"] + + - id: T8 + name: 미세먼지 계절관리제 + keywords: [계절관리제, 미세먼지, 초미세먼지, PM2.5, 겨울철] + summary: 매년 12~3월 전국 시행. 공간 대조군이 없어 식별이 약함. + unit: 측정소 × 일 + outcomes: [PM2.5] + design: its + datasets: + - id: airkorea-final-hourly + name: 에어코리아 최종확정 측정자료(시간별) + provider: 한국환경공단 + url: https://www.airkorea.or.kr/web/last_amb_hour_data?pMENU_NO=123 + access: file + license: KOGL-1 + granularity: 측정소 × 시간 + role: outcome + search_terms: [미세먼지 측정정보] + pitfalls: ["전국 동시 → 공간 대조군 없음", "기상·국외 유입이 크게 교란"] diff --git a/core/adapters/__init__.py b/core/adapters/__init__.py index 09dddc0..775fc43 100644 --- a/core/adapters/__init__.py +++ b/core/adapters/__init__.py @@ -3,9 +3,15 @@ from .base import BaseAdapter, MissingAPIKey, SourceMeta from .kosis import KosisAdapter from .local import LocalFileAdapter +from .molit import MolitAptTradeAdapter from .synthetic import SyntheticAdapter, simulate_panel -REGISTRY = {"kosis": KosisAdapter, "local": LocalFileAdapter, "synthetic": SyntheticAdapter} +REGISTRY = { + "kosis": KosisAdapter, + "local": LocalFileAdapter, + "molit": MolitAptTradeAdapter, + "synthetic": SyntheticAdapter, +} __all__ = [ "BaseAdapter", @@ -13,6 +19,7 @@ "MissingAPIKey", "KosisAdapter", "LocalFileAdapter", + "MolitAptTradeAdapter", "SyntheticAdapter", "simulate_panel", "REGISTRY", diff --git a/core/adapters/molit.py b/core/adapters/molit.py new file mode 100644 index 0000000..dd753f8 --- /dev/null +++ b/core/adapters/molit.py @@ -0,0 +1,111 @@ +"""국토교통부 아파트 매매 실거래가 상세 자료 (data.go.kr 15126468) 어댑터. + +- 인증키: 공공데이터포털에서 활용신청(자동승인) 후 DATA_GO_KR_API_KEY 에 넣는다. +- 조회 단위: 시군구코드(법정동코드 앞 5자리) × 계약년월(YYYYMM). 1회 최대 1,000건 → 페이지 순회. +- 해제(취소)된 거래(cdealType == "O")는 제외한다. +- ⚠️ 2026-09 작성 시점에 실제 호출로 검증하지 못했다(작성 환경에서 apis.data.go.kr 접속 불가). + 키를 넣고 처음 돌릴 때 응답 필드명을 확인하고, 다르면 FIELD_MAP 만 고친다. +""" + +from __future__ import annotations + +import time +import xml.etree.ElementTree as ET + +import pandas as pd + +from .base import BaseAdapter, SourceMeta + +ENDPOINT = "https://apis.data.go.kr/1613000/RTMSDataSvcAptTradeDev/getRTMSDataSvcAptTradeDev" + +# 서울 25개 자치구 시군구코드 +SEOUL_GU = { + "11110": "종로구", "11140": "중구", "11170": "용산구", "11200": "성동구", "11215": "광진구", + "11230": "동대문구", "11260": "중랑구", "11290": "성북구", "11305": "강북구", "11320": "도봉구", + "11350": "노원구", "11380": "은평구", "11410": "서대문구", "11440": "마포구", "11470": "양천구", + "11500": "강서구", "11530": "구로구", "11545": "금천구", "11560": "영등포구", "11590": "동작구", + "11620": "관악구", "11650": "서초구", "11680": "강남구", "11710": "송파구", "11740": "강동구", +} # fmt: skip + +FIELD_MAP = { # API 응답 태그 → 컬럼 + "sggCd": "gu_code", + "dealYear": "year", + "dealMonth": "month", + "dealAmount": "price_10k_won", + "excluUseAr": "area_m2", + "cdealType": "cancelled", +} + + +def months(start: str, end: str) -> list[str]: + """'2024-01', '2025-09' → ['202401', ..., '202509']""" + return [p.strftime("%Y%m") for p in pd.period_range(start, end, freq="M")] + + +class MolitAptTradeAdapter(BaseAdapter): + meta = SourceMeta( + name="국토교통부_아파트 매매 실거래가 상세 자료", + provider="국토교통부 (공공데이터포털 15126468)", + license="KOGL-1", + url="https://www.data.go.kr/data/15126468/openapi.do", + ) + api_key_env = "DATA_GO_KR_API_KEY" + + def _page(self, lawd: str, ym: str, page: int) -> tuple[list[dict], int]: + import requests + + r = requests.get( + ENDPOINT, + params={ + "serviceKey": self.api_key(), + "LAWD_CD": lawd, + "DEAL_YMD": ym, + "pageNo": page, + "numOfRows": 1000, + }, + timeout=30, + ) + r.raise_for_status() + root = ET.fromstring(r.content) + code = root.findtext(".//resultCode") + if code not in (None, "00", "000"): + raise RuntimeError(f"API 오류 {code}: {root.findtext('.//resultMsg')}") + rows = [ + {col: (it.findtext(tag) or "").strip() for tag, col in FIELD_MAP.items()} + for it in root.iter("item") + ] + return rows, int(root.findtext(".//totalCount") or 0) + + def fetch(self, start: str, end: str, gu_codes: list[str] | None = None) -> pd.DataFrame: + """거래 단위 원자료 (gu_code, year, month, price_10k_won, area_m2).""" + rows: list[dict] = [] + for lawd in gu_codes or list(SEOUL_GU): + for ym in months(start, end): + page, total = 1, None + while total is None or (page - 1) * 1000 < total: + got, total = self._page(lawd, ym, page) + rows += [{**g, "gu_code": g["gu_code"] or lawd} for g in got] + page += 1 + time.sleep(0.1) + df = pd.DataFrame(rows, columns=list(FIELD_MAP.values())) + df = df[df["cancelled"].ne("O")].drop(columns="cancelled") + df["price_10k_won"] = pd.to_numeric(df["price_10k_won"].str.replace(",", "")) + df["area_m2"] = pd.to_numeric(df["area_m2"]) + df[["year", "month"]] = df[["year", "month"]].astype(int) + return df + + +def to_gu_month_panel(trades: pd.DataFrame, start: str) -> pd.DataFrame: + """거래 단위 → 자치구 × 월 패널 (거래건수, ㎡당 가격 중위값).""" + t = trades.assign(ym=trades["year"].astype(str) + trades["month"].astype(str).str.zfill(2)) + t["price_per_m2"] = t["price_10k_won"] * 10_000 / t["area_m2"] + g = t.groupby(["gu_code", "ym"]).agg( + trades=("price_per_m2", "size"), price_m2_median=("price_per_m2", "median") + ) + return g.reset_index().assign(month_idx=lambda d: month_index(d["ym"], start)) + + +def month_index(ym: pd.Series, start: str) -> pd.Series: + """'202401' → 0, '202402' → 1 … (core 는 정수 시간축을 요구)""" + p0 = pd.Period(start, freq="M") + return ym.map(lambda s: (pd.Period(f"{s[:4]}-{s[4:]}", freq="M") - p0).n).astype(int) diff --git a/core/agent/guard.py b/core/agent/guard.py index dec2656..4e4d166 100644 --- a/core/agent/guard.py +++ b/core/agent/guard.py @@ -41,6 +41,7 @@ TRIGGER_KO = { "pretrend_rejected": "사전추세 차이(평행추세 가정 기각)", "few_clusters": "클러스터 수 부족(표준오차 과소추정 위험)", + "few_treated_clusters": "처치 단위 수 부족(무작위화 추론 사용)", "staggered_adoption": "시차 도입에서 TWFE 편향 위험", "placebo_significant": "가짜 도입시점 검정 유의", "ci_crosses_zero": "신뢰구간이 0을 포함", diff --git a/core/agent/nodes.py b/core/agent/nodes.py index 005573e..36161c0 100644 --- a/core/agent/nodes.py +++ b/core/agent/nodes.py @@ -295,7 +295,8 @@ def _checks(plan, r) -> dict: if a.check == "pretrend_test" and "parallel_pretrends" in ac: x = ac["parallel_pretrends"] out[a.name] = ( - f"사전계수 결합 Wald p={x['p_value']:.3f} → {'통과' if x['passed'] else '기각'}" + f"{x.get('test', '사전계수 결합검정')} p={x['p_value']:.3f} → " + f"{'통과' if x['passed'] else '기각'}" ) elif a.check == "placebo_time" and "placebo_time" in ac: x = ac["placebo_time"] @@ -340,7 +341,12 @@ def report(s: FlowState): raw_trends(df, r.outcome, p.time.col, p.treatment.group_col, tt, figdir / "raw_trends.png") figs["Raw trends"] = "figures/raw_trends.png" if "coefs" in r.extra: - event_study_plot(r.extra["coefs"], figdir / "event_study.png") + note = ( + "per-period CIs cluster-robust, reference only: few treated units" + if "few_treated_clusters" in r.triggers + else "cluster-robust" + ) + event_study_plot(r.extra["coefs"], figdir / "event_study.png", ci_note=note) figs["Event study"] = "figures/event_study.png" md = render_report(p, s.results, figs, _checks(p, r), narrative=s.guard.get("narrative")) s.report_path = s.case_dir / "report.md" diff --git a/core/discovery/__init__.py b/core/discovery/__init__.py new file mode 100644 index 0000000..77c4ac6 --- /dev/null +++ b/core/discovery/__init__.py @@ -0,0 +1,29 @@ +"""소셜 반응 → 정책 식별 → 데이터셋 추천 → 분석계획 연결 (Flow ① 앞단). + + from core.discovery import discover + r = discover("토허제 확대하고 강남 집값 잡혔나요?") + r.matches[0].policy.name, r.datasets, r.sample_case + +규칙 +- 정책 식별은 카탈로그(catalog/policies.yaml) 키워드 매칭이 기본이다. LLM이 설정돼 있으면 + 후보 중 하나를 고르는 보조 역할만 하고, 카탈로그에 없는 정책을 만들어 내지 못한다. +- 데이터셋 추천 = 카탈로그에 검증해 둔 데이터셋 + (선택) 공공데이터포털 실시간 검색. +- 분석 방법은 LLM이 아니라 카탈로그의 design(규칙 기반 판별 결과)으로 정한다. +""" + +from .catalog import Dataset, Policy, load_catalog +from .match import Match, match_issue +from .pipeline import DiscoveryResult, discover +from .search import SearchHit, search_datago + +__all__ = [ + "Dataset", + "Policy", + "load_catalog", + "Match", + "match_issue", + "SearchHit", + "search_datago", + "DiscoveryResult", + "discover", +] diff --git a/core/discovery/catalog.py b/core/discovery/catalog.py new file mode 100644 index 0000000..8ff3b88 --- /dev/null +++ b/core/discovery/catalog.py @@ -0,0 +1,72 @@ +"""정책 × 데이터셋 카탈로그 로더.""" + +from __future__ import annotations + +from datetime import date +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, Field + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CATALOG = ROOT / "catalog" / "policies.yaml" + +Design = Literal["did_simultaneous", "did_staggered", "scm", "its"] + +# core 가 지금 추정할 수 있는 설계 (나머지는 추정 모듈 준비 중 → 보류 판정) +SUPPORTED: dict[str, str] = { + "did_simultaneous": "지원", + "its": "지원", + "did_staggered": "준비 중 (W4, Callaway–Sant'Anna)", + "scm": "준비 중 (W5, 합성통제)", +} +DESIGN_KO = { + "did_simultaneous": "이중차분(동시 도입)", + "did_staggered": "이중차분(시차 도입)", + "scm": "합성통제", + "its": "단절 시계열", +} + + +class Dataset(BaseModel): + id: str + name: str + provider: str + url: str + access: Literal["api_key", "file", "manual"] + license: str + granularity: str = "" + role: Literal["outcome", "treatment", "covariate"] = "outcome" + + +class Policy(BaseModel): + id: str + name: str + keywords: list[str] + summary: str + announced: date | None = None + effective: date | None = None + sources: list[str] = [] + unit: str + treated_units: list[str] = [] + control_units: str | None = None + outcomes: list[str] + design: Design + datasets: list[Dataset] = Field(..., min_length=1) + search_terms: list[str] = [] + pitfalls: list[str] = [] + sample_case: str | None = None + + @property + def support(self) -> str: + return SUPPORTED[self.design] + + @property + def design_ko(self) -> str: + return DESIGN_KO[self.design] + + +def load_catalog(path: str | Path = DEFAULT_CATALOG) -> list[Policy]: + raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + return [Policy.model_validate(p) for p in raw["policies"]] diff --git a/core/discovery/match.py b/core/discovery/match.py new file mode 100644 index 0000000..d976c76 --- /dev/null +++ b/core/discovery/match.py @@ -0,0 +1,70 @@ +"""소셜 반응 텍스트 → 카탈로그 정책 매칭. + +기본은 결정론적 키워드 점수(긴 키워드일수록 가중). LLM 이 설정돼 있으면 상위 후보 중 +하나를 고르고 이유를 한 줄로 달게 하되, 카탈로그 밖의 답은 버린다. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field + +from .catalog import Policy + + +@dataclass +class Match: + policy: Policy + score: float + hits: list[str] + reason: str = "" + picked_by: str = "keyword" # keyword | llm + extra: dict = field(default_factory=dict) + + +def _norm(s: str) -> str: + return re.sub(r"\s+", "", s).lower() + + +def score(text: str, p: Policy) -> tuple[float, list[str]]: + t = _norm(text) + hits = [k for k in p.keywords if _norm(k) in t] + # 긴 키워드(고유명사)에 가중: '토지거래허가'(6자) > '집값'(2자) + return float(sum(min(len(_norm(k)), 6) for k in hits)), hits + + +def match_issue( + text: str, catalog: list[Policy], top_k: int = 3, use_llm: bool = False +) -> list[Match]: + scored = sorted( + (Match(p, *score(text, p)) for p in catalog), key=lambda m: m.score, reverse=True + ) + out = [m for m in scored if m.score > 0][:top_k] + for m in out: + m.reason = "키워드 일치: " + ", ".join(m.hits) + if use_llm and out: + _llm_pick(text, out) + return out + + +def _llm_pick(text: str, cands: list[Match]) -> None: + """LLM 이 후보 중 하나를 고르게 한다. 실패하거나 목록 밖이면 키워드 순위를 유지.""" + from ..agent import llm + + if not llm.configured(): + return + menu = "\n".join(f"- {m.policy.id}: {m.policy.name} — {m.policy.summary}" for m in cands) + sys_msg = ( + "너는 공공정책 분석 보조자다. 사용자의 글이 어떤 정책에 대한 반응인지 후보 중에서만 고른다. " + 'JSON 한 줄로만 답한다: {"id": "<후보 id 또는 none>", "reason": "<한 문장>"}' + ) + try: + ans = json.loads(llm.strip_fence(llm.chat(sys_msg, f"글:\n{text}\n\n후보:\n{menu}"))) + except Exception: # noqa: BLE001 - LLM 실패는 키워드 결과로 대체 + return + for i, m in enumerate(cands): + if m.policy.id == ans.get("id"): + m.picked_by, m.reason = "llm", f"LLM: {ans.get('reason', '')} ({m.reason})" + cands.insert(0, cands.pop(i)) + return diff --git a/core/discovery/pipeline.py b/core/discovery/pipeline.py new file mode 100644 index 0000000..548cfb6 --- /dev/null +++ b/core/discovery/pipeline.py @@ -0,0 +1,51 @@ +"""discover(): 소셜 반응 한 건을 정책·데이터셋·분석계획까지 잇는다.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from .catalog import ROOT, Policy, load_catalog +from .match import Match, match_issue +from .search import SearchHit, search_datago + + +@dataclass +class DiscoveryResult: + text: str + matches: list[Match] + live_hits: list[SearchHit] = field(default_factory=list) + live_status: str = "실시간 검색 안 함" + + @property + def top(self) -> Policy | None: + return self.matches[0].policy if self.matches else None + + @property + def sample_case(self) -> Path | None: + p = self.top + return ROOT / p.sample_case if p and p.sample_case else None + + @property + def next_step(self) -> str: + p = self.top + if p is None: + return "카탈로그에서 정책을 찾지 못했습니다. 정책명이나 지역·시점을 넣어 다시 입력하거나, catalog/policies.yaml 에 정책을 추가하세요." + if self.sample_case and (self.sample_case / "plan.yaml").exists(): + return f"사전 등록된 분석계획이 있습니다: {p.sample_case} → Flow 로 효과 분석을 실행하세요." + if p.support != "지원": + return f"설계가 '{p.design_ko}'입니다. 추정 모듈 {p.support}. 지금은 데이터 수집과 plan.yaml 작성까지 진행하세요." + return "cases/_template 을 복사해 이 정책의 plan.yaml 을 작성하고 PR 로 사전 등록하세요." + + +def discover( + text: str, + live_search: bool = False, + use_llm: bool = False, + catalog: list[Policy] | None = None, +) -> DiscoveryResult: + cat = catalog if catalog is not None else load_catalog() + res = DiscoveryResult(text=text, matches=match_issue(text, cat, use_llm=use_llm)) + if live_search and res.top and res.top.search_terms: + res.live_hits, res.live_status = search_datago(res.top.search_terms) + return res diff --git a/core/discovery/search.py b/core/discovery/search.py new file mode 100644 index 0000000..ab2321c --- /dev/null +++ b/core/discovery/search.py @@ -0,0 +1,97 @@ +"""공공데이터포털(data.go.kr) 데이터셋 검색. + +공개 검색 페이지 결과를 읽어 데이터셋 이름·유형·제공기관·링크만 뽑는다(데이터 자체는 받지 않음). +- 네트워크가 막혀 있거나 실패하면 빈 목록 + 사유를 돌려준다(추천은 카탈로그만으로 계속). +- 검색어당 1회, 요청 간 1초 간격. 대량 수집 용도로 쓰지 않는다. +- 공식 목록조회 API(키 필요)가 준비되면 이 모듈을 그 API 로 교체한다. +""" + +from __future__ import annotations + +import html +import re +import time +from dataclasses import asdict, dataclass + +SEARCH_URL = "https://www.data.go.kr/tcs/dss/selectDataSetList.do" +UA = "policy-effect-analytics-agent/0.1 (+https://github.com/CausalInferenceLab/policy-effect-analytics-agent)" + +_ITEM = re.compile( + r'
(.*?)(?=
|$)', re.S +) +_LINK = re.compile(r'(.*?)', re.S) +_SUMMARY = re.compile(r'(.*?)', re.S) +_PROVIDER = re.compile(r"제공기관(.*?)", re.S) +_MODIFIED = re.compile(r"수정일\s*([0-9-]+)", re.S) + +KIND_KO = {"openapi": "오픈API(키 필요)", "fileData": "파일", "standard": "표준데이터"} + + +@dataclass +class SearchHit: + id: str + title: str + kind: str + provider: str + modified: str + summary: str + url: str + + def to_dict(self) -> dict: + return asdict(self) + + +def _text(s: str) -> str: + return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", "", s))).strip() + + +def parse_results(page: str, limit: int = 5) -> list[SearchHit]: + hits: list[SearchHit] = [] + for block in _ITEM.findall(page): + link = _LINK.search(block) + if not link: + continue + path, did, kind, title = link.groups() + prov, mod, summ = _PROVIDER.search(block), _MODIFIED.search(block), _SUMMARY.search(block) + hits.append( + SearchHit( + id=did, + title=_text(title), + kind=KIND_KO.get(kind, kind), + provider=_text(prov.group(1)) if prov else "", + modified=mod.group(1) if mod else "", + summary=_text(summ.group(1))[:160] if summ else "", + url="https://www.data.go.kr" + path, + ) + ) + if len(hits) >= limit: + break + return hits + + +def search_datago( + terms: list[str], limit: int = 5, timeout: int = 10 +) -> tuple[list[SearchHit], str]: + """(검색 결과, 상태 메시지). 실패해도 예외를 던지지 않는다.""" + try: + import requests + except ImportError: + return [], "requests 미설치" + seen, out, errors = set(), [], [] + for i, term in enumerate(terms): + if i: + time.sleep(1) + try: + r = requests.get( + SEARCH_URL, params={"keyword": term}, headers={"User-Agent": UA}, timeout=timeout + ) + r.raise_for_status() + except Exception as exc: # noqa: BLE001 + errors.append(f"'{term}': {type(exc).__name__}") + continue + for h in parse_results(r.text, limit): + if h.id not in seen: + seen.add(h.id) + out.append(h) + msg = f"공공데이터포털 검색 {len(out)}건" + (f" (실패: {', '.join(errors)})" if errors else "") + return out, msg diff --git a/core/estimators/did.py b/core/estimators/did.py index 3e2c110..60f6395 100644 --- a/core/estimators/did.py +++ b/core/estimators/did.py @@ -18,6 +18,7 @@ from .diagnostics import check_clusters, check_staggered from .result import EffectResult +from .ri import did_ri, event_study_ri def _first_treat(df, unit, time, group_col, first_treat_col, treat_time) -> pd.Series: @@ -44,6 +45,7 @@ def did( covariates: list[str] = (), alpha: float = 0.05, min_clusters: int = 20, + min_treated_clusters: int = 10, ) -> EffectResult: """TWFE DiD. cluster 기본값은 unit (정책이 단위 수준에서 배정되므로).""" cluster = cluster or unit @@ -65,6 +67,24 @@ def did( n_obs=int(fit._N), n_clusters=int(n_cl), ) + treated_units = set(d.loc[d["_ft"].notna(), unit].unique()) + balanced = d.groupby(unit)[time].nunique().nunique() == 1 + if ( + len(treated_units) < min_treated_clusters + and balanced + and d["_ft"].dropna().nunique() == 1 + and not covariates + ): + ri = did_ri(d, y, unit, time, treated_units, int(d["_ft"].dropna().iloc[0]), alpha=alpha) + res.extra["crv1"] = {"ci": [res.ci_low, res.ci_high], "p_value": res.p_value} + res.ci_low, res.ci_high = ri["ci"] + res.p_value = ri["p"] + res.assumptions_checked["inference"] = "randomization inference" + res.warn( + f"처치 단위가 {len(treated_units)}개뿐이라 군집-강건 표준오차를 믿을 수 없습니다. " + "무작위화 추론으로 p값과 신뢰구간을 계산했습니다.", + "few_treated_clusters", + ) check_clusters(res, n_cl, min_clusters) check_staggered(res, d.groupby(unit)["_ft"].first()) return res @@ -84,9 +104,13 @@ def event_study( pretrend_alpha: float = 0.10, alpha: float = 0.05, min_clusters: int = 20, + min_treated_clusters: int = 10, ) -> EffectResult: """상대시점 더미 TWFE. 창 밖 상대시점은 양끝으로 binning. + 처치 단위가 min_treated_clusters 개 미만이고 균형 패널·동시 도입이면, 군집-강건 SE 대신 + 무작위화 추론(ri.py)으로 p값·신뢰구간·사전추세 검정을 계산한다. + 반환 EffectResult.estimate = 사후 계수들의 평균(단순평균), extra['coefs'] = 상대시점별 계수표, assumptions_checked['parallel_pretrends'] = 결합검정. """ @@ -151,16 +175,37 @@ def event_study( 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, - } + pretrend = {"test": "joint Wald (chi2, CRV1)", "stat": wald, "df": len(pre), "p_value": p_pre} + treated_units = set(d.loc[d["_ft"].notna(), unit].unique()) + balanced = d.groupby(unit)[time].nunique().nunique() == 1 + simultaneous = d["_ft"].dropna().nunique() == 1 + if len(treated_units) < min_treated_clusters and balanced and simultaneous: + ri = event_study_ri( + d, y, unit, time, treated_units, int(d["_ft"].dropna().iloc[0]), + list(names.values()), lo, hi, alpha=alpha, + ) # fmt: skip + res.extra["crv1"] = { + "ci": [res.ci_low, res.ci_high], + "p_value": res.p_value, + "pretrend": pretrend, + } + res.ci_low, res.ci_high = ri["ci"] + res.p_value = ri["p_post"] + res.assumptions_checked["inference"] = ri["method"] + pretrend = { + "test": "randomization inference (사전 계수 제곱합)", + "p_value": ri["p_pretrend"], + } + p_pre = ri["p_pretrend"] + res.warn( + f"처치 단위가 {len(treated_units)}개뿐이라 군집-강건 표준오차를 믿을 수 없습니다. " + "무작위화 추론(처치 단위를 무작위로 바꿔 반복)으로 p값과 신뢰구간을 계산했습니다.", + "few_treated_clusters", + ) + res.assumptions_checked["parallel_pretrends"] = {**pretrend, "passed": p_pre >= pretrend_alpha} if p_pre < pretrend_alpha: res.warn( - f"사전추세 결합검정 p={p_pre:.3f} < {pretrend_alpha}: 평행추세 가정이 의심됩니다. " + f"사전추세 검정 p={p_pre:.3f} < {pretrend_alpha}: 평행추세 가정이 의심됩니다. " "효과를 인과적으로 해석하지 마세요.", "pretrend_rejected", ) diff --git a/core/estimators/ri.py b/core/estimators/ri.py new file mode 100644 index 0000000..3255517 --- /dev/null +++ b/core/estimators/ri.py @@ -0,0 +1,134 @@ +"""처치 단위가 적을 때의 무작위화 추론(randomization inference, placebo-in-space). + +처치 클러스터가 몇 개뿐이면(예: 서울 25개 구 중 4개) 군집-강건 표준오차는 크게 과소추정되고, +사전추세 Wald 검정은 귀무가설이 참이어도 절반 이상 기각한다(MacKinnon & Webb 2017). +대신 "처치를 받은 단위 집합"을 무작위로 바꿔 가며 같은 이벤트 스터디를 반복해 분포를 만든다. + +- 균형 패널 + 동시 도입일 때만 사용한다(양방향 고정효과를 평균 빼기로 정확히 제거 가능). +- 가짜 처치 단위는 실제 처치 단위를 뺀 대조군에서만 뽑는다(placebo-in-space, Abadie et al. 2010). +- 통계량: 사전 = 사전 계수 제곱합, 사후 = 사후 계수 평균. +- 신뢰구간: 사후 평균효과에 대한 검정 역산(test inversion). +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + + +def _twoway_demean(a: np.ndarray, u: np.ndarray, t: np.ndarray) -> np.ndarray: + """균형 패널에서 unit·time 고정효과 제거 (a: n x k).""" + df = pd.DataFrame(a) + return ( + a + - df.groupby(u).transform("mean").to_numpy() + - df.groupby(t).transform("mean").to_numpy() + + a.mean(axis=0) + ) + + +def _dummies(rel: np.ndarray, treated_row: np.ndarray, ks: list[int]) -> np.ndarray: + return np.column_stack([(treated_row & (rel == k)).astype(float) for k in ks]) + + +def event_study_ri( + df: pd.DataFrame, + y: str, + unit: str, + time: str, + treated_units: set, + treat_time: int, + ks: list[int], + lo: int, + hi: int, + alpha: float = 0.05, + n_perm: int = 999, + seed: int = 0, +) -> dict: + u = df[unit].to_numpy() + t = df[time].to_numpy() + rel = np.clip(t - treat_time, lo, hi) + pool = np.array([x for x in np.unique(u) if x not in treated_units]) + k_tr = len(treated_units) + pre = np.array([k < 0 for k in ks]) + post = ~pre + w = post / post.sum() + yd = _twoway_demean(df[[y]].to_numpy(float), u, t)[:, 0] + # 실제 처치 단위의 사후 지시변수 — H0(효과=δ)에서 y(0) = y − δ·D_obs 로 보정할 때 쓴다 + d_obs = _twoway_demean( + (np.isin(u, list(treated_units)) & (t >= treat_time)).astype(float)[:, None], u, t + )[:, 0] + + def coefs(tr_set) -> tuple[np.ndarray, np.ndarray]: + tr_row = np.isin(u, list(tr_set)) + Xd = _twoway_demean(_dummies(rel, tr_row, ks), u, t) + a = w @ np.linalg.pinv(Xd) # 사후 평균효과 = a · y (선형 범함수) + beta = np.linalg.lstsq(Xd, yd, rcond=None)[0] + return beta, np.array([a @ yd, a @ d_obs]) + + b_obs, (m_obs, c_obs) = coefs(treated_units) + s_pre_obs = float(np.sum(b_obs[pre] ** 2)) + rng = np.random.default_rng(seed) + s_pre, m, c = np.empty(n_perm), np.empty(n_perm), np.empty(n_perm) + for i in range(n_perm): + b, (m[i], c[i]) = coefs(set(rng.choice(pool, k_tr, replace=False))) + s_pre[i] = np.sum(b[pre] ** 2) + + p_pre = float((1 + np.sum(s_pre >= s_pre_obs)) / (n_perm + 1)) + p_post = float((1 + np.sum(np.abs(m) >= abs(m_obs))) / (n_perm + 1)) + # 검정 역산: H0 효과=δ 에서 관측·순열 통계량을 δ 만큼 보정해 p(δ) > alpha 인 δ 의 범위 + spread = max(np.std(m), abs(m_obs), 1e-9) + grid = np.linspace(m_obs - 6 * spread, m_obs + 6 * spread, 481) + obs = np.abs(m_obs - grid * c_obs) + perm = np.abs(m[:, None] - grid[None, :] * c[:, None]) + p_grid = (1 + (perm >= obs[None, :]).sum(axis=0)) / (n_perm + 1) + keep = grid[p_grid > alpha] + ci = (float(keep.min()), float(keep.max())) if keep.size else (float("nan"), float("nan")) + return { + "method": f"randomization inference (처치 단위 {k_tr}개를 무작위 재배정, {n_perm}회)", + "estimate": float(m_obs), + "p_post": p_post, + "p_pretrend": p_pre, + "ci": ci, + } + + +def did_ri( + df: pd.DataFrame, + y: str, + unit: str, + time: str, + treated_units: set, + treat_time: int, + alpha: float = 0.05, + n_perm: int = 999, + seed: int = 0, +) -> dict: + """2x2/동시 도입 TWFE DiD 계수에 대한 무작위화 추론 (p값, 검정 역산 신뢰구간).""" + u = df[unit].to_numpy() + t = df[time].to_numpy() + pool = np.array([x for x in np.unique(u) if x not in treated_units]) + yd = _twoway_demean(df[[y]].to_numpy(float), u, t)[:, 0] + + def dd(tr_set) -> np.ndarray: + return _twoway_demean( + (np.isin(u, list(tr_set)) & (t >= treat_time)).astype(float)[:, None], u, t + )[:, 0] + + d_obs = dd(treated_units) + b_obs = float(d_obs @ yd / (d_obs @ d_obs)) + rng = np.random.default_rng(seed) + m, c = np.empty(n_perm), np.empty(n_perm) + for i in range(n_perm): + dp = dd(set(rng.choice(pool, len(treated_units), replace=False))) + m[i], c[i] = dp @ yd / (dp @ dp), dp @ d_obs / (dp @ dp) + p = float((1 + np.sum(np.abs(m) >= abs(b_obs))) / (n_perm + 1)) + spread = max(np.std(m), abs(b_obs), 1e-9) + grid = np.linspace(b_obs - 6 * spread, b_obs + 6 * spread, 481) + obs = np.abs(b_obs - grid) + p_grid = (1 + (np.abs(m[:, None] - grid[None, :] * c[:, None]) >= obs[None, :]).sum(0)) / ( + n_perm + 1 + ) + keep = grid[p_grid > alpha] + ci = (float(keep.min()), float(keep.max())) if keep.size else (float("nan"), float("nan")) + return {"estimate": b_obs, "p": p, "ci": ci} diff --git a/core/report/plots.py b/core/report/plots.py index ae5bd89..a90a3c8 100644 --- a/core/report/plots.py +++ b/core/report/plots.py @@ -37,7 +37,12 @@ def raw_trends( return _finish(ax, out) -def event_study_plot(coefs: pd.DataFrame, out, ylabel: str = "Effect vs t=-1"): +def event_study_plot( + coefs: pd.DataFrame, out, ylabel: str | None = None, ci_note: str = "cluster-robust" +): + """ci_note: 시점별 신뢰구간의 계산 방식 (처치 단위가 적으면 참고용임을 표시).""" + ref = coefs.loc[coefs["se"].eq(0) & coefs["coef"].eq(0), "rel_time"] + ylabel = ylabel or f"Effect vs t={int(ref.iloc[0]) if len(ref) else -1}" fig, ax = plt.subplots(figsize=(7, 4)) pre = coefs["rel_time"] < 0 for mask, c in ((pre, CTRL), (~pre, TREAT)): @@ -55,7 +60,7 @@ def event_study_plot(coefs: pd.DataFrame, out, ylabel: str = "Effect vs t=-1"): ax.set( xlabel="Periods relative to policy (endpoints binned)", ylabel=ylabel, - title="Event study (95% CI, cluster-robust)", + title=f"Event study (95% CI, {ci_note})", ) return _finish(ax, out) diff --git a/core/schema/plan.py b/core/schema/plan.py index 058d3a3..66bff98 100644 --- a/core/schema/plan.py +++ b/core/schema/plan.py @@ -36,6 +36,7 @@ AbstainTrigger = Literal[ "pretrend_rejected", # 사전추세 결합검정 p < pretrend_alpha "few_clusters", # 클러스터 수 < min_clusters + "few_treated_clusters", # 처치 단위 < 10 → 무작위화 추론으로 대체 "staggered_adoption", # 도입시점이 여러 개인데 TWFE 사용 "placebo_significant", # 가짜 도입시점 검정이 유의 "ci_crosses_zero", # 신뢰구간이 0을 포함 (효과 '없음'이 아니라 '불확실') diff --git a/tests/core/test_ri.py b/tests/core/test_ri.py new file mode 100644 index 0000000..0a1f3c0 --- /dev/null +++ b/tests/core/test_ri.py @@ -0,0 +1,38 @@ +"""처치 단위가 적을 때(4/25) 추론이 명목 수준을 지키는지 — CRV1 은 여기서 크게 과대기각한다.""" + +import numpy as np +import pandas as pd + +from core.estimators import did, event_study + + +def _panel(seed, effect=-0.4, n=25, k=4, months=20, treat=14): + rng = np.random.default_rng(seed) + shock = np.cumsum(rng.normal(0, 0.05, months)) + rows = [] + for i in range(n): + a = rng.normal(5, 0.5) + for t in range(months): + d = i < k and t >= treat + rows.append( + (f"G{i:02d}", t, int(i < k), a + shock[t] + effect * d + rng.normal(0, 0.08)) + ) + return pd.DataFrame(rows, columns=["g", "t", "treated", "y"]) + + +KW = dict(unit="g", time="t", group_col="treated", treat_time=14, cluster="g") + + +def test_few_treated_uses_randomization_inference(): + r = event_study(_panel(1), "y", ref_period=-1, window=(-6, 5), **KW) + assert "few_treated_clusters" in r.triggers + assert "randomization" in r.assumptions_checked["inference"] + assert r.ci_low <= -0.4 <= r.ci_high + assert r.assumptions_checked["parallel_pretrends"]["passed"] + + +def test_did_ri_covers_truth_and_placebo_null(): + r = did(_panel(2), "y", **KW) + assert r.ci_low <= -0.4 <= r.ci_high and r.p_value < 0.05 + null = did(_panel(3, effect=0.0), "y", **KW) + assert null.ci_low <= 0 <= null.ci_high diff --git a/tests/discovery/test_discovery.py b/tests/discovery/test_discovery.py new file mode 100644 index 0000000..ee65d47 --- /dev/null +++ b/tests/discovery/test_discovery.py @@ -0,0 +1,36 @@ +from pathlib import Path + +from core.discovery import discover, load_catalog +from core.discovery.search import parse_results + +FIX = Path(__file__).resolve().parents[1] / "fixtures" / "datago" / "search_apt_trade.html" + + +def test_catalog_loads_and_sample_case_exists(): + cat = load_catalog() + assert {p.id for p in cat} >= {"T1", "T3", "T5"} + t3 = next(p for p in cat if p.id == "T3") + assert t3.design == "did_simultaneous" and t3.support == "지원" + assert (Path(__file__).resolve().parents[2] / t3.sample_case / "plan.yaml").exists() + + +def test_social_text_maps_to_policy(): + r = discover("토허제 강남3구·용산까지 확대하고 집값 잡혔나? 옆 동네만 올랐다던데") + assert r.top.id == "T3" and "토허제" in r.matches[0].hits + assert r.sample_case is not None + + +def test_unrelated_text_returns_no_match(): + r = discover("오늘 점심 뭐 먹지") + assert r.matches == [] and "찾지 못했습니다" in r.next_step + + +def test_unsupported_design_is_flagged(): + r = discover("안전속도 5030 이후 보행자 교통사고") + assert r.top.id == "T4" and "준비 중" in r.next_step + + +def test_parse_datago_search_fixture(): + hits = parse_results(FIX.read_text(encoding="utf-8"), limit=10) + assert any(h.id == "15126468" and h.kind.startswith("오픈API") for h in hits) + assert all(h.url.startswith("https://www.data.go.kr/data/") for h in hits) diff --git a/tests/fixtures/datago/search_apt_trade.html b/tests/fixtures/datago/search_apt_trade.html new file mode 100644 index 0000000..0485831 --- /dev/null +++ b/tests/fixtures/datago/search_apt_trade.html @@ -0,0 +1,874 @@ + +
+
+
+ 공공행정 + 국가행정기관 + 국가중점 +
+ + + 「부동산 거래신고 등에 관한 법률」 등에 따라 신고된 자료 및 「주택임대차보호법」에 따라 부여된 확정일자 자료로 실거래가공개시스템에서 제공하는 자료입니다. + +
    +
  • + 제공기관 + 국토교통부 +
  • +
  • + 수정일 + 2026-06-26 +
  • +
  • + 조회수 + 114,412건 +
  • +
  • + 다운로드 + 26,292건 +
  • +
  • + 키워드 + 실거래가,매매,거래,임대차,전월세 +
  • +
+
+
+ + +
+
+
+
+
+ 국토관리 + 자치행정기관 +
+ + + 서울특별시 부동산 실거래가 정보로 자치구, 법정동, 신고년도, 업무구분, 물건구분, 대지권면적, 관리구분, 건물면적, 건물주용도, 물건금액, 건물명, 건축년도 등의 실거래가 정보를 + +
    +
  • + 제공기관 + 서울특별시 +
  • +
  • + 수정일 + 2025-09-25 +
  • +
  • + 조회수 + 49,162건 +
  • +
  • + 다운로드 + 12,444건 +
  • +
  • + 키워드 + 부동산,아파트,건물,실거래,건축 +
  • +
+
+
+ + +
+
+
+
+
+ 공공행정 + 공공기관 +
+ + + 한국부동산원(구.한국감정원)에서 제공하는 공동주택 실거래가격지수중 아파트의 월별 지역별 매매 데이터입니다. - (기준시점 : 2017.11 = 100) ㅇ 국가통계 승인번호 : 제116072호 + +
    +
  • + 제공기관 + 한국부동산원 +
  • +
  • + 수정일 + 2025-06-10 +
  • +
  • + 조회수 + 27,252건 +
  • +
  • + 다운로드 + 5,140건 +
  • +
  • + 키워드 + 실거래가격지수,아파트 월별 매매,지역별 매매,공동주택 +
  • +
+
+
+ + +
+
+
+
+
+ 공공행정 + 공공기관 +
+ + + 한국부동산원(구.한국감정원)에서 제공하는 공동주택 실거래가격지수 중 아파트 유형의 분기별 시군구별 매매 데이터입니다. - (기준시점 : 2017.4Q = 100) ㅇ 국가통계 승인번호 + +
    +
  • + 제공기관 + 한국부동산원 +
  • +
  • + 수정일 + 2025-06-10 +
  • +
  • + 조회수 + 23,520건 +
  • +
  • + 다운로드 + 2,448건 +
  • +
  • + 키워드 + 실거래가격지수,아파트 분기별 매매,시군구별 매매,공동주택 +
  • +
+
+
+ + +
+
+
+
+
+ 공공행정 + 공공기관 +
+ + + 한국부동산원(구.한국감정원)에서 제공하는 공동주택 실거래가격지수조사 중 아파트 월별 규모별 전세 데이터입니다. - 단위 : 지수 ㅇ 국가통계 승인번호 : 제116072호 ㅇ 법적근거 + +
    +
  • + 제공기관 + 한국부동산원 +
  • +
  • + 수정일 + 2025-06-10 +
  • +
  • + 조회수 + 13,465건 +
  • +
  • + 다운로드 + 1,888건 +
  • +
  • + 키워드 + 실거래가격지수,아파트 월별 전세,규모별 전세,공동주택 +
  • +
+
+
+ + +
+
+
+
+ + +
+
+

오픈API + + (4건) + +

+ + 더보기 + +
+
+
+
+
+ 공공행정 + 국가행정기관 + 국가중점 +
+ + + 행정표준코드관리시스템(www.code.go.kr)의 법정동 코드 중 앞5자리(예시 : 서울 종로구 - 11110), 계약년월 6자리(예시 : 201801)로 해당 지역, 해당 기간의 아파트 + +
    +
  • + 제공기관 + 국토교통부 +
  • +
  • + 수정일 + 2026-07-22 +
  • +
  • + 조회수 + 125,638회 +
  • +
  • + 활용신청 + 18,929건 +
  • +
  • + 키워드 + 실거래가,매매,거래,아파트,주택 +
  • +
+
+
+ + + +
+
+
+
+
+ 공공행정 + 국가행정기관 + 국가중점 +
+ + + 행정표준코드관리시스템(www.code.go.kr)의 법정동 코드 중 앞5자리(예시 : 서울 종로구 - 11110), 계약년월 6자리(예시 : 201801)로 해당 지역, 해당 기간의 아파트 + +
    +
  • + 제공기관 + 국토교통부 +
  • +
  • + 수정일 + 2026-09-08 +
  • +
  • + 조회수 + 64,856회 +
  • +
  • + 활용신청 + 14,463건 +
  • +
  • + 키워드 + 실거래가,매매,거래,아파트,주택 +
  • +
+
+
+ + + +
+
+
+
+
+ 공공행정 + 국가행정기관 + 국가중점 +
+ + + 행정표준코드관리시스템(www.code.go.kr)의 법정동 코드 중 앞5자리(예시 : 서울 종로구 - 11110), 계약년월 6자리(예시 : 201801)로 해당 지역, 해당 기간의 아파트 + +
    +
  • + 제공기관 + 국토교통부 +
  • +
  • + 수정일 + 2026-07-29 +
  • +
  • + 조회수 + 54,125회 +
  • +
  • + 활용신청 + 10,426건 +
  • +
  • + 키워드 + 실거래가,임대차,전월세,아파트,주택 +
  • +
+
+
+ + + +
+
+
+
+
+ 공공행정 + 국가행정기관 + 국가중점 +
+ + + 자료로서 행정표준코드관리시스템(www.code.go.kr)의 법정동 코드 중 앞5자리(예시 : 서울 종로구 - 11110), 계약년월(예시 : 201801)로 해당 지역, 해당 기간의 아파트 + +
    +
  • + 제공기관 + 국토교통부 +
  • +
  • + 수정일 + 2026-07-22 +
  • +
  • + 조회수 + 19,389회 +
  • +
  • + 활용신청 + 2,580건 +
  • +
  • + 키워드 + 실거래가,매매,거래,아파트,주택 +
  • +
+
+
+ + + +
+
+
+ + +
+ + + + + +
+
+

표준데이터셋 + 0개 (0건) +

+ + 더보기 + +
+
+
+
+ 검색 결과가 없습니다. +
+
+
+
+ + +
+
+

연계데이터(68건)

+ + 더보기 + +
+
+

연계데이터'는 「공공데이터 관리지침」 제14조에 따라, 개별 포털로부터 수집되는 데이터입니다.

+
+
+
+
+
+ 도시 + 경기도 AI데이터행정과 +
+ + + 경기도 내 아파트 매매 자료(아파트, 면적, 지번 등)의 실거래 상세자료 현황 입니다. 년도별로 아파트의 거래금액, 전용면적 등의 정보를 제공합니다. + +
    +
  • + 제공기관(플랫폼) + 경기도 AI데이터행정과 +
  • +
  • + 수정일 + 2026-04-28 +
  • +
  • + 조회수 + 47건 +
  • +
  • + 키워드 +
  • + 아파트매매실거래,아파트매매실거래상세자료,실거래상세자료현황,경기도 아파트,경기도 부동산,아파트 거래금액,부동산 거래신고,국가중점데이터,아파트매매,아파트,매매,매매 상세,아파트 상세,실거래,경기도 부동산 현황 +
+
+
+
+
+
+ 건설에너지 + 한국감정평가사협회(금융) +
+ + + 2016년부터 현재까지 부동산거래 및 주택거래에 대한 신고를 한 아파트의 매매 전세 월세 등의 계약(거래)내역 데이터를 기반으로 식별코드 좌표 토지특성등을 추가한 자료 + +
    +
  • + 제공기관(플랫폼) + 한국감정평가사협회(금융) +
  • +
  • + 수정일 + 2026-04-28 +
  • +
  • + 조회수 + 28건 +
  • +
  • + 키워드 +
  • + 감정,평가,부동산,평가사,감정평가,평가협회,감정평가사,협회데이터,감정평가협회,협회데이터센터 +
+
+
+
+
+
+ 주택 + 경기도 +
+ + + 이 데이터는 경기도 내 아파트 매매 실거래가 정보를 원천 수준에서 수집한 기본자료입니다. 주요 항목으로는 주소, 지번주소, 지번본번, 지번부번, 단지명, 전용면적, 계약연월, 계약일, 거래금액, 층명, 건축년도, 도로명 등이 포함됩니다. 이 데이터는 특정 시점과 지역에서의 거래가격, 면적, 건축연도, 위치좌표 등을 기반으로 주택 가격 변동을 추적하고, 지역별 부동산 시장 동향을 파악하는 데 활용할 수 있습니다. 또한 부동산 가치평가, 주거정책 수립, 도시개발 계획 수립, 금융기관의 담보평가, 연구기관의 주거환경 분석 등에 유용하게 사용될 수 있습니다. + +
    +
  • + 제공기관(플랫폼) + 경기도 +
  • +
  • + 수정일 + 2026-04-28 +
  • +
  • + 조회수 + 23건 +
  • +
  • + 키워드 +
  • + 분석포털,부동산,주택가,아파트거래,계약정보,거래정보 +
+
+
+
+
+
+ 건설에너지 + 아파트엔(공간데이터마켓) +
+ + + ○ 아파트단지 공시가격 분석 정보 1. 설명 - 아파트단지의 면적유형(초소형, 소형, 중형, 중대형, 대형)별 실거래가 정보 2. 갱신방법 - 월별 신규 데이터 생성 3. CSV 설명 - 인코딩 : EUC-KR - 구분자 : , - QUOTE 문자: ' + +
    +
  • + 제공기관(플랫폼) + 아파트엔(공간데이터마켓) +
  • +
  • + 수정일 + 2026-04-28 +
  • +
  • + 조회수 + 11건 +
  • +
  • + 키워드 +
  • + 부동산,아파트,실거래가,아파트단지,아파트 가격,부동산 가격 동향,아파트 가격 동향 +
+
+
+
+
+
+ 도시 + 경기도 AI데이터행정과 +
+ + + 경기도 내 아파트 전월세 자료 현황입니다. 부동산 거래 신고에 관한 법률에 따라 신고된 아파트 전월세 자료(아파트, 면적, 지번 등)를 제공하는 아파트 전월세 신고현황입니다. 아파트명, 보증금액, 월세금액, 전용면적 등의 정보를 제공합니다. + +
    +
  • + 제공기관(플랫폼) + 경기도 AI데이터행정과 +
  • +
  • + 수정일 + 2026-04-28 +
  • +
  • + 조회수 + 29건 +
  • +
  • + 키워드 +
  • + 국가중점데이터,아파트전월세자료현황,아파트자료현황,전월세자료현황,아파트 현황,아파트전월세,전세,아파트신고,부동산,아파트정보,아파트 정보,실거래 +
+
+
+
+
+ + + + +