"""3-suspect 예시: 종로구 밤 상권 가설을 의심한다.

표준 라이브러리만 씁니다(pandas 없이도 실행). 같은 폴더에 eda_report.html, traps_check.md를 만듭니다.
실행: python 3_eda.py   (자료실 백업 데이터 ../data/*.csv를 읽음)
"""
import csv, collections, datetime as dt, html, statistics as st
from pathlib import Path

HERE = Path(__file__).resolve().parent
DATA = HERE.parent / 'data'
GU = {'11110': '종로구', '11140': '중구', '11440': '마포구', '11680': '강남구'}
BANDS = [('00-06', range(0, 6)), ('06-11', range(6, 11)), ('11-14', range(11, 14)),
         ('14-17', range(14, 17)), ('17-21', range(17, 21)), ('21-24', range(21, 24))]
HOURS = {b: len(r) for b, r in BANDS}
DAY = ['11-14', '14-17', '17-21']
TB = {b: f'시간대_{b.replace("-", "~")}_매출_금액' for b, _ in BANDS}
HOLI = {'20250101': '신정', '20250127': '설 연휴', '20250128': '설 연휴', '20250129': '설날', '20250130': '설 연휴',
        '20250303': '대체공휴일', '20250505': '어린이날', '20250506': '대체공휴일', '20250603': '대선',
        '20250606': '현충일', '20250815': '광복절', '20251003': '개천절', '20251005': '추석 연휴',
        '20251006': '추석', '20251007': '추석 연휴', '20251008': '대체공휴일', '20251009': '한글날', '20251225': '성탄절'}
WD = ['월', '화', '수', '목', '금', '토', '일']


def band(h):
    return next(b for b, r in BANDS if h in r)


def read(name):
    with open(DATA / name, encoding='utf-8-sig') as f:
        return list(csv.DictReader(f))


# ---------- 0. 불러오기와 상태 확인 ----------
pop = read('pop_gu_2025_4gu_hourly.csv')
sales = read('sales_2025_4gu.csv')
mp = {r['상권_코드']: r for r in read('trdar_mapping_seoul.csv')}
popcols = [c for c in pop[0] if c.startswith(('male', 'female'))]

chk = {}
chk['pop_rows'] = len(pop)
chk['pop_empty'] = sum(1 for r in pop for v in r.values() if not v.strip())
k = collections.Counter((r['stdr_de_id'], r['tmzon_pd_se'], r['adstrd_code_se']) for r in pop)
chk['pop_dups'] = sum(v - 1 for v in k.values() if v > 1)
dates = {r['stdr_de_id'] for r in pop}
alld = {(dt.date(2025, 1, 1) + dt.timedelta(i)).strftime('%Y%m%d') for i in range(365)}
chk['missing_dates'] = sorted(alld - dates)
chk['sum_gap'] = max(abs(sum(float(r[c]) for c in popcols) - float(r['tot_lvpop_co'])) for r in pop)
chk['sales_rows'] = len(sales)
chk['sales_empty'] = sum(1 for r in sales for v in r.values() if not v.strip())
k = collections.Counter((r['기준_년분기_코드'], r['상권_코드'], r['서비스_업종_코드']) for r in sales)
chk['sales_dups'] = sum(v - 1 for v in k.values() if v > 1)
chk['unmapped'] = len({r['상권_코드'] for r in sales if r['상권_코드'] not in mp})
chk['band_mismatch'] = sum(1 for r in sales if float(r['당월_매출_금액']) > 0 and
                           abs(sum(float(r[c]) for c in TB.values()) / float(r['당월_매출_금액']) - 1) > 0.01)
trd_sales = {g: len({r['상권_코드'] for r in sales if mp[r['상권_코드']]['시군구_코드'] == g}) for g in GU}
trd_map = {g: sum(1 for m in mp.values() if m['시군구_코드'] == g) for g in GU}

# ---------- 1. 생활인구 모양 ----------
hr = collections.defaultdict(list)
bp = collections.defaultdict(list)
bpq = collections.defaultdict(list)
daily = collections.defaultdict(list)
for r in pop:
    g, h, v, d = r['adstrd_code_se'], int(r['tmzon_pd_se']), float(r['tot_lvpop_co']), r['stdr_de_id']
    hr[(g, h)].append(v)
    bp[(g, band(h))].append(v)
    bpq[(g, (int(d[4:6]) - 1) // 3 + 1, band(h))].append(v)
    if g == '11110':
        daily[d].append(v)
hourly = {g: [st.mean(hr[(g, h)]) for h in range(24)] for g in GU}
popb = {g: {b: st.mean(bp[(g, b)]) for b, _ in BANDS} for g in GU}
dm = sorted((d, st.mean(v)) for d, v in daily.items())
dmed = st.median(v for _, v in dm)
low = sorted(dm, key=lambda x: x[1])[:6]
high = sorted(dm, key=lambda x: -x[1])[:5]
wdpop = collections.defaultdict(list)
for d, v in dm:
    wdpop[dt.date(int(d[:4]), int(d[4:6]), int(d[6:])).weekday()].append(v)
wdpop = [st.mean(wdpop[i]) for i in range(7)]


def grp(c):
    lo = int(c.split('_')[1][1:].split('t')[0])
    a = '0-19' if lo < 20 else f'{lo // 10 * 10}대' if lo < 70 else '70+'
    return ('남' if c.startswith('male') else '여'), a


ga = {'낮(11~21시)': collections.Counter(), '밤(21~24시)': collections.Counter()}
n = collections.Counter()
for r in pop:
    if r['adstrd_code_se'] != '11110':
        continue
    h = int(r['tmzon_pd_se'])
    key = '낮(11~21시)' if 11 <= h < 21 else '밤(21~24시)' if h >= 21 else None
    if key:
        n[key] += 1
        for c in popcols:
            ga[key][grp(c)] += float(r[c])
ga = {k: {g: v / n[k] for g, v in c.items()} for k, c in ga.items()}
AGES = ['0-19', '20대', '30대', '40대', '50대', '60대', '70+']

# ---------- 2. 매출과 1인당 매출 ----------
QDAYS = {1: 90, 2: 91, 3: 92, 4: 92}


def sales_by(g, filt=lambda r: True, q=None):
    s = collections.defaultdict(float)
    for r in sales:
        if mp[r['상권_코드']]['시군구_코드'] != g or not filt(r):
            continue
        if q and int(r['기준_년분기_코드'][-1]) != q:
            continue
        for b, c in TB.items():
            s[b] += float(r[c])
    return s


def per_person(g, filt=lambda r: True, q=None, per_hour=True):
    s = sales_by(g, filt, q)
    days = QDAYS[q] if q else 365
    out = {}
    for b, _ in BANDS:
        p = st.mean(bpq[(g, q, b)]) if q else popb[g][b]
        out[b] = s[b] / days / p / (HOURS[b] if per_hour else 1)
    return out


def drop(pp):
    day = st.mean(pp[b] for b in DAY)
    return (pp['21-24'] / day - 1) * 100


FOOD = lambda r: r['서비스_업종_코드'] < 'CS100011'          # 한식~커피-음료 10개 외식 업종
NOJEW = lambda r: r['서비스_업종_코드_명'] != '시계및귀금속'
pp_band = per_person('11110', per_hour=False)
pp_hour = per_person('11110')
m = {'리포트 방식(구간당)': drop(pp_band), '시간당으로 보정': drop(pp_hour),
     '시계·귀금속 제외': drop(per_person('11110', NOJEW)), '외식 10개 업종만': drop(per_person('11110', FOOD))}
byq = {q: drop(per_person('11110', q=q)) for q in range(1, 5)}
bygu = {GU[g]: drop(per_person(g)) for g in GU}
bygu_food = {GU[g]: drop(per_person(g, FOOD)) for g in GU}
food_pp = per_person('11110', FOOD)
s_all = sales_by('11110')
tot = sum(s_all.values())
share = {b: s_all[b] / tot * 100 for b in TB}
s_jew = sales_by('11110', lambda r: not NOJEW(r))
jew_share = sum(s_jew.values()) / tot * 100
jew_night = s_jew['21-24'] / sum(s_jew.values()) * 100
s_food = sales_by('11110', FOOD)
food_share = sum(s_food.values()) / tot * 100
wdsales = [0.0] * 7
for r in sales:
    if mp[r['상권_코드']]['시군구_코드'] == '11110':
        for i, w in enumerate(WD):
            wdsales[i] += float(r[f'{w}요일_매출_금액'])
ndays = [sum(1 for i in range(365) if (dt.date(2025, 1, 1) + dt.timedelta(i)).weekday() == w) for w in range(7)]
wdsales = [wdsales[i] / ndays[i] for i in range(7)]
upj = collections.Counter()
for r in sales:
    if mp[r['상권_코드']]['시군구_코드'] == '11110':
        upj[r['서비스_업종_코드_명']] += int(float(r['당월_매출_건수']))
small = sorted(upj.items(), key=lambda x: x[1])[:3]
night_ratio = popb['11110']['21-24'] / st.mean(popb['11110'][b] for b in DAY) * 100
weekday_avg = st.mean(wdpop[:5])


# ---------- SVG 도우미 ----------
ACC, GRAY, INK, MUTED = '#ea4b71', '#b9b9c3', '#1a1a22', '#6f6f7a'
E = html.escape


def fmt(v, unit=''):
    return f'{v:,.0f}{unit}'


def vbars(labels, values, w=640, h=260, color=None, hl=None, unit='', fmtf=None, neg=False):
    fmtf = fmtf or (lambda v: fmt(v, unit))
    pad_l, pad_b, pad_t = 10, 34, 24
    vmax = max(abs(v) for v in values) or 1
    bw = (w - pad_l * 2) / len(values)
    out = [f'<svg viewBox="0 0 {w} {h}" role="img">']
    base = pad_t if neg else h - pad_b
    for i, (l, v) in enumerate(zip(labels, values)):
        bh = abs(v) / vmax * (h - pad_b - pad_t - 6)
        x = pad_l + i * bw + bw * 0.18
        y = base if neg else base - bh
        c = (color[i] if isinstance(color, list) else color) or (ACC if hl and l in hl else GRAY)
        out.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{bw * 0.64:.1f}" height="{bh:.1f}" rx="3" fill="{c}"/>')
        ty = (y + bh + 14) if neg else (y - 6)
        out.append(f'<text x="{x + bw * 0.32:.1f}" y="{ty:.1f}" text-anchor="middle" font-size="12" fill="{INK}" font-weight="600">{E(fmtf(v))}</text>')
        out.append(f'<text x="{x + bw * 0.32:.1f}" y="{h - 12}" text-anchor="middle" font-size="12" fill="{MUTED}">{E(l)}</text>')
    out.append('</svg>')
    return ''.join(out)


def hbars(rows, w=640, unit='%', hl=None):
    rh, lw = 34, 170
    h = rh * len(rows) + 10
    vmax = max(abs(v) for _, v in rows) or 1
    out = [f'<svg viewBox="0 0 {w} {h}" role="img">']
    for i, (l, v) in enumerate(rows):
        y = 6 + i * rh
        bw = abs(v) / vmax * (w - lw - 90)
        c = ACC if hl and l in hl else GRAY
        out.append(f'<text x="{lw - 10}" y="{y + 19}" text-anchor="end" font-size="13" fill="{INK}">{E(l)}</text>')
        out.append(f'<rect x="{lw}" y="{y + 4}" width="{max(bw, 2):.1f}" height="22" rx="3" fill="{c}"/>')
        out.append(f'<text x="{lw + max(bw, 2) + 8:.1f}" y="{y + 20}" font-size="13" font-weight="700" fill="{INK}">{v:+.1f}{unit}</text>')
    out.append('</svg>')
    return ''.join(out)


def lines(series, labels, w=640, h=260, marks=(), ymin=None):
    pad_l, pad_r, pad_t, pad_b = 46, 12, 16, 30
    allv = [v for _, vs, _ in series for v in vs if v is not None]
    lo = ymin if ymin is not None else min(allv)
    hi = max(allv)
    X = lambda i: pad_l + i / (len(labels) - 1) * (w - pad_l - pad_r)
    Y = lambda v: pad_t + (1 - (v - lo) / (hi - lo)) * (h - pad_t - pad_b)
    out = [f'<svg viewBox="0 0 {w} {h}" role="img">']
    for t in range(5):
        v = lo + (hi - lo) * t / 4
        out.append(f'<line x1="{pad_l}" x2="{w - pad_r}" y1="{Y(v):.1f}" y2="{Y(v):.1f}" stroke="#eee"/>')
        out.append(f'<text x="{pad_l - 6}" y="{Y(v) + 4:.1f}" text-anchor="end" font-size="10" fill="{MUTED}">{v / 10000:,.0f}만</text>')
    for i, l in enumerate(labels):
        if l:
            out.append(f'<text x="{X(i):.1f}" y="{h - 10}" text-anchor="middle" font-size="11" fill="{MUTED}">{E(l)}</text>')
    for name, vs, c in series:
        pts, seg = [], []
        for i, v in enumerate(vs):
            if v is None:
                if seg:
                    pts.append(seg)
                seg = []
            else:
                seg.append(f'{X(i):.1f},{Y(v):.1f}')
        if seg:
            pts.append(seg)
        for s in pts:
            out.append(f'<polyline points="{" ".join(s)}" fill="none" stroke="{c}" stroke-width="{2.4 if c == ACC else 1.6}"/>')
    for i, txt in marks:
        v = next(vs[i] for _, vs, c in series if c == ACC)
        if v is not None:
            out.append(f'<circle cx="{X(i):.1f}" cy="{Y(v):.1f}" r="4" fill="{INK}"/><text x="{X(i) + 6:.1f}" y="{Y(v) + 14:.1f}" font-size="11" fill="{INK}">{E(txt)}</text>')
    out.append('</svg>')
    return ''.join(out)


def legend(items):
    return '<div class="legend">' + ''.join(f'<span><i style="background:{c}"></i>{E(n)}</span>' for n, c in items) + '</div>'


# ---------- 그래프 ----------
COL = {'종로구': ACC, '중구': '#5b7bd5', '마포구': '#3aa57a', '강남구': '#c79a2a'}
g_hour = lines([(GU[g], hourly[g], COL[GU[g]]) for g in GU], [f'{h}시' if h % 3 == 0 else '' for h in range(24)], ymin=0) \
    + legend([(n, c) for n, c in COL.items()])
g_pp = vbars([b for b, _ in BANDS], [pp_hour[b] for b, _ in BANDS], hl=['21-24'], unit='원')
g_wd = vbars(WD, wdpop, hl=['토', '일'], fmtf=lambda v: f'{v / 10000:.1f}만')
g_wds = vbars(WD, wdsales, hl=['토', '일'], fmtf=lambda v: f'{v / 1e8:.0f}억')
ga_vals, ga_lab, ga_col = [], [], []
for a in AGES:
    for s, c in (('남', '#5b7bd5'), ('여', ACC)):
        ga_lab.append(f'{a}' if s == '남' else '')
        ga_vals.append(ga['밤(21~24시)'][(s, a)] / ga['낮(11~21시)'][(s, a)] * 100)
        ga_col.append(c)
g_ga = vbars(ga_lab, ga_vals, color=ga_col, fmtf=lambda v: f'{v:.0f}%') + legend([('남', '#5b7bd5'), ('여', ACC)])
mon = [d for d, _ in dm]
seq, labs, marks = [], [], []
cur = dt.date(2025, 1, 1)
dmd = dict(dm)
for i in range(365):
    d = (cur + dt.timedelta(i)).strftime('%Y%m%d')
    seq.append(dmd.get(d))
    labs.append(f'{int(d[4:6])}월' if d[6:] == '01' else '')
    if d in ('20251006', '20250129'):
        marks.append((i, '추석' if d == '20251006' else '설'))
g_daily = lines([('종로구', seq, ACC)], labs, marks=marks, ymin=150000)
g_method = hbars(list(m.items()), hl=['외식 10개 업종만'])
g_gu = hbars(list(bygu.items()), hl=['종로구'])
g_gu_food = hbars(list(bygu_food.items()), hl=['종로구'])
g_food = vbars([b for b, _ in BANDS], [food_pp[b] for b, _ in BANDS], hl=['21-24'], unit='원')

# ---------- 함정 판정표 ----------
traps = [
    ('기준 기간', '확인함', f'2025년 1년치. 4개 분기를 따로 계산해도 밤 낙폭(시간당)이 {abs(max(byq.values())):.1f}~{abs(min(byq.values())):.1f}% 하락으로 비슷해 특정 계절 탓이 아님. 설·추석이 가장 낮은 날이지만 연평균에 주는 영향은 작음.',
     '분기별 숫자를 리포트 근거에 같이 적는다.'),
    ('절대 vs 변화율', '확인함', f'질문이 "밤에 얼마나 떨어지나"라 낮 대비 변화율이 맞음. 다만 21~24시 체류인구 {popb["11110"]["21-24"] / 10000:.1f}만 명(낮의 {night_ratio:.0f}%)이라는 절대 규모도 같이 적음.',
     '변화율 옆에 절대 인원을 병기한다.'),
    ('표본 크기', '걸림', f'종로구 업종 {len(upj)}개 중 {small[0][0]}({small[0][1]:,}건/년), {small[1][0]}({small[1][1]:,}건) 등 건수가 아주 적은 업종이 있음. 관광특구는 종로구에 1개 상권뿐.',
     '업종별 %는 외식 10개 업종 묶음으로만 쓰고, "관광특구는 ~하다"는 1개 상권 사례로만 서술.'),
    ('평균의 함정', '걸림 ★', f'시계·귀금속 1개 업종이 종로구 연매출의 {jew_share:.0f}%이고, 그중 밤(21~24시) 매출은 {jew_night:.1f}%뿐. 이 업종을 빼면 낙폭이 {m["시간당으로 보정"]:.0f}% → {m["시계·귀금속 제외"]:.0f}%로 줄고, 외식 10개 업종만 보면 {m["외식 10개 업종만"]:.1f}%로 거의 안 떨어짐.',
     '헤드라인을 "밤에 지갑이 닫힌다"에서 "밤에 문 닫는 낮 장사(귀금속·소매) 효과가 크다. 외식은 유지"로 바꿀지 ④에서 결정.'),
    ('상관 ≠ 인과', '걸림', '"밤에 사람이 남아 있으니 밤 상권을 키우면 매출이 는다"는 인과가 아님. 밤 체류인구에는 집에 있는 주민이 섞여 있고, 개입(야간 행사 등) 전후 데이터도 없음.',
     '"사람은 남아 있다"까지만 쓰고, 효과는 "시범 후 측정할 가설"로 넘긴다.'),
    ('단위 정의', '걸림', f'① 매출 시간대 구간 길이가 3~6시간으로 달라 "구간당 1인당 매출"은 긴 구간이 유리함. 시간당으로 바꾸면 {m["리포트 방식(구간당)"]:.1f}% → {m["시간당으로 보정"]:.1f}%. ② 생활인구는 "그 시각에 머문 사람 수"(스냅샷), 매출은 누적이라 1인당 매출은 "혼잡도 대비 매출 밀도"임. ③ 영문 컬럼 f70t74는 같은 계열 한글 파일 기준 "70세 이상".',
     '리포트 수치를 시간당 기준(-53%)으로 바꾸고, "1인당"의 뜻을 각주로 단다.'),
    ('지표 대리 오류', '걸림', f'체류인구 ≠ 관광객. 종로구 일평균 인구는 평일 {weekday_avg / 10000:.1f}만, 일요일 {wdpop[6] / 10000:.1f}만이고 가장 붐빈 5일이 모두 평일 → 직장인 비중이 큼. 추정매출은 카드 결제 기반 추정치.',
     '"관광객 1인당"이 아니라 "체류인구 1인당 추정매출"로 표기.'),
    ('소스 편향', '걸림', '추정매출은 카드 결제만 잡혀 현금 비중이 큰 시장·노점은 덜 잡힐 수 있음. 자치구 생활인구는 내국인만이라 외국인 관광객이 빠짐.',
     '④ "한계" 카드로 넘김. 외국인은 데이터랩 외국인 방문자 증감으로 따로 본다.'),
    ('데이터 범위·상한', '걸림', f'생활인구 원본에 {", ".join(d[4:6] + "/" + d[6:] for d in chk["missing_dates"])}이 없음(서울 전체 원본 파일 자체가 비어 있음, 363일). 매출은 매핑표의 종로구 상권 {trd_map["11110"]}개 중 {trd_sales["11110"]}개만 행이 있음.',
     '"363일 기준", "매출 행이 있는 68개 상권 기준"을 리포트에 명시.'),
    ('구조 보정', '걸림 ★', f'밤 낙폭은 4개 구 모두 큼({", ".join(f"{k} {v:.0f}%" for k, v in bygu.items())}). 종로구만의 특징이 아니라 도시 전반의 자명한 패턴. 외식만 보면 종로 {bygu_food["종로구"]:.1f}%, 마포 {bygu_food["마포구"]:+.1f}%.',
     '"종로구가 특히 약하다"는 주장 금지. 비교 기준으로 마포구를 같이 보여준다.'),
]


def traps_md():
    L = ['# traps_check.md — 종로구 밤 상권 가설 (3-suspect 예시)', '',
         '- 가설: hypothesis.md (21시 이후 체류인구는 남는데 1인당 매출은 낮 평균보다 30% 이상 낮다)',
         '- 데이터: 자료실 백업 데이터 pop_gu_2025_4gu_hourly.csv, sales_2025_4gu.csv, trdar_mapping_seoul.csv (2025년)',
         '- 재현: python 3_eda.py', '',
         '| # | 함정 | 판정 | 근거 | 조치 |', '|---|---|---|---|---|']
    for i, (t, v, why, act) in enumerate(traps, 1):
        L.append(f'| {i} | {t} | {v} | {why} | {act} |')
    L += ['', '## 트렌드·지역 추가 함정 (trend-traps.md)', '',
          '| 함정 | 판정 | 근거 |', '|---|---|---|',
          '| T1 동음이의어 · T2 언급량≠목적지 · T3 저볼륨 노이즈 · T5 매체 편향 | 해당없음 | 검색량·SNS 언급량이 아니라 공식 집계(생활인구·카드 추정매출)를 씀 |',
          '| T4 상시 vs 신규 | 해당없음 | 특정 이벤트의 효과를 주장하지 않음 |',
          '| T6 팬데믹 기준연도 | 해당없음 | 2025년 단년 비교, 2020~2022를 기준으로 쓰지 않음 |', '',
          '## 가설 판정 (hypothesis.md 기준)', '',
          f'- 조건 1 (밤 체류인구가 낮의 60% 이상): 충족 — {night_ratio:.0f}%',
          f'- 조건 2 (1인당 매출 낙폭 30% 이상): 전체는 충족({m["시간당으로 보정"]:.0f}%, 시간당), 외식 10개 업종은 미충족({m["외식 10개 업종만"]:.1f}%)',
          f'- 조건 3 (4개 분기 모두 같은 방향): 충족 — {", ".join(f"{q}분기 {v:.0f}%" for q, v in byq.items())}',
          '- 결론: **부분 지지.** 밤에 1인당 매출이 떨어지는 건 맞지만, 주로 낮 장사(귀금속·소매)가 문을 닫아서다. 외식은 밤에도 낮 평균 수준.', '',
          '## ④로 넘길 것', '',
          f'- 검증된 수치: 21~24시 체류인구 {popb["11110"]["21-24"] / 10000:.1f}만 명, 시간당 1인당 추정매출 낙폭 {m["시간당으로 보정"]:.0f}%, 외식 {m["외식 10개 업종만"]:.1f}%, 분기별 {abs(max(byq.values())):.0f}~{abs(min(byq.values())):.0f}% 하락',
          '- 한계 카드: 체류인구≠관광객, 카드 기반 추정, 내국인만, 7/8·7/9 결측, 68개 상권 기준, 인과 아님']
    return '\n'.join(L) + '\n'


# ---------- HTML ----------
def pill(v):
    c = 'bad' if v.startswith('걸림') else 'ok' if v == '확인함' else 'na'
    return f'<span class="pill {c}">{E(v)}</span>'


trap_rows = ''.join(f'<tr><td>{i}</td><td><b>{E(t)}</b></td><td>{pill(v)}</td><td>{E(why)}</td><td>{E(act)}</td></tr>'
                    for i, (t, v, why, act) in enumerate(traps, 1))
low_txt = ', '.join(f'{d[4:6]}/{d[6:]} {HOLI.get(d, "")} {v / 10000:.1f}만' for d, v in low)
high_txt = ', '.join(f'{d[4:6]}/{d[6:]}({WD[dt.date(int(d[:4]), int(d[4:6]), int(d[6:])).weekday()]}) {v / 10000:.1f}만' for d, v in high)

page = f'''<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>eda_report — 종로구 밤 상권 가설 의심하기 (3-suspect 예시)</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css">
<style>
body{{margin:0;background:#fafaf8;color:#1a1a22;font-family:Pretendard,-apple-system,"Apple SD Gothic Neo",sans-serif;line-height:1.65}}
main{{max-width:880px;margin:0 auto;padding:40px 20px 80px}}
.kicker{{color:#ea4b71;font-weight:700;font-size:13px;letter-spacing:.06em}}
h1{{font-size:30px;line-height:1.3;margin:6px 0 10px}} h2{{font-size:21px;margin:44px 0 6px}} h3{{font-size:16px;margin:22px 0 4px}}
.lead{{color:#3d3d48;font-size:16px}} .muted{{color:#6f6f7a;font-size:13px}}
.card{{background:#fff;border:1px solid rgba(15,15,20,.08);border-radius:14px;padding:18px 20px;margin:14px 0;box-shadow:0 1px 2px rgba(15,15,20,.05)}}
.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}}
.kpi b{{display:block;font-size:24px}} .kpi span{{color:#6f6f7a;font-size:13px}}
.alert{{background:#fdeef2;border:1px solid rgba(234,75,113,.35)}}
.find{{border-left:4px solid #ea4b71;padding:4px 0 4px 14px;margin:12px 0;font-weight:600}}
svg{{width:100%;height:auto;font-family:inherit}}
table{{border-collapse:collapse;width:100%;font-size:13.5px}} th,td{{border-bottom:1px solid #eee;padding:8px 6px;text-align:left;vertical-align:top}} th{{color:#6f6f7a;font-weight:600}}
.pill{{display:inline-block;padding:2px 8px;border-radius:99px;font-size:12px;font-weight:700;white-space:nowrap}} .pill.bad{{background:#fdeef2;color:#d62553}} .pill.ok{{background:#e8f6ef;color:#1f7a50}} .pill.na{{background:#f0f0f3;color:#6f6f7a}}
.legend{{display:flex;gap:14px;font-size:12px;color:#6f6f7a;margin-top:4px}} .legend i{{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px}}
code{{background:#f3f3ef;padding:1px 5px;border-radius:4px;font-size:13px}}
.tablewrap{{overflow-x:auto}} .traps td:nth-child(2),.traps td:nth-child(3){{white-space:nowrap}} .traps td:nth-child(5){{min-width:180px;color:#3d3d48}}
</style></head><body><main>
<div class="kicker">3-SUSPECT 예시 · eda_report.html</div>
<h1>종로구 밤 상권 가설, 결론 내기 전에 의심해 보기</h1>
<p class="lead">가설: <b>"종로구는 밤 9시 이후에도 사람은 남아 있는데, 1인당 매출은 낮보다 크게 떨어진다."</b><br>
종로구 예시 리포트의 숫자(−57.3%)를 3-suspect 방식으로 다시 뜯어봤습니다. 결론부터: <b>숫자는 재현되지만, 해석은 바뀌어야 합니다.</b></p>
<p class="muted">데이터: 자료실 백업 데이터(서울 열린데이터광장 2025년 자치구 생활인구·상권 추정매출·상권 영역) · 재현: <code>python 3_eda.py</code> · 같은 폴더 <a href="traps_check.md">traps_check.md</a> · <a href="hypothesis.md">hypothesis.md</a></p>

<div class="card alert"><b>이 의심 단계에서 바뀐 것 3가지</b>
<div class="find">① 시간대 구간 길이(3~6시간)를 보정하면 낙폭은 −57.3%가 아니라 <u>−{abs(m["시간당으로 보정"]):.0f}%</u></div>
<div class="find">② 외식 10개 업종만 보면 밤 1인당 매출 변화는 <u>{m["외식 10개 업종만"]:.1f}%</u> — 거의 안 떨어진다. 빠지는 건 낮 장사(시계·귀금속 등)</div>
<div class="find">③ 밤 낙폭은 강남 {bygu["강남구"]:.0f}%, 중구 {bygu["중구"]:.0f}%도 비슷 — 종로만의 문제가 아니다 (마포 {bygu["마포구"]:.0f}%가 비교 기준)</div></div>

<h2>0. 데이터 상태 확인 (미션 1)</h2>
<p class="muted">②에서 넘어온 preprocessing_report.html의 "남은 문제"를 먼저 보는 단계입니다. 이 예시는 백업 CSV를 직접 다시 검사했습니다.</p>
<div class="card tablewrap"><table><tr><th>항목</th><th>생활인구 (4개 구 × 시간)</th><th>추정매출 (상권 × 업종 × 분기)</th></tr>
<tr><td>행 수</td><td>{chk["pop_rows"]:,}</td><td>{chk["sales_rows"]:,}</td></tr>
<tr><td>빈 값(결측치)</td><td>{chk["pop_empty"]}</td><td>{chk["sales_empty"]}</td></tr>
<tr><td>중복</td><td>{chk["pop_dups"]} (날짜·시간대·구 기준)</td><td>{chk["sales_dups"]} (분기·상권·업종 기준)</td></tr>
<tr><td>빠진 기간</td><td><b style="color:#d62553">2025-07-08, 07-09 이틀 없음</b> → 363일 (원본 파일 자체에 없음)</td><td>1~4분기 모두 있음</td></tr>
<tr><td>합계 검산</td><td>성별·연령 합 = 총인구 (최대 오차 {chk["sum_gap"]:.2f}명)</td><td>시간대 6구간 합 = 분기 매출 (어긋난 행 {chk["band_mismatch"]}개)</td></tr>
<tr><td>범위</td><td>4개 구 모두 8,712행으로 같음</td><td>종로구 매핑표 {trd_map["11110"]}개 상권 중 매출 행 있는 곳 {trd_sales["11110"]}개</td></tr></table></div>
<p><b>이상치 판단:</b> 종로구 일평균 생활인구가 가장 낮은 날은 {E(low_txt)}. 모두 명절이라 <b>오류가 아니라 실제 현상</b>이므로 지우지 않습니다. 가장 높은 날은 {E(high_txt)} — 전부 평일입니다.</p>
<div class="card"><h3>종로구 일평균 생활인구 (2025)</h3>{g_daily}<p class="muted">7월 초 끊긴 부분이 원본 결측 이틀. 바닥 두 곳이 설·추석.</p></div>

<h2>1. 시간대 · 요일 · 성별·연령대 패턴 (미션 2 그래프 3개)</h2>
<div class="card"><h3>① 시간대 — 시간별 평균 생활인구</h3>{g_hour}
<p class="muted">종로·중구는 낮에 몰렸다 밤에 빠지는 "출근형", 마포는 하루 종일 평평한 "주거·여가형"입니다. 21~24시 종로 체류인구 {popb["11110"]["21-24"] / 10000:.1f}만 명 = 낮(11~21시) 평균의 {night_ratio:.0f}%.</p></div>
<div class="card"><h3>② 요일 — 종로구 요일별 일평균 생활인구 / 일평균 추정매출</h3>
<div class="grid"><div>{g_wd}<p class="muted">생활인구</p></div><div>{g_wds}<p class="muted">추정매출 (요일별 합 ÷ 그 요일 수)</p></div></div>
<p class="muted">주말에 사람도 매출도 줄어듭니다. 종로구 체류인구는 관광객보다 <b>직장인</b> 영향이 크다는 신호 → 함정 7(지표 대리).</p></div>
<div class="card"><h3>③ 성별·연령대 — 밤(21~24시) 인구가 낮의 몇 %로 남나</h3>{g_ga}
<p class="muted">0~19세(남 {ga_vals[0]:.0f}%·여 {ga_vals[1]:.0f}%)와 20대 남성({ga_vals[2]:.0f}%)이 밤에 가장 많이 남고, 30~40대(직장인층, {min(ga_vals[4:8]):.0f}~{max(ga_vals[4:8]):.0f}%)가 가장 많이 빠집니다. 밤에 남는 사람에는 집에 있는 주민(아이·고령층)이 꽤 섞여 있다는 뜻 → 함정 5·7.</p></div>

<h2>2. 숫자 의심하기 — 리포트의 −57.3%를 뜯어보기</h2>
<div class="card"><h3>시간대별 1인당 추정매출 (시간당, 원)</h3>{g_pp}
<p class="muted">시간대 매출 ÷ 365일 ÷ 그 시간대 평균 체류인구 ÷ 구간 시간 수. 리포트 방식은 마지막 "÷ 구간 시간 수"가 없어 6시간짜리 구간이 유리했습니다.</p></div>
<div class="card"><h3>계산 방법·범위에 따라 밤 낙폭이 이렇게 달라집니다 (21~24시 vs 11~21시 평균)</h3>{g_method}
<p class="muted">시계·귀금속은 종로구 연매출의 {jew_share:.0f}%인데, 그 매출의 {jew_night:.1f}%만 밤에 나옵니다. 외식 10개 업종(한식~커피·호프)은 매출의 {food_share:.0f}%.</p></div>
<div class="card"><h3>외식 10개 업종만 — 시간대별 1인당 추정매출 (시간당, 원)</h3>{g_food}
<p class="muted">밤 9시 이후에도 점심·저녁 피크의 {food_pp["21-24"] / food_pp["17-21"] * 100:.0f}% 수준(저녁 대비). "밤에 지갑이 닫힌다"보다 "밤에 문 여는 업종이 적다"에 가깝습니다.</p></div>
<div class="card"><h3>다른 구와 비교 — 전체 업종 / 외식만</h3>
<div class="grid"><div>{g_gu}<p class="muted">전체 업종</p></div><div>{g_gu_food}<p class="muted">외식 10개 업종</p></div></div>
<p class="muted">밤에 1인당 매출이 떨어지는 건 4개 구 공통입니다(자명한 발견). 분기별로 나눠도 종로구 낙폭은 {", ".join(f"{q}분기 {v:.0f}%" for q, v in byq.items())}로 안정적입니다.</p></div>

<h2>3. 함정 10개 판정 (traps_check.md)</h2>
<p class="muted">판정 기준은 hypothesis.md의 것. 해당없음 / 확인함 / 걸림 중 하나. ★ = 결론을 바꾸는 함정.</p>
<div class="card tablewrap"><table class="traps"><tr><th>#</th><th>함정</th><th>판정</th><th>근거</th><th>조치</th></tr>{trap_rows}</table>
<p class="muted">트렌드·지역 추가 함정 6개(T1~T6)는 검색량·SNS를 쓰지 않아 모두 해당없음 — 자세한 근거는 traps_check.md.</p></div>

<h2>4. 가설 판정과 ④로 넘길 것</h2>
<div class="card"><table>
<tr><td>조건 1 · 밤 체류인구가 낮의 60% 이상</td><td>{pill("확인함")} {night_ratio:.0f}%</td></tr>
<tr><td>조건 2 · 1인당 매출 낙폭 30% 이상</td><td>{pill("걸림")} 전체 {m["시간당으로 보정"]:.0f}% 충족 · 외식 {m["외식 10개 업종만"]:.1f}% 미충족</td></tr>
<tr><td>조건 3 · 4개 분기 모두 같은 방향</td><td>{pill("확인함")} {abs(max(byq.values())):.0f}~{abs(min(byq.values())):.0f}% 하락</td></tr></table>
<p><b>판정: 부분 지지.</b> 밤에 1인당 매출이 떨어지는 건 맞지만, 주로 낮 장사가 문을 닫기 때문입니다. 외식은 밤에도 낮 평균 수준이라, "밤 상권을 살리자"의 근거로 쓰려면 <b>업종을 나눠서</b> 말해야 합니다.</p>
<p class="muted">④ 한계 카드: 체류인구≠관광객 · 카드 결제 기반 추정 · 내국인만 · 7/8·7/9 결측 · 매출 행이 있는 68개 상권 기준 · 인과 아님(개입 전후 데이터 없음)</p></div>
<p class="muted">반복은 코드, 판단은 사람. 이 페이지의 "걸림" 중 하나를 골라 원본이나 출처 설명을 직접 열어 확인해 보세요.</p>
</main></body></html>'''

(HERE / 'eda_report.html').write_text(page, encoding='utf-8')
(HERE / 'traps_check.md').write_text(traps_md(), encoding='utf-8')
print('saved eda_report.html, traps_check.md')
print({k: round(v, 1) for k, v in m.items()}, {k: round(v, 1) for k, v in bygu.items()})
