<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>ParkS2.tistory</title>
    <link>https://ojko.tistory.com/</link>
    <description>데이터 분석가 포트폴리오 &amp;mdash; Python, SQL, ML 기반 프로젝트</description>
    <language>ko</language>
    <pubDate>Sun, 14 Jun 2026 12:56:05 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>ParkS2</managingEditor>
    <image>
      <title>ParkS2.tistory</title>
      <url>https://tistory1.daumcdn.net/tistory/6722250/attach/c4a7e3bbd0e94980af8c17f2ccf26cef</url>
      <link>https://ojko.tistory.com</link>
    </image>
    <item>
      <title>GaussianHMM으로 시장 국면을 분류한 방법</title>
      <link>https://ojko.tistory.com/196</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dinsightlab.com&quot;&gt;https://dinsightlab.com&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777799817263&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Passive&quot; data-og-description=&quot;미국 지수 투자자를 위한 금융 데이터 분석 사이트&quot; data-og-host=&quot;dinsightlab.com&quot; data-og-source-url=&quot;https://dinsightlab.com&quot; data-og-url=&quot;https://dinsightlab.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cl6zoB/dJMb88F8ya5/590sgWtrKjuAQ6DzXYaMXk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/EqfSe/dJMb85vSA9S/xX2TlmBmIgNcbhXA6ahKi0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941&quot;&gt;&lt;a href=&quot;https://dinsightlab.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dinsightlab.com&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cl6zoB/dJMb88F8ya5/590sgWtrKjuAQ6DzXYaMXk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/EqfSe/dJMb85vSA9S/xX2TlmBmIgNcbhXA6ahKi0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Passive&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;미국 지수 투자자를 위한 금융 데이터 분석 사이트&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dinsightlab.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;GaussianHMM으로 시장 국면을 분류한 방법&amp;nbsp;&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 기반 미국 주식 분석 플랫폼 &lt;a href=&quot;https://dinsightlab.com/&quot;&gt;Passive&lt;/a&gt;의 핵심 기능인 &lt;b&gt;Noise vs Signal 국면 분류기&lt;/b&gt; 구현 과정을 기록한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 시장 국면 분류가 필요했는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ETF 투자자에게 가장 위험한 순간은 &quot;지금 시장이 이성적으로 움직이고 있는가, 아니면 감정에 의해 움직이고 있는가&quot;를 구분하지 못할 때다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주가가 오르고 있어도 펀더멘털 대비 고평가된 상태에서 감정이 이끄는 상승이라면 매수 타이밍이 아니다. 반대로 주가가 급락해도 펀더멘털이 견고하다면 공포에 의한 과매도 구간일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 판단을 숫자로 만들고 싶었다. &quot;지금 시장이 이성적인가 감정적인가&quot;를 정량화하는 것. 그게 Passive 펀더멘털 탭의 출발점이었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 HMM인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 시도한 방법은 단순한 임계값 기반 분류였다. CAPE가 30 이상이면 고평가, VIX가 25 이상이면 공포 구간이라는 식이었다. 문제는 각 지표가 독립적으로 신호를 내고, 지표끼리 충돌할 때 어떤 걸 우선해야 하는지 기준이 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 지표를 동시에 고려해서 하나의 상태를 추정하는 방법이 필요했다. Hidden Markov Model이 딱 맞았다. 시장의 실제 국면(&quot;숨겨진 상태&quot;)은 직접 관측할 수 없고, 우리가 보는 건 그 상태에서 나온 관측값(지표들)뿐이라는 구조가 시장을 표현하기에 자연스러웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GaussianHMM을 선택한 이유는 연속형 관측값을 다루기 때문이다. CAPE, HY 스프레드, 실현 변동성 같은 지표들은 범주형이 아닌 연속형이고, GaussianHMM은 각 상태에서 관측값이 정규분포를 따른다고 가정한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8개 피처 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델 입력으로 8개 월별 피처를 사용한다. 18년치 월별 데이터(약 219개월)가 학습 데이터다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;FEATURE_NAMES = [
    'fundamental_gap',  # CAPE 기반 가격-이익 괴리
    'erp_zscore',       # 주식 위험 프리미엄 표준화
    'residual_corr',    # 섹터 잔차 상관관계
    'dispersion',       # 종목간 수익률 분산
    'amihud',           # Amihud 비유동성 지표
    'vix_term',         # VIX 기간 구조 (VIX/VIX3M)
    'hy_spread',        # HY 신용 스프레드
    'realized_vol',     # 실현 변동성
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 피처는 시장의 다른 측면을 포착한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;fundamental_gap&lt;/b&gt;은 Shiller 데이터에서 계산한다. 12개월 주가 누적 수익률에서 12개월 EPS 누적 성장률을 뺀 값이다. 양수면 가격이 이익보다 빠르게 올랐다는 뜻, 즉 비싸졌다는 신호다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;shiller['log_P'] = np.log(shiller['P'])
shiller['log_E'] = np.log(shiller['E'].clip(lower=0.01))
fundamental_gap = (shiller['log_P'].diff(12) - shiller['log_E'].diff(12)).dropna()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;erp_zscore&lt;/b&gt;는 주식 위험 프리미엄의 역사적 표준화값이다. ERP = 주식 수익률(1/CAPE) - TIPS 실질금리. 이 값의 rolling z-score를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;erp = erp_df['ey'] - erp_df['tips']
erp_rm = erp.rolling(120, min_periods=60).mean()
erp_rs = erp.rolling(120, min_periods=60).std()
erp_zscore = ((erp - erp_rm) / erp_rs).abs().dropna()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;residual_corr&lt;/b&gt;은 시장 전체 움직임(SPY 베타)을 제거한 잔차 수익률 간 상관관계다. 감정 지배 구간에서는 개별 종목들이 펀더멘털과 무관하게 같은 방향으로 움직이기 때문에 잔차 상관이 높아진다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;cov_spy = ret.rolling(60, min_periods=30).cov(spy_ret)
spy_var = spy_ret.rolling(60, min_periods=30).var()
beta = cov_spy / spy_var
residuals[ticker] = ret - beta * spy_ret
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;vix_term&lt;/b&gt;은 VIX를 VIX3M으로 나눈 값이다. 1보다 크면 단기 공포가 장기보다 크다는 뜻(역전 상태). 감정적 시장에서 이 값이 1을 넘는 경우가 많다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모델 학습&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RobustScaler로 전처리한 뒤 GaussianHMM을 학습한다. full covariance를 먼저 시도하고 실패하면 diag로 폴백한다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;scaler = RobustScaler()
X_scaled = scaler.fit_transform(X)

for cov_type in ('full', 'diag'):
    try:
        model = GaussianHMM(
            n_components=4,
            covariance_type=cov_type,
            n_iter=200,
            random_state=42,
        )
        model.fit(X_scaled)
        break
    except (ValueError, np.linalg.LinAlgError):
        if cov_type == 'diag':
            raise
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RobustScaler를 선택한 이유는 금융 데이터의 이상치 때문이다. 2008년 금융위기나 2020년 코로나 급락 같은 극단적 사건이 StandardScaler의 평균과 분산을 왜곡한다. RobustScaler는 중앙값과 IQR을 기준으로 스케일링하기 때문에 이상치에 강건하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4개 상태를 선택한 이유는 실험 기반이다. 2개는 너무 단순하고 6개 이상은 해석 불가능한 상태가 생겼다. 4개가 &quot;이성 / 약한 이성 / 약한 감정 / 강한 감정&quot; 정도로 해석 가능한 최적점이었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;noise_score: 국면을 하나의 숫자로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HMM이 4개 상태를 분류하지만, 사용자에게 &quot;현재 상태 2번&quot;이라고 보여주는 건 의미가 없다. 직관적인 점수로 변환해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;noise_score는 8개 피처의 가중합으로 계산한다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;def compute_noise_score(means: np.ndarray) -&amp;gt; np.ndarray:
    return (
        0.5 * np.abs(means[:, 0])   # fundamental_gap (절대값)
      + 0.3 * np.abs(means[:, 1])   # erp_zscore (절대값)
      + 1.0 * means[:, 2]           # residual_corr
      + 0.5 * means[:, 4]           # amihud
      + 2.0 * means[:, 5]           # vix_term (가장 높은 가중치)
      + 1.5 * means[:, 6]           # hy_spread
      + 2.0 * means[:, 7]           # realized_vol (가장 높은 가중치)
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가중치 선택 기준은 두 가지다. 첫째, 시장 감정을 직접적으로 반영하는 지표에 높은 가중치를 준다. vix_term과 realized_vol이 2.0인 이유다. 둘째, 절대값을 취하는 지표와 그대로 쓰는 지표를 구분한다. fundamental_gap과 erp_zscore는 방향보다 괴리의 크기가 중요하기 때문에 절대값을 취한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 저장 시 양수가 &quot;감정적 시장&quot;을 의미하도록 부호 컨벤션을 정했고, API 응답 단계에서 부호를 반전해서 사용자에게는 &quot;양수 = 이성적 시장&quot;으로 보여준다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def _flip_noise_record(record):
    &quot;&quot;&quot;DB 저장 부호(양수=감정) &amp;rarr; 표시 부호(양수=이성) 변환.&quot;&quot;&quot;
    if record.get('noise_score') is not None:
        record['noise_score'] = round(-float(record['noise_score']), 4)
    fc = record.get('feature_contributions')
    if isinstance(fc, list):
        for c in fc:
            if c.get('contribution') is not None:
                c['contribution'] = round(-float(c['contribution']), 4)
    return record
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;경량 예측: 10분마다 실시간 업데이트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 파이프라인(18년치 데이터 수집 + 모델 학습)은 3시간마다 실행된다. 그 사이에도 오늘의 국면은 업데이트해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8개 피처 중 월별로만 업데이트되는 피처(fundamental_gap, erp_zscore, vix_term, hy_spread)는 모델 번들에 캐싱해두고, 실시간으로 계산 가능한 피처(residual_corr, dispersion, amihud, realized_vol)는 최근 60일 데이터로 재계산한다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# 모델 번들에서 월별 피처 캐시값 로드
monthly = model_bundle['last_monthly_values']
fg_val = monthly['fundamental_gap']
ez_val = monthly['erp_zscore']

# vix_term은 Yahoo에서 실시간 업데이트
vix_hist = yf.Ticker('^VIX').history(period='5d', auto_adjust=True)
vix3m_hist = yf.Ticker('^VIX3M').history(period='5d', auto_adjust=True)
vt_val = float(vix_hist['Close'].dropna().iloc[-1]) / float(vix3m_hist['Close'].dropna().iloc[-1])
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식으로 10분마다 실행되는 경량 파이프라인에서도 최신 국면을 반영한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;50일 백필&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델을 새로 학습하면 과거 50일치 국면도 새 모델로 재채점한다. 오래된 모델로 채점된 과거 데이터와 새 모델 사이의 불일치를 최소화하기 위해서다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;for date in spy_dates:
    # 해당 날짜 기준 20일 윈도우로 실시간 피처 재계산
    recent_resid = residuals[residuals.index &amp;lt;= date].iloc[-20:]

    pair_corrs = []
    for sector, stocks in SECTOR_STOCKS.items():
        avail = [s for s in stocks if s in recent_resid.columns]
        if len(avail) &amp;lt; 2:
            continue
        for s1, s2 in combinations(avail, 2):
            c = recent_resid[s1].corr(recent_resid[s2])
            pair_corrs.append(c)
    rc_val = float(np.nanmean(pair_corrs)) if pair_corrs else 0.0

    feat_vec = np.array([[fg_val, ez_val, rc_val, disp_val, ami_val, vt_val, hy_val, rv_val]])
    feat_scaled = scaler.transform(feat_vec)
    ns = float(compute_noise_score(feat_scaled)[0])
    pred_phase, pred_name = score_to_regime_name(ns)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배포 후 발견한 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 큰 문제는 FRED API 불안정성이었다. 간헐적으로 타임아웃이 나거나 빈 응답을 반환한다. FRED 없이는 hy_spread와 tips_rate 피처를 계산할 수 없어서 전체 파이프라인이 멈춘다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책은 두 단계로 구성했다. 첫째, 재시도 로직을 지수 백오프로 구현했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;def _fetch_fred(series_id, col_name, retries=3, timeout=(10, 30)):
    for attempt in range(retries):
        try:
            resp = session.get(url, timeout=timeout)
            resp.raise_for_status()
            return df
        except Exception as e:
            if attempt &amp;lt; retries - 1:
                wait = 3 * (2 ** attempt)  # 3초, 6초 대기
                time.sleep(wait)
            else:
                raise
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 전체 파이프라인에서 FRED 데이터를 수집하면 fred_cache.pkl로 저장하고, 경량 파이프라인은 이 캐시를 우선 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나는 부호 컨벤션 혼선이었다. 개발 초기에 &quot;양수 = 이성, 음수 = 감정&quot;으로 정의했다가 중간에 반대로 뒤집었다. 이미 DB에 저장된 데이터와 새 데이터가 다른 컨벤션을 가지게 됐고, 결국 전체 DB를 일괄 반전하는 마이그레이션 스크립트를 써야 했다. 컨벤션 변경이 필요할 때는 처음부터 API 레이어에서 변환하는 방식을 잡는 게 맞다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 기능을 만들면서 배운 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HMM은 학습 자체보다 피처 설계가 결과를 좌우한다는 걸 배웠다. fundamental_gap 없이 VIX만 넣으면 변동성 높낮이로만 국면이 분류되고, ERP를 빼면 밸류에이션 신호가 사라진다. 어떤 피처를 넣느냐가 곧 &quot;시장을 어떻게 정의하느냐&quot;의 문제다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;noise_score라는 단일 숫자로 압축하는 결정도 중요했다. HMM이 출력하는 4개 상태 확률보다 단일 점수가 사용자에게 훨씬 직관적으로 전달된다. 복잡한 모델의 결과를 단순화하는 것도 제품을 만드는 일의 일부다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  Passive 바로가기: &lt;a href=&quot;https://dinsightlab.com&quot;&gt;https://dinsightlab.com&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777799824859&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Passive&quot; data-og-description=&quot;미국 지수 투자자를 위한 금융 데이터 분석 사이트&quot; data-og-host=&quot;dinsightlab.com&quot; data-og-source-url=&quot;https://dinsightlab.com&quot; data-og-url=&quot;https://dinsightlab.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cl6zoB/dJMb88F8ya5/590sgWtrKjuAQ6DzXYaMXk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/EqfSe/dJMb85vSA9S/xX2TlmBmIgNcbhXA6ahKi0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941&quot;&gt;&lt;a href=&quot;https://dinsightlab.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dinsightlab.com&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cl6zoB/dJMb88F8ya5/590sgWtrKjuAQ6DzXYaMXk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/EqfSe/dJMb85vSA9S/xX2TlmBmIgNcbhXA6ahKi0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Passive&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;미국 지수 투자자를 위한 금융 데이터 분석 사이트&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dinsightlab.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;#GaussianHMM #시장국면분류 #머신러닝 #데이터분석 #파이썬 #시계열분석 #FRED #Shiller #VIX #ETF분석 #Passive개발일지 #미국주식AI #데이터사이언스 #포트폴리오 #개발일지 #퀀트 #hmmlearn #RobustScaler #펀더멘털분석 #사이드프로젝트&lt;/p&gt;</description>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/196</guid>
      <comments>https://ojko.tistory.com/196#entry196comment</comments>
      <pubDate>Sun, 3 May 2026 18:23:20 +0900</pubDate>
    </item>
    <item>
      <title>XGBoost로 급락&amp;middot;급등 예측 모델을 만든 방법</title>
      <link>https://ojko.tistory.com/195</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dinsightlab.com&quot;&gt;https://dinsightlab.com&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777785877478&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Passive&quot; data-og-description=&quot;미국 지수 투자자를 위한 금융 데이터 분석 사이트&quot; data-og-host=&quot;dinsightlab.com&quot; data-og-source-url=&quot;https://dinsightlab.com&quot; data-og-url=&quot;https://dinsightlab.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cl6zoB/dJMb88F8ya5/590sgWtrKjuAQ6DzXYaMXk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/EqfSe/dJMb85vSA9S/xX2TlmBmIgNcbhXA6ahKi0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941&quot;&gt;&lt;a href=&quot;https://dinsightlab.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dinsightlab.com&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cl6zoB/dJMb88F8ya5/590sgWtrKjuAQ6DzXYaMXk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/EqfSe/dJMb85vSA9S/xX2TlmBmIgNcbhXA6ahKi0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Passive&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;미국 지수 투자자를 위한 금융 데이터 분석 사이트&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dinsightlab.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;XGBoost로 급락&amp;middot;급등 예측 모델을 만든 방법&amp;nbsp;&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혼자 만든 AI 주식 분석 플랫폼 &lt;a href=&quot;https://dinsightlab.com/&quot;&gt;Passive&lt;/a&gt;의 핵심 모델 구현 과정을 기록한다.&lt;br /&gt;이 글은 &quot;어떻게 만들었는가&quot;보다 &quot;왜 이 선택을 했는가&quot;에 집중한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 조기경보 시스템이 필요했는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GaussianHMM으로 시장 레짐을 분류하는 데 성공했지만, 한 가지 중요한 질문이 남았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;지금 강세 국면인 건 알겠는데, 곧 급락이 올 가능성은 얼마나 되는가?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레짐 분류는 현재 상태를 알려주지만, 단기 리스크 이벤트를 사전에 감지하지는 못한다. 강세 국면 한가운데서도 갑자기 -10% 급락이 발생하는 게 시장이기 때문이다. 이 문제를 풀기 위해 XGBoost 기반 조기경보 모델을 추가로 구현했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 정의: 무엇을 예측할 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조기경보 모델을 설계할 때 가장 먼저 결정한 것은 레이블 정의다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;초기 시도 &amp;mdash; 너무 단순한 정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순하게 정의했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;내일 S&amp;amp;P 500이 하락하면 &amp;rarr; 1
내일 S&amp;amp;P 500이 상승하면 &amp;rarr; 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 처참했다. 정확도가 52% 수준으로 동전 던지기와 다를 게 없었다. 당연한 결과였다. 일간 등락은 노이즈가 너무 많아 어떤 모델도 유의미하게 예측하기 어렵다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 레이블 정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 시도 끝에 아래 기준으로 정착했다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;# 급락 레이블
def label_crash(returns, window=20, threshold=-0.10):
    &quot;&quot;&quot;
    향후 20거래일(약 1개월) 내
    누적 수익률이 -10% 이하로 떨어지면 &amp;rarr; 1 (급락 위험)
    그 외 &amp;rarr; 0 (정상)
    &quot;&quot;&quot;
    labels = []
    for i in range(len(returns)):
        future = returns.iloc[i:i+window]
        cumulative = (1 + future).prod() - 1
        labels.append(1 if cumulative &amp;lt;= threshold else 0)
    return pd.Series(labels, index=returns.index)

# 급등 레이블
def label_surge(returns, window=20, threshold=0.10):
    labels = []
    for i in range(len(returns)):
        future = returns.iloc[i:i+window]
        cumulative = (1 + future).prod() - 1
        labels.append(1 if cumulative &amp;gt;= threshold else 0)
    return pd.Series(labels, index=returns.index)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;20거래일 누적 기준을 선택한 이유:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;너무 짧으면(5일) 노이즈가 많아 신호 가치가 떨어진다&lt;/li&gt;
&lt;li&gt;너무 길면(60일) 선행 지표와의 인과관계가 약해진다&lt;/li&gt;
&lt;li&gt;20거래일은 투자자가 실제로 포지션을 조절할 수 있는 현실적인 기간이다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;클래스 불균형: 가장 먼저 마주친 벽&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이블을 정의하고 데이터를 살펴보니 심각한 문제가 있었다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;print(labels.value_counts(normalize=True))
# 정상(0): 91.3%
# 급락(1):  8.7%
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;급락 이벤트는 전체 데이터의 8.7%에 불과했다. 이 상태로 모델을 학습하면 모델은 &quot;항상 0(정상)&quot;이라고 예측해도 91.3% 정확도를 달성한다. 쓸모없는 모델이 높은 정확도를 보이는 함정이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 1 &amp;mdash; SMOTE 오버샘플링&lt;/h3&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;from imblearn.over_sampling import SMOTE

smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: Precision이 높아졌지만 Recall이 너무 낮았다. SMOTE가 생성한 합성 샘플이 금융 시계열의 시간적 구조를 무시해서 실제로 존재하지 않는 패턴을 학습했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 2 &amp;mdash; scale_pos_weight 파라미터&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XGBoost에는 클래스 불균형을 직접 처리하는 파라미터가 있다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;# 정상 샘플 수 / 급락 샘플 수
scale_pos_weight = (labels == 0).sum() / (labels == 1).sum()
# 결과: 약 10.5

model = XGBClassifier(
    scale_pos_weight=scale_pos_weight,
    ...
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: SMOTE보다 안정적이었다. 시계열 구조를 훼손하지 않으면서 소수 클래스에 더 높은 가중치를 부여하는 방식이라 금융 데이터에 더 적합했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 선택 &amp;mdash; scale_pos_weight + 임계값 조정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scale_pos_weight로 학습하되, 예측 임계값을 0.5에서 0.35로 낮춰 Recall을 높였다. 투자 맥락에서는 급락을 놓치는 것(False Negative)이 오신호(False Positive)보다 훨씬 비싸기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 기본 임계값 0.5
pred_default = (model.predict_proba(X_test)[:, 1] &amp;gt;= 0.5).astype(int)

# 조정 임계값 0.35
pred_adjusted = (model.predict_proba(X_test)[:, 1] &amp;gt;= 0.35).astype(int)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임계값 Precision Recall F1&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;0.68&lt;/td&gt;
&lt;td&gt;0.51&lt;/td&gt;
&lt;td&gt;0.58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;td&gt;0.57&lt;/td&gt;
&lt;td&gt;0.71&lt;/td&gt;
&lt;td&gt;0.63&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;급락 감지 목적에서는 0.35가 더 적합했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Optuna 하이퍼파라미터 튜닝&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XGBoost 파라미터를 수동으로 튜닝하다가 한계를 느끼고 Optuna를 도입했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;수동 튜닝의 한계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Grid Search로 시작했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;param_grid = {
    'max_depth': [3, 4, 5, 6],
    'learning_rate': [0.01, 0.05, 0.1],
    'n_estimators': [100, 300, 500]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 두 가지였다. 첫째, 조합이 너무 많아 시간이 오래 걸렸다. 둘째, 시계열 데이터에서 일반적인 K-Fold CV는 데이터 누수 문제가 있었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TimeSeriesSplit + Optuna 조합&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import optuna
from sklearn.model_selection import TimeSeriesSplit

def objective(trial):
    params = {
        'max_depth': trial.suggest_int('max_depth', 3, 7),
        'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
        'n_estimators': trial.suggest_int('n_estimators', 100, 1000),
        'subsample': trial.suggest_float('subsample', 0.6, 1.0),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 1.0),
        'min_child_weight': trial.suggest_int('min_child_weight', 1, 10),
        'gamma': trial.suggest_float('gamma', 0, 1),
        'scale_pos_weight': scale_pos_weight,
        'random_state': 42
    }

    tscv = TimeSeriesSplit(n_splits=5)
    scores = []

    for train_idx, val_idx in tscv.split(X):
        X_tr, X_val = X[train_idx], X[val_idx]
        y_tr, y_val = y[train_idx], y[val_idx]

        model = XGBClassifier(**params)
        model.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=False)

        prob = model.predict_proba(X_val)[:, 1]
        from sklearn.metrics import roc_auc_score
        scores.append(roc_auc_score(y_val, prob))

    return np.mean(scores)

study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=100)

best_params = study.best_params
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TimeSeriesSplit을 사용한 이유는 미래 데이터가 학습에 섞이는 데이터 누수를 방지하기 위해서다. 일반 K-Fold는 시간 순서를 무시하고 랜덤으로 데이터를 분할하기 때문에 금융 시계열에는 적합하지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;100회 시도 후 최적 파라미터:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;best_params = {
    'max_depth': 4,
    'learning_rate': 0.047,
    'n_estimators': 387,
    'subsample': 0.81,
    'colsample_bytree': 0.73,
    'min_child_weight': 3,
    'gamma': 0.12
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AUC-ROC가 튜닝 전 0.71에서 튜닝 후 0.78로 개선됐다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Platt Scaling: 확률 보정이 필요한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XGBoost가 출력하는 확률값(predict_proba)은 실제 확률과 다를 수 있다. 모델이 &quot;급락 확률 70%&quot;라고 출력해도 실제로 그 상황에서 70%의 빈도로 급락이 발생한다는 보장이 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;보정 전 확인&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;from sklearn.calibration import calibration_curve

fraction_of_positives, mean_predicted_value = calibration_curve(
    y_test, prob_pred, n_bins=10
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보정 전 결과: 모델이 높은 확률을 출력할 때 실제 급락 발생 비율이 과소 추정되는 경향이 있었다. 즉, 모델이 &quot;70% 확률&quot;이라고 해도 실제로는 55% 정도만 급락이 발생했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Platt Scaling 적용&lt;/h3&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;from sklearn.calibration import CalibratedClassifierCV

calibrated_model = CalibratedClassifierCV(
    base_model,
    method='sigmoid',  # Platt Scaling
    cv='prefit'        # 이미 학습된 모델에 적용
)
calibrated_model.fit(X_val, y_val)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Platt Scaling은 시그모이드 함수를 사용해 모델 출력을 실제 확률에 맞게 보정한다. 보정 후 &quot;70% 확률&quot;이 실제로 70% 빈도에 가까워졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자에게 &quot;급락 확률 73%&quot;라는 숫자를 보여줄 때, 그 숫자가 실제 확률에 가까워야 신뢰할 수 있는 서비스가 된다. 이것이 Platt Scaling을 적용한 핵심 이유다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SHAP으로 변수 중요도 해석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델 성능을 높이는 것만큼 &quot;왜 이 신호가 발생했는가&quot;를 설명하는 것도 중요했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;import shap

explainer = shap.TreeExplainer(best_model)
shap_values = explainer.shap_values(X_test)

# 전체 변수 중요도
shap.summary_plot(shap_values, X_test, feature_names=feature_names)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SHAP 분석 결과 변수 중요도 순위:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순위 변수 기여도&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;hy_spread&lt;/td&gt;
&lt;td&gt;가장 높음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;vix_term_structure&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;fundamental_gap&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;erp_zscore&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;amihud_illiquidity&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;dispersion&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;residual_correlation&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HY 스프레드가 가장 중요한 변수로 나온 건 예상했다. 신용 시장이 주식 시장보다 먼저 반응한다는 것은 금융 이론에서도 잘 알려진 사실이다. 이 결과가 경제적으로 타당하다는 것이 모델 신뢰성의 근거가 됐다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배포 후 실제로 발견한 문제들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 1 &amp;mdash; 오신호 연속 발생 구간&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2026년 초 시장이 빠르게 회복하는 구간에서 급락 신호가 계속 울렸다. 실제로 급락은 오지 않았고, 사용자 신뢰도 문제가 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인 분석: HY 스프레드가 일시적으로 확대됐다가 빠르게 수축하는 패턴이 반복됐는데, 모델이 이를 급락 전조로 과민하게 반응했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책&lt;/b&gt;: 신호 지속 기간 필터 + HMM 레짐과 교차 확인&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def final_signal(crash_prob, regime, min_consecutive=2):
    &quot;&quot;&quot;
    급락 확률 &amp;gt; 임계값이 2거래일 이상 연속이고
    HMM 레짐이 전환/약세 국면일 때만 신호 발생
    &quot;&quot;&quot;
    if crash_prob &amp;gt;= 0.35 and consecutive_days &amp;gt;= min_consecutive:
        if regime in ['transition', 'bear']:
            return 'WARNING'
    return 'NORMAL'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HMM 레짐과 XGBoost 신호를 교차 확인하는 이중 레이어가 오신호를 크게 줄였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 2 &amp;mdash; 모델 드리프트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간이 지나면서 모델 성능이 서서히 떨어지는 드리프트 현상이 발생했다. 학습 당시와 시장 구조가 달라지기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책&lt;/b&gt;: 주간 자동 재학습 파이프라인&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# GitHub Actions cron: 매주 일요일 새벽 2시
# 1. FRED API에서 최신 데이터 수집
# 2. 레이블 재생성
# 3. Optuna 튜닝 (50 trials로 축소)
# 4. 모델 재학습 + Platt Scaling
# 5. AUC-ROC 검증 후 기준치(0.65) 이상이면 배포
# 6. 기준치 미달이면 이전 모델 유지
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 프로젝트에서 배운 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모델을 구현하면서 데이터 분석가로서 가장 크게 배운 것은 &quot;평가 지표 선택이 모델 설계 전체를 결정한다&quot;는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 Accuracy로 모델을 평가했을 때는 91%가 나왔다. 훌륭해 보였지만 완전히 쓸모없는 모델이었다. AUC-ROC로 바꾸고, 다시 Precision-Recall 커브로 평가 기준을 정교화하면서 비로소 실제로 유용한 모델이 만들어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;투자 맥락에서 &quot;급락을 놓치는 것&quot;과 &quot;오신호를 내는 것&quot;의 비용이 다르다는 걸 인식하고, 임계값을 0.35로 조정한 것도 순수하게 분석적 판단이 아니라 도메인 이해에서 나온 결정이었다. 데이터 분석은 결국 도메인과 분리될 수 없다는 것을 이 프로젝트에서 다시 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  Passive 바로가기: &lt;a href=&quot;https://dinsightlab.com&quot;&gt;https://dinsightlab.com&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777785881667&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Passive&quot; data-og-description=&quot;미국 지수 투자자를 위한 금융 데이터 분석 사이트&quot; data-og-host=&quot;dinsightlab.com&quot; data-og-source-url=&quot;https://dinsightlab.com&quot; data-og-url=&quot;https://dinsightlab.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cl6zoB/dJMb88F8ya5/590sgWtrKjuAQ6DzXYaMXk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/EqfSe/dJMb85vSA9S/xX2TlmBmIgNcbhXA6ahKi0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941&quot;&gt;&lt;a href=&quot;https://dinsightlab.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dinsightlab.com&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cl6zoB/dJMb88F8ya5/590sgWtrKjuAQ6DzXYaMXk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/EqfSe/dJMb85vSA9S/xX2TlmBmIgNcbhXA6ahKi0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Passive&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;미국 지수 투자자를 위한 금융 데이터 분석 사이트&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dinsightlab.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;#XGBoost #조기경보시스템 #머신러닝 #데이터분석 #파이썬 #Optuna #PlattScaling #클래스불균형 #SHAP #시계열분석 #퀀트 #데이터사이언스 #Passive개발일지 #미국주식AI #주식머신러닝 #데이터분석가 #포트폴리오 #개발일지 #FastAPI #사이드프로젝트&lt;/p&gt;</description>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/195</guid>
      <comments>https://ojko.tistory.com/195#entry195comment</comments>
      <pubDate>Sun, 3 May 2026 14:25:24 +0900</pubDate>
    </item>
    <item>
      <title>GaussianHMM으로 S&amp;amp;P 500 시장 레짐을 분류한 방법</title>
      <link>https://ojko.tistory.com/194</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dinsightlab.com&quot;&gt;https://dinsightlab.com&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777730471927&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Passive&quot; data-og-description=&quot;미국 지수 투자자를 위한 금융 데이터 분석 사이트&quot; data-og-host=&quot;dinsightlab.com&quot; data-og-source-url=&quot;https://dinsightlab.com&quot; data-og-url=&quot;https://dinsightlab.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/r7kr9/dJMb8VNy19D/t3rKcS7jrsjPVhXqglKcgk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/b7Jync/dJMb9lMe1wK/TJ5dXxLh3KadNSpO1Mozw0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941&quot;&gt;&lt;a href=&quot;https://dinsightlab.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dinsightlab.com&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/r7kr9/dJMb8VNy19D/t3rKcS7jrsjPVhXqglKcgk/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941,https://scrap.kakaocdn.net/dn/b7Jync/dJMb9lMe1wK/TJ5dXxLh3KadNSpO1Mozw0/img.png?width=1672&amp;amp;height=941&amp;amp;face=0_0_1672_941');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Passive&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;미국 지수 투자자를 위한 금융 데이터 분석 사이트&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dinsightlab.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;GaussianHMM으로 S&amp;amp;P 500 시장 레짐을 분류한 방법&amp;nbsp;&lt;/h1&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 시장 레짐 분류가 필요했는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Passive를 기획할 때 가장 먼저 고민한 것은 &quot;사용자에게 어떤 판단 근거를 줄 수 있는가&quot;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 주가 차트나 ETF 수익률을 보여주는 서비스는 이미 넘쳐난다. 내가 만들고 싶었던 건 &quot;지금 시장이 어떤 국면인지&quot;를 데이터로 명확하게 알려주는 기능이었다. 같은 ETF라도 상승기에 사는 것과 하락기에 사는 것은 완전히 다른 결과를 만들기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 시장 국면이라는 게 직접 관측할 수 없다는 점이다. 우리가 볼 수 있는 건 주가 수익률, VIX, 스프레드 같은 관측 데이터뿐이고, 그 이면의 구조적 상태는 추론해야 한다. 이 문제를 풀기 위해 HMM(Hidden Markov Model)을 선택했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HMM을 선택한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시장 레짐 분류에 사용할 수 있는 방법은 여러 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방법 장점 단점&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;이동평균 기반 규칙&lt;/td&gt;
&lt;td&gt;구현 단순&lt;/td&gt;
&lt;td&gt;후행 지표, 노이즈 민감&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;K-Means 클러스터링&lt;/td&gt;
&lt;td&gt;직관적&lt;/td&gt;
&lt;td&gt;시계열 구조 무시, 전이 확률 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regime-Switching GARCH&lt;/td&gt;
&lt;td&gt;변동성 모델링 강점&lt;/td&gt;
&lt;td&gt;구현 복잡, 수렴 불안정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;GaussianHMM&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;시계열 구조 반영, 전이 확률 모델링, 해석 가능&lt;/td&gt;
&lt;td&gt;상태 수 사전 설정 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;딥러닝 (LSTM)&lt;/td&gt;
&lt;td&gt;복잡한 패턴 학습&lt;/td&gt;
&lt;td&gt;블랙박스, 과적합 위험, 해석 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GaussianHMM을 선택한 핵심 이유는 두 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째, 전이 확률 행렬을 얻을 수 있다.&lt;/b&gt; 단순히 &quot;지금 강세 국면&quot;이 아니라 &quot;강세 국면에서 전환 국면으로 이동할 확률이 8%&quot;라는 정보를 줄 수 있다. 이게 투자 판단에 훨씬 유용하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째, 각 상태의 분포(평균, 분산)를 해석할 수 있다.&lt;/b&gt; 모델이 어떤 기준으로 상태를 나눴는지 수치로 확인할 수 있어서 블랙박스 문제가 없다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1단계 &amp;mdash; 변수 선택: 7개 변수를 고른 과정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 변수를 많이 넣을수록 좋다고 생각했다. 틀렸다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;초기 시도 &amp;mdash; 20개 변수&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 VIX, 거래량, 이동평균, RSI, MACD, 섹터 ETF 수익률 등 20개 가까운 변수를 넣었다. 결과는 엉망이었다. 상태 분류가 불안정했고, 같은 데이터를 다시 돌릴 때마다 결과가 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 두 가지였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;다중공선성&lt;/b&gt;: 서로 높은 상관관계를 가진 변수들이 모델을 불안정하게 만들었다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;차원의 저주&lt;/b&gt;: 변수가 많아질수록 GaussianHMM의 공분산 추정이 불안정해진다&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;변수 선별 기준 3가지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수를 줄이면서 세운 기준:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기준 1 &amp;mdash; 경제적 의미가 명확해야 한다&lt;/b&gt; VIX 기간 구조를 넣은 이유는 단순히 상관관계가 높아서가 아니라, 단기&amp;middot;장기 변동성의 비율이 시장 참여자들의 공포 구조를 반영하기 때문이다. 경제적 해석이 가능한 변수만 남겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기준 2 &amp;mdash; 서로 다른 정보를 담아야 한다&lt;/b&gt; VIX와 풋/콜 비율은 둘 다 센티먼트를 반영하지만 시장 참여자 집단이 다르다. 그러나 단기 VIX와 장기 VIX 간 상관관계는 너무 높아서 기간 구조(비율)로 변환했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기준 3 &amp;mdash; 데이터 가용성이 안정적이어야 한다&lt;/b&gt; FRED API로 안정적으로 수집할 수 있는 지표여야 한다. 일부 대안 데이터(옵션 플로우, 다크풀 거래량)는 데이터 소스가 불안정해서 제외했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 선택 7개 변수&lt;/h3&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;features = [
    'fundamental_gap',      # 펀더멘탈-주가 괴리
    'erp_zscore',           # 주식 리스크 프리미엄 Z-Score
    'residual_correlation',  # 주가-이익 추정치 잔차 상관계수
    'dispersion',           # 섹터 간 수익률 분산도
    'amihud_illiquidity',   # Amihud 비유동성 지표
    'vix_term_structure',   # VIX 기간 구조 (단기/장기 비율)
    'hy_spread'             # 하이일드 채권 스프레드
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 변수가 담고 있는 정보:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수 측정 대상 데이터 소스&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fundamental_gap&lt;/td&gt;
&lt;td&gt;주가 vs 이익 추정치 괴리&lt;/td&gt;
&lt;td&gt;FRED + 시장 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;erp_zscore&lt;/td&gt;
&lt;td&gt;주식 리스크 프리미엄 수준&lt;/td&gt;
&lt;td&gt;FRED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;residual_correlation&lt;/td&gt;
&lt;td&gt;펀더멘탈 이탈 정도&lt;/td&gt;
&lt;td&gt;계산값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dispersion&lt;/td&gt;
&lt;td&gt;섹터 간 쏠림 현상&lt;/td&gt;
&lt;td&gt;시장 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;amihud_illiquidity&lt;/td&gt;
&lt;td&gt;시장 깊이 (유동성)&lt;/td&gt;
&lt;td&gt;시장 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vix_term_structure&lt;/td&gt;
&lt;td&gt;공포의 기간 구조&lt;/td&gt;
&lt;td&gt;CBOE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hy_spread&lt;/td&gt;
&lt;td&gt;신용 리스크 수준&lt;/td&gt;
&lt;td&gt;FRED&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2단계 &amp;mdash; 상태 수(K) 결정: 4가지가 최적인 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HMM에서 가장 중요한 하이퍼파라미터는 상태 수(K)다. 정답이 없고 데이터와 목적에 맞게 결정해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;K=2, 3, 4, 5 비교 실험&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from hmmlearn.hmm import GaussianHMM
import numpy as np

results = {}

for n_states in [2, 3, 4, 5]:
    model = GaussianHMM(
        n_components=n_states,
        covariance_type='full',
        n_iter=1000,
        random_state=42
    )
    model.fit(X_scaled)
    
    results[n_states] = {
        'aic': -2 * model.score(X_scaled) + 2 * model._get_n_fit_scalars_per_param()['t'],
        'bic': -2 * model.score(X_scaled) + np.log(len(X_scaled)) * model._get_n_fit_scalars_per_param()['t'],
        'log_likelihood': model.score(X_scaled)
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;K AIC BIC 해석 가능성 안정성&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;너무 단순 (상승/하락만)&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;괜찮음 (상승/횡보/하락)&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;4&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;최적&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;최적&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;강세/약세/횡보/전환 구분&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;높음&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;상태 간 경계 불명확&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;K=4가 최적이었던 결정적 이유는 BIC 기준 수치 외에도 &lt;b&gt;실제 시장 해석이 자연스럽다&lt;/b&gt;는 점이었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태 0 (강세): &amp;mu; &amp;gt; 0, &amp;sigma; 낮음 &amp;rarr; 저변동성 상승&lt;/li&gt;
&lt;li&gt;상태 1 (약세): &amp;mu; &amp;lt; 0, &amp;sigma; 높음 &amp;rarr; 고변동성 하락&lt;/li&gt;
&lt;li&gt;상태 2 (횡보): &amp;mu; &amp;asymp; 0, &amp;sigma; 중간 &amp;rarr; 방향 없는 등락&lt;/li&gt;
&lt;li&gt;상태 3 (전환): &amp;mu; 불안정, &amp;sigma; 매우 높음 &amp;rarr; 추세 전환 구간&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;K=5로 늘렸을 때 상태 2와 4가 거의 동일한 분포를 가지는 문제가 발생했다. 같은 정보를 두 개 상태가 나눠 갖는 불필요한 분할이었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3단계 &amp;mdash; 과적합 방지: 가장 고생한 부분&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GaussianHMM의 과적합은 학습 데이터에서는 깔끔하게 분류되지만 새로운 데이터에서 상태가 불안정하게 튀는 현상으로 나타난다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 1 &amp;mdash; 초기값 민감성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GaussianHMM은 EM 알고리즘으로 학습하는데, 초기값에 따라 로컬 최적값에 빠지는 문제가 있다. 같은 데이터를 돌려도 랜덤 시드에 따라 결과가 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책: 멀티스타트 + 최적 모델 선택&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;best_model = None
best_score = -np.inf

for seed in range(50):  # 50회 랜덤 초기화
    model = GaussianHMM(
        n_components=4,
        covariance_type='full',
        n_iter=1000,
        random_state=seed
    )
    try:
        model.fit(X_scaled)
        score = model.score(X_scaled)
        if score &amp;gt; best_score:
            best_score = score
            best_model = model
    except Exception:
        continue
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;50회 반복 중 로그 우도가 가장 높은 모델을 선택했다. 이 방식으로 초기값 민감성 문제를 크게 줄였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 2 &amp;mdash; 공분산 행렬 특이점(Singularity)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수 간 상관관계가 높거나 특정 구간 데이터가 부족하면 공분산 행렬이 특이점(singular)이 되어 모델이 발산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책: 정규화 공분산 + 스케일링&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from sklearn.preprocessing import RobustScaler

# RobustScaler: 이상치에 강한 스케일링
scaler = RobustScaler()
X_scaled = scaler.fit_transform(X)

# covariance_type='diag'로 변경 시도
# full &amp;rarr; tied &amp;rarr; diag 순으로 제약을 강하게
model = GaussianHMM(
    n_components=4,
    covariance_type='diag',  # 대각 공분산으로 제약
    n_iter=1000,
    random_state=42
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로는 covariance_type='full'을 유지하되, 충분한 학습 데이터(10년 이상)와 RobustScaler를 조합해 안정성을 확보했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 3 &amp;mdash; 상태 레이블 불일치 (Label Switching)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HMM은 상태 번호가 고정되어 있지 않다. 어떤 날은 상태 0이 강세 국면이고, 다음 날 모델을 다시 돌리면 상태 2가 강세 국면이 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책: 평균 수익률 기준 자동 정렬&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;def align_states(model, returns):
    &quot;&quot;&quot;각 상태의 평균 수익률로 상태 정렬&quot;&quot;&quot;
    state_means = []
    hidden_states = model.predict(X_scaled)
    
    for state in range(model.n_components):
        mask = hidden_states == state
        mean_return = returns[mask].mean()
        state_means.append((state, mean_return))
    
    # 평균 수익률 기준 정렬
    # 가장 높은 수익률 = 강세, 가장 낮은 = 약세
    sorted_states = sorted(state_means, key=lambda x: x[1], reverse=True)
    
    state_mapping = {
        sorted_states[0][0]: 'bull',        # 강세
        sorted_states[3][0]: 'bear',        # 약세
        # 횡보/전환은 변동성 기준으로 추가 분류
    }
    return state_mapping
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평균 수익률과 변동성을 함께 사용해 4개 상태를 강세/약세/횡보/전환으로 자동 정렬하는 로직을 구현했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4단계 &amp;mdash; 검증: 내가 만든 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델을 만든 후 &quot;이게 진짜 잘 작동하는가&quot;를 확인하는 게 가장 어려웠다. 시장 레짐에는 정답 레이블이 없기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;검증 기준 1 &amp;mdash; 역사적 사건과의 일치도&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델이 분류한 약세/전환 국면이 실제 주요 시장 하락 사건과 일치하는지 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사건 실제 기간 모델 감지 시점 선행 여부&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;코로나 쇼크&lt;/td&gt;
&lt;td&gt;2020.02~03&lt;/td&gt;
&lt;td&gt;2020.02 중순&lt;/td&gt;
&lt;td&gt;약 1~2주 선행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2022년 약세장&lt;/td&gt;
&lt;td&gt;2022.01~10&lt;/td&gt;
&lt;td&gt;2021.12 말&lt;/td&gt;
&lt;td&gt;약 2~3주 선행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2018년 4분기 조정&lt;/td&gt;
&lt;td&gt;2018.10~12&lt;/td&gt;
&lt;td&gt;2018.10 초&lt;/td&gt;
&lt;td&gt;동시 감지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완벽하지는 않지만 주요 하락 사건에서 유의미한 선행 또는 동시 감지를 확인했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;검증 기준 2 &amp;mdash; 레짐별 수익률 분포 유의성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 레짐별 S&amp;amp;P 500 일간 수익률 분포가 통계적으로 유의미하게 다른지 검정했다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;from scipy import stats

bull_returns = returns[states == 'bull']
bear_returns = returns[states == 'bear']

# Mann-Whitney U 검정 (정규성 가정 없음)
stat, p_value = stats.mannwhitneyu(bull_returns, bear_returns)
print(f&quot;p-value: {p_value:.6f}&quot;)  # 결과: p &amp;lt; 0.001
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강세/약세 국면 수익률 분포 차이의 p-value가 0.001 미만으로, 모델이 통계적으로 유의미한 구분을 하고 있음을 확인했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;검증 기준 3 &amp;mdash; Walk-Forward 안정성 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터를 시간 순서대로 잘라서 각 구간에서 모델을 다시 학습하고, 레짐 분류 결과가 일관성 있게 유지되는지 확인했다.&lt;/p&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;# 2년치 학습 &amp;rarr; 다음 6개월 예측 &amp;rarr; 슬라이딩
for i in range(0, len(data) - train_size, test_size):
    train = data[i:i+train_size]
    test = data[i+train_size:i+train_size+test_size]
    
    model.fit(scaler.fit_transform(train))
    test_states = model.predict(scaler.transform(test))
    # 각 구간별 레짐 일관성 평가
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배포 후 실제로 발견한 문제들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델을 Railway + FastAPI로 배포하고 실제 사용자 데이터가 쌓이면서 예상치 못한 문제들이 나왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 1 &amp;mdash; 데이터 갱신 시 레짐이 급격히 바뀌는 현상&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매일 새 데이터가 추가될 때마다 전체 모델을 재학습하면, 어제 &quot;강세 국면&quot;이었던 게 오늘 갑자기 &quot;횡보 국면&quot;으로 바뀌는 일이 발생했다. 사용자 입장에서는 신뢰성 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책&lt;/b&gt;: 주간 배치 재학습 + 일간 상태 예측 분리&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 모델 재학습: 매주 일요일 GitHub Actions
# 상태 예측: 매일 학습된 모델로 predict만 실행
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델 자체는 주 1회 재학습하고, 일간 업데이트는 기존 모델의 predict만 사용하도록 분리해 안정성을 확보했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 2 &amp;mdash; 전환 국면이 너무 짧게 감지되는 현상&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전환 국면이 하루 이틀씩 산발적으로 나타나 사용자가 &quot;이게 의미 있는 신호인가&quot;라고 의심하는 피드백이 나왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책&lt;/b&gt;: 최소 지속 기간 필터 적용&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def apply_persistence_filter(states, min_duration=3):
    &quot;&quot;&quot;3거래일 미만 지속되는 레짐 변화는 무시&quot;&quot;&quot;
    filtered = states.copy()
    # 슬라이딩 윈도우로 단기 노이즈 제거
    ...
    return filtered
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3거래일 미만 지속되는 레짐 변화는 이전 상태로 유지하는 필터를 추가했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 프로젝트에서 배운 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 분석가로서 이 모델을 만들면서 가장 크게 배운 점은 &quot;모델 선택보다 문제 정의와 검증 기준 설계가 더 중요하다&quot;는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 변수를 20개 넣고 실패했을 때, 문제는 알고리즘이 아니었다. &quot;어떤 정보를 넣어야 시장 국면을 잘 구분할 수 있는가&quot;라는 질문에 제대로 답하지 못한 것이 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답 레이블이 없는 비지도 학습 문제에서는 특히 검증 기준을 스스로 설계해야 한다. 역사적 사건과의 일치도, 통계적 유의성 검정, Walk-Forward 안정성 테스트라는 세 가지 기준을 만든 것이 이 프로젝트에서 가장 중요한 분석 작업이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델을 배포하고 실제 사용자 피드백을 받으면서 &quot;학습 환경과 운영 환경의 차이&quot;를 체감했다. 논문이나 노트북에서 잘 되던 모델이 실시간 데이터 파이프라인 위에서는 다르게 동작한다. 이 경험이 데이터 분석가로서 가장 값진 배움이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;#GaussianHMM #시장레짐분류 #머신러닝 #데이터분석 #파이썬 #HMM #비지도학습 #시계열분석 #퀀트 #데이터사이언스 #Passive개발일지 #미국주식AI #주식머신러닝 #hmmlearn #데이터분석가 #포트폴리오 #개발일지 #FastAPI #Railway #사이드프로젝트&lt;/p&gt;</description>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/194</guid>
      <comments>https://ojko.tistory.com/194#entry194comment</comments>
      <pubDate>Sat, 2 May 2026 23:02:16 +0900</pubDate>
    </item>
    <item>
      <title>18년치 금융 데이터 파이프라인을 혼자 설계하면서 배운 것들</title>
      <link>https://ojko.tistory.com/192</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&quot;과거 데이터를 모으는 건 쉬울 줄 알았습니다&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Passive를 만들 때 가장 먼저 부딪힌 벽은 모델이 아니었습니다. &lt;b&gt;데이터였습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HMM이든 XGBoost든, 머신러닝 모델의 품질은 결국 학습 데이터에 달려있습니다. 미국 시장 레짐을 판별하려면 최소한 &lt;b&gt;여러 경기 사이클&lt;/b&gt;이 포함된 데이터가 필요합니다. 2008년 금융위기, 2015년 유가 쇼크, 2020년 코로나, 2022년 인플레이션 &amp;mdash; 이런 &quot;다른 시장 얼굴&quot;들이 학습 데이터에 있어야 모델이 다양한 상황을 구분할 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 목표를 잡았습니다. &lt;b&gt;2007년부터 현재까지, 18년치 데이터.&lt;/b&gt; 처음엔 &quot;그냥 FRED에서 긁어오면 되는 거 아닌가?&quot;라고 생각했습니다. 그게 첫 번째 오판이었습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 1. &quot;한 곳에서 다 주는 데이터가 아니다&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Passive가 쓰는 데이터 소스를 정리해보면 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;FRED&lt;/b&gt; (미 연준): 거시지표, 하이일드 스프레드, 금리 구조&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Yahoo Finance&lt;/b&gt;: 주요 ETF 16종 OHLCV, 개별 종목&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CNN Fear &amp;amp; Greed&lt;/b&gt;: 투자자 심리 지수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CBOE&lt;/b&gt;: VIX, 옵션 풋/콜 비율&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 서로 다른 API이고, 서로 다른 &lt;b&gt;갱신 주기, 서로 다른 타임존, 서로 다른 날짜 포맷&lt;/b&gt;을 쓴다는 걸 금방 알게 됐습니다. FRED는 날짜 문자열, Yahoo는 유닉스 타임스탬프, CNN은 자체 포맷. 하나로 합치려면 &lt;b&gt;전부 UTC 기준 daily 인덱스&lt;/b&gt;로 정렬해야 하는데, 이 정규화 코드만 며칠을 잡아먹었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;심지어 지표마다 &lt;b&gt;업데이트 시점&lt;/b&gt;도 달랐습니다. FRED의 어떤 시리즈는 월말에, 어떤 건 주 단위로, 어떤 건 분기 단위로 발표됩니다. 분기 데이터를 일 단위로 쓰려면 forward-fill을 해야 하는데, 이게 &lt;b&gt;학습 시점에 미래 데이터를 참조하는 look-ahead bias&lt;/b&gt;를 만들 수 있습니다. &quot;2024년 3월 말에 발표된 2024년 1분기 GDP를 2024년 1월 1일부터 있었던 것처럼 쓰면&quot; 모델은 미래를 보고 학습하게 됩니다. 백테스트는 잘 나오는데 실전에서는 무너지는 전형적인 함정입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;배운 것 1&lt;/b&gt;: 데이터를 &quot;언제 받았는지&quot;가 &quot;언제 발표됐는지&quot;보다 중요하다. 발표 지연(release lag)을 무시하면 미래 정보가 과거로 새어 들어간다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 2. &quot;과거로 갈수록 데이터가 이상해진다&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;18년치를 긁고 나서 이상한 점을 발견했습니다. &lt;b&gt;2010년 이전 데이터의 품질이 현재와 다릅니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;2008년엔 아직 상장 안 된 ETF들 (예: 일부 섹터 ETF는 2010년대 중반에 상장)&lt;/li&gt;
&lt;li&gt;2010년 이전엔 아예 집계 안 되던 지표들 (예: CNN Fear &amp;amp; Greed는 2011년부터)&lt;/li&gt;
&lt;li&gt;데이터 제공사가 중간에 산출 방법을 바꾼 지표들&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 사실을 모르고 &quot;2007년부터 학습&quot;이라고 단순하게 돌리면, 모델 입장에서는 &lt;b&gt;특정 구간에 NaN이 잔뜩 낀 이상한 데이터&lt;/b&gt;가 들어갑니다. XGBoost는 NaN을 어느 정도 처리할 수 있어서 학습은 되지만, HMM은 그렇지 못합니다. 그리고 NaN이 너무 많은 구간은 애초에 &quot;정보가 없는 구간&quot;이라 모델의 패턴 학습을 방해합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 두 가지를 고려했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방법 A. 지표별로 학습 시작점을 다르게 잡기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;2007년부터 있는 지표: 2007년부터 사용&lt;/li&gt;
&lt;li&gt;2011년부터 있는 지표: 2011년부터 사용&lt;/li&gt;
&lt;li&gt;모델은 &quot;공통으로 존재하는 최소 구간&quot;에서 학습&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방법 B. 지표 티어를 나누기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Core (2007년부터): 금리, VIX, S&amp;amp;P 500&lt;/li&gt;
&lt;li&gt;Secondary (2011년부터): Fear &amp;amp; Greed, 하이일드 스프레드&lt;/li&gt;
&lt;li&gt;Experimental (2015년부터): 특정 섹터 ETF 로테이션&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 방법 B를 택했습니다. 핵심 모델(NoiseHMM)은 Core 지표만으로 돌아가고, 보조 모델(CrashSurge의 XGBoost)은 Secondary까지 활용하는 구조입니다. 단순해 보이지만, 이 구조를 잡기까지 두 번의 시행착오가 있었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;배운 것 2&lt;/b&gt;: &quot;18년치 데이터&quot;는 환상이다. 실제로는 &quot;구간마다 다른 두께의 데이터&quot;이고, 이걸 모델에 맞게 티어링해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 3. &quot;백필(backfill)과 증분(incremental)은 다른 코드다&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 이렇게 작성했습니다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;def fetch_data(start_date, end_date):
    # FRED에서 데이터 가져와서 DB에 저장
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 하나로 &lt;b&gt;과거 18년치 백필도 하고, 3시간마다 돌아가는 증분 업데이트도&lt;/b&gt; 처리하려고 했습니다. 깔끔해 보였기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 운영을 시작하니 문제가 터지기 시작했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;백필 중에 API rate limit에 걸려 중간에 끊기면, 어디까지 받았는지 모름&lt;/li&gt;
&lt;li&gt;증분 업데이트에서 실패하면 전체 파이프라인이 멈춤&lt;/li&gt;
&lt;li&gt;중복 저장 방지를 위한 UPSERT 로직이 두 케이스에서 다르게 동작해야 함&lt;/li&gt;
&lt;li&gt;백필은 &quot;한 번만 돌면 되는&quot; 배치, 증분은 &quot;안정적으로 반복되는&quot; 스트림 &amp;mdash; 성격이 완전히 다름&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 분리했습니다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;def backfill_historical(start_date, end_date):
    # 청크 단위로 끊어서 받기
    # 체크포인트 저장 (어디까지 받았는지)
    # 실패 시 재시작 가능
    ...

def incremental_update():
    # 마지막 저장 시점부터 현재까지
    # 재시도 로직 + 지수 백오프
    # 실패해도 다음 주기엔 자동 복구
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백필은 **멱등성(idempotency)**이 핵심이고, 증분은 **복원력(resilience)**이 핵심입니다. 같은 코드로 둘 다 만족시키려는 건 욕심이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 백필 함수에서 한 번 더 당한 문제가 있었는데, hy_spread(하이일드 스프레드) 같은 특정 지표가 중간에 누락되면 KeyError로 파이프라인 전체가 멈추는 문제였습니다. 이건 개별 지표별 try-except를 &lt;b&gt;바깥 루프가 아니라 안쪽 루프&lt;/b&gt;에 넣는 것으로 해결했습니다. 이걸 찾느라 3일을 썼습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;배운 것 3&lt;/b&gt;: 같은 데이터를 다루더라도, 배치 잡(백필)과 스트림 잡(증분)은 다른 코드 경로로 분리해라. 그리고 에러 격리는 가장 안쪽 루프에서 해라.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 4. &quot;3시간마다 재학습, 그런데 학습 데이터는 매일만 바뀐다&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Passive의 모델은 3시간마다 자동 재학습됩니다. 이건 &lt;b&gt;시장 상황이 바뀌면 빠르게 반영하기 위한&lt;/b&gt; 설계였습니다. 그런데 곰곰이 생각해보면 이상합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;FRED 데이터는 &lt;b&gt;일 단위&lt;/b&gt;로 업데이트됨 (대부분 전날 마감 기준)&lt;/li&gt;
&lt;li&gt;Yahoo OHLCV도 &lt;b&gt;일 단위&lt;/b&gt;로 확정됨&lt;/li&gt;
&lt;li&gt;그럼 3시간마다 재학습한다고 해서 &lt;b&gt;실제로 새 데이터가 들어오는 건 아니지 않은가?&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맞습니다. 사실 데이터가 바뀌지 않는 주기에는 재학습해봤자 같은 결과가 나옵니다. 그래서 구조를 이렇게 바꿨습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;학습 주기를 두 겹으로 나누기&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Heavy 재학습 (매일 1회, 새벽)&lt;/b&gt;: 전날 마감 데이터 포함해서 모델 전체 재학습&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Light 추론 갱신 (3시간마다)&lt;/b&gt;: 모델은 그대로 두고, 현재 시장 지표만 새로 받아서 &lt;b&gt;추론 결과&lt;/b&gt;만 업데이트&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 바꾸니 두 가지가 좋아졌습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;비용 절감&lt;/b&gt;: 3시간마다 재학습하던 것을 하루 1회로 줄이니 컴퓨팅 비용이 1/8로 감소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;반응성 유지&lt;/b&gt;: 추론 갱신은 여전히 3시간마다 돌아서 사용자 입장에선 &quot;최신 해석&quot;이 보임&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 분 단위 데이터(예: 1분봉, 실시간 VIX)를 붙이면 그때 다시 설계를 바꿔야 하겠지만, 일 단위 데이터만 쓰는 현재 단계에선 이 구조가 적절하다고 판단했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;배운 것 4&lt;/b&gt;: &quot;자주 재학습&quot;이 좋은 게 아니라, &quot;데이터 갱신 주기에 맞춰&quot; 재학습하는 것이 맞다. 학습과 추론을 분리하면 비용과 반응성 둘 다 잡을 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 5. &quot;실시간 대시보드는 또 다른 파이프라인이다&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 만들고 나니 또 다른 문제가 생겼습니다. &lt;b&gt;사용자가 앱을 열었을 때 어떤 데이터를 보여줄 것인가?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선택지는 두 개였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A. 요청 시점에 DB 조회 + 실시간 계산&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;장점: 항상 최신&lt;/li&gt;
&lt;li&gt;단점: 응답 느림, DB 부하, 여러 사용자가 동시에 몰리면 터짐&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B. 미리 계산된 결과를 캐시에 저장, 사용자는 캐시만 조회&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;장점: 빠름, 부하 적음&lt;/li&gt;
&lt;li&gt;단점: 신선도 관리 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연히 B로 갔습니다. Supabase에 market_snapshot 테이블을 만들고, 10분마다 백엔드가 자동으로 계산 결과를 덮어씁니다. 사용자 요청은 이 테이블만 읽으면 되니 응답이 50ms 이내로 돌아옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 여기서 또 한 번 실수를 했습니다. 처음엔 &lt;b&gt;10분마다 전체 레코드를 INSERT&lt;/b&gt;하는 구조였습니다. 그러니 Supabase에 하루 144개, 1년이면 5만 개가 넘는 스냅샷이 쌓입니다. 한 달 만에 DB 용량 경고가 왔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결은 이렇게 했습니다. &lt;b&gt;단일 row를 UPSERT&lt;/b&gt;로 덮어쓰고, 필요한 과거 스냅샷은 별도 market_history 테이블에 일 단위로만 저장. 현재 상태는 1 row, 역사는 365 row. 이 구조로 바꾸니 DB가 조용해졌습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;배운 것 5&lt;/b&gt;: &quot;프로덕션 데이터&quot;와 &quot;히스토리 데이터&quot;는 테이블을 분리해라. 사용자가 조회하는 &quot;지금 이 순간&quot;과 분석가가 조회하는 &quot;지난 1년&quot;은 다른 쿼리 패턴이고, 다른 저장 전략이 필요하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돌아보면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파이프라인을 만드는 데 걸린 시간은 대략 &lt;b&gt;두 달&lt;/b&gt;이었습니다. 처음엔 &quot;API 붙이고 DB 넣으면 끝 아닌가?&quot; 싶었는데, 실제로 부딪힌 건 전부 &lt;b&gt;모델 외적인 문제&lt;/b&gt;들이었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;타임존과 날짜 정규화&lt;/li&gt;
&lt;li&gt;발표 지연과 look-ahead bias&lt;/li&gt;
&lt;li&gt;지표별 가용 구간 불일치&lt;/li&gt;
&lt;li&gt;백필 vs 증분의 코드 분리&lt;/li&gt;
&lt;li&gt;학습 주기와 추론 주기의 분리&lt;/li&gt;
&lt;li&gt;프로덕션 테이블과 히스토리 테이블의 분리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인 개발자가 혼자 이걸 다 겪는 것이 효율적이냐 하면, 비효율적입니다. 회사에서 데이터 엔지니어 팀이 할 일을 혼자서 더듬거리며 배우는 셈이기 때문입니다. 그런데 이 과정을 거치고 나서 확실히 알게 된 것이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;머신러닝 모델의 성능은 모델 튜닝보다 데이터 파이프라인의 설계가 먼저 결정한다.&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kaggle 대회에서는 데이터가 이미 정제되어 주어지지만, 실제 제품에서는 데이터를 &lt;b&gt;매일 받아와서 매일 갱신하고 매일 모델을 돌리는&lt;/b&gt; 구조를 만드는 것이 진짜 일입니다. 그리고 그 구조가 얼마나 탄탄한가가 모델의 실전 성능을 좌우합니다. Feature engineering보다 Pipeline engineering이 먼저라는 말을 이제야 체감합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/192</guid>
      <comments>https://ojko.tistory.com/192#entry192comment</comments>
      <pubDate>Wed, 15 Apr 2026 01:17:10 +0900</pubDate>
    </item>
    <item>
      <title>인덱스 투자자에게 시장 분석 도구가 필요한 이유 &amp;mdash; Passive 앱 개발지 ①</title>
      <link>https://ojko.tistory.com/191</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;앱 바로가기&lt;/b&gt;: &lt;a href=&quot;https://passive-financial-data-analysis-production.up.railway.app/&quot;&gt;Passive - 미국 지수 투자 분석&lt;/a&gt; 이 글은 Passive 앱 개발 과정을 다루는 시리즈의 첫 번째 글입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;figure id=&quot;og_1773672358245&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Passive&quot; data-og-description=&quot;미국 지수 투자자를 위한 금융 데이터 분석 사이트&quot; data-og-host=&quot;passive-financial-data-analysis-production.up.railway.app&quot; data-og-source-url=&quot;https://passive-financial-data-analysis-production.up.railway.app/&quot; data-og-url=&quot;https://passive-financial-data-analysis-production.up.railway.app&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/jrpd4/dJMb8Rj0Ji0/98LHdqsMkQy1eO5X6V4yL0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512&quot;&gt;&lt;a href=&quot;https://passive-financial-data-analysis-production.up.railway.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://passive-financial-data-analysis-production.up.railway.app/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/jrpd4/dJMb8Rj0Ji0/98LHdqsMkQy1eO5X6V4yL0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Passive&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;미국 지수 투자자를 위한 금융 데이터 분석 사이트&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;passive-financial-data-analysis-production.up.railway.app&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결하고자 했던 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2024년 기준, 전 세계 인덱스 펀드에 투자된 자산은 약 15조 달러를 넘었습니다. 뱅가드 창립자 존 보글이 1975년에 첫 인덱스 펀드를 만들었을 때, 월가에서는 &quot;보글의 어리석음(Bogle's Folly)&quot;이라고 비웃었습니다. 50년이 지난 지금, 인덱스 투자는 개인 투자의 표준이 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;S&amp;amp;P 500 ETF 하나만 사서 20년을 들고 있으면 연평균 10% 수익률을 기대할 수 있습니다. 이론적으로는 완벽합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 실제로 해보면 다릅니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&quot;아무것도 안 하는 게&quot; 왜 이렇게 어려운가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2020년 3월, 코로나 팬데믹으로 S&amp;amp;P 500이 한 달 만에 34% 폭락했습니다. 2022년에는 연준의 급격한 금리 인상으로 S&amp;amp;P 500이 연간 -19%, 나스닥은 -33%를 기록했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시기에 인덱스 투자자가 해야 할 일은 &quot;아무것도 안 하는 것&quot;입니다. 보글이 말한 &quot;Stay the course.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 계좌에서 수천만 원이 증발하는 걸 매일 보면서 아무것도 안 하기가 쉽지 않습니다. 실제로 2020년 3월에 S&amp;amp;P 500 ETF(SPY)에서 빠져나간 자금은 약 250억 달러였습니다. 바닥에서 팔고, 반등 후에 다시 산 사람들이 그만큼 많았다는 뜻입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패시브 투자의 진짜 적은 수수료도 아니고 종목 선택도 아닙니다. &lt;b&gt;투자자 자신의 심리&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 도구들의 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시장에 투자 분석 도구는 넘쳐납니다. TradingView, Yahoo Finance, Seeking Alpha, 국내에서는 증권사 MTS까지. 그런데 이 도구들의 공통적인 문제가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째, 액티브 트레이더를 위해 만들어졌습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차트 패턴, 매매 타이밍, 종목 스크리닝 &amp;mdash; 인덱스 투자자에게는 필요 없는 기능들입니다. SPY를 사서 들고 있는 사람에게 &quot;골든 크로스가 발생했습니다&quot;라는 알림은 의미가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째, &quot;지금 시장이 어떤 상태인가&quot;를 알려주지 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스 투자자에게 필요한 건 &quot;지금 사야 하나 팔아야 하나&quot;가 아닙니다. &quot;지금 시장이 펀더멘털로 움직이고 있는가, 아니면 공포/탐욕에 의해 움직이고 있는가&quot;입니다. 이 판단이 있어야 폭락 시에 패닉 셀링을 하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;셋째, 정보가 너무 많습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VIX가 30이면 무섭고, 금리가 올라가면 불안하고, 신용 스프레드가 벌어지면 걱정됩니다. 개별 지표는 많은데, 이걸 종합해서 &quot;그래서 지금이 어떤 상황인데?&quot;라는 질문에 답해주는 도구가 없습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Passive 앱의 컨셉: &quot;지금 시장의 상태&quot;를 보여주는 도구&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 Passive라는 앱을 만들기 시작했습니다. 핵심 아이디어는 간단합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여러 시장 데이터를 머신러닝으로 종합해서, 인덱스 투자자가 알아야 할 &quot;시장의 현재 상태&quot;를 한눈에 보여주자.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구체적으로 세 가지 질문에 답하는 앱입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 지금 시장이 펀더멘털로 움직이는가, 심리로 움직이는가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HMM(Hidden Markov Model)을 사용해서 시장을 4개 국면으로 분류합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;펀더멘털 반영&lt;/b&gt;: 주가가 기업 실적과 경제 지표에 맞게 움직이는 상태. 이 구간에서는 단기 변동에 흔들릴 필요가 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;센티멘트 지배&lt;/b&gt;: 주가가 펀더멘털과 무관하게 공포나 탐욕에 의해 움직이는 상태. 이 구간에서 패닉 셀링이 가장 많이 발생합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;투자자가 알아야 할 건 &quot;지금이 어떤 구간인가&quot;입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 하락/상승 가능성이 얼마나 되는가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XGBoost로 44개 시장 지표(VIX, 신용 스프레드, 금리, 거래량 등)를 종합해서 향후 20일 내 하락/상승 가능성을 수치화합니다. &quot;예측&quot;이 아니라 &quot;과거에 비슷한 상황이었을 때 어떤 일이 일어났는가&quot;를 보여주는 방식입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 지금 어떤 경기 국면인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FRED에서 수집한 8개 거시경제 지표(PMI, 금리차, 실업률 등)로 경기를 회복/확장/둔화/침체 4개 국면으로 분류합니다. 경기 국면에 따라 어떤 섹터가 강한지를 함께 보여줍니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 직접 만들었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 말하면, 처음부터 앱을 만들 생각은 아니었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IT경영학과 졸업 프로젝트로 S&amp;amp;P 500 포트폴리오 최적화를 주제로 잡았는데, 그 과정에서 HMM으로 시장 국면을 분류하는 논문들을 접했습니다. &quot;노이즈 구간과 시그널 구간을 구분할 수 있으면, 인덱스 투자자의 가장 큰 문제(패닉 셀링)를 해결할 수 있겠다&quot;는 생각이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, 데이터 수집부터 전처리, 모델 학습, 백엔드 API, 프론트엔드 UI, 배포까지 전 과정을 혼자 해보면 그 자체가 데이터 분석가로서의 역량을 보여줄 수 있겠다고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &quot;내가 인덱스 투자자로서 갖고 싶었던 도구&quot;를 만든 겁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기술 스택 한눈에 보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Passive 앱의 전체 구조를 간단히 정리하면 이렇습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영역 기술&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;데이터 수집&lt;/td&gt;
&lt;td&gt;Yahoo Finance API, FRED API, CNN Fear &amp;amp; Greed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 저장&lt;/td&gt;
&lt;td&gt;Supabase (PostgreSQL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML 모델&lt;/td&gt;
&lt;td&gt;HMM (hmmlearn), XGBoost, Optuna&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;백엔드&lt;/td&gt;
&lt;td&gt;FastAPI + APScheduler (3시간 주기 자동 실행)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프론트엔드&lt;/td&gt;
&lt;td&gt;Vanilla JS + CSS (모바일 최적화)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;배포&lt;/td&gt;
&lt;td&gt;Railway (Docker)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;분석&lt;/td&gt;
&lt;td&gt;Google Analytics 4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자세한 기술적 내용은 다음 편부터 다루겠습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2편: Yahoo Finance, FRED API로 100년치 시장 데이터 수집하기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6개 외부 API에서 데이터를 수집하고, 46개 피처를 엔지니어링하는 과정을 다룹니다. FRED API의 분당 120건 제한을 어떻게 처리했는지, Yahoo Finance에서 100년치 데이터를 가져올 때 생기는 문제들과 해결 방법을 공유합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;Passive 앱 바로가기&lt;/b&gt;: &lt;a href=&quot;https://passive-financial-data-analysis-production.up.railway.app/&quot;&gt;https://passive-financial-data-analysis-production.up.railway.app/&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;figure id=&quot;og_1773672363320&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Passive&quot; data-og-description=&quot;미국 지수 투자자를 위한 금융 데이터 분석 사이트&quot; data-og-host=&quot;passive-financial-data-analysis-production.up.railway.app&quot; data-og-source-url=&quot;https://passive-financial-data-analysis-production.up.railway.app/&quot; data-og-url=&quot;https://passive-financial-data-analysis-production.up.railway.app&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/jrpd4/dJMb8Rj0Ji0/98LHdqsMkQy1eO5X6V4yL0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512&quot;&gt;&lt;a href=&quot;https://passive-financial-data-analysis-production.up.railway.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://passive-financial-data-analysis-production.up.railway.app/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/jrpd4/dJMb8Rj0Ji0/98LHdqsMkQy1eO5X6V4yL0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Passive&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;미국 지수 투자자를 위한 금융 데이터 분석 사이트&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;passive-financial-data-analysis-production.up.railway.app&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Data Analyst Project</category>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/191</guid>
      <comments>https://ojko.tistory.com/191#entry191comment</comments>
      <pubDate>Mon, 16 Mar 2026 23:48:00 +0900</pubDate>
    </item>
    <item>
      <title>데이터 분석 머신러닝/통계분석/분석기법/전처리/시각화 종류 총정리</title>
      <link>https://ojko.tistory.com/190</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스트에서는 데이터 분석에는 어떤 대표적인 머신러닝 알고리즘이 있는지, 또한 분석에 많이 쓰이는 통계분석, 비즈니스적 그외에도 다양한 도메인 맞춤형 분석기법과 데이터 전처리, 시각화 종류들에 대해서 나열을 하고 다음 포스트에서 하나씩 상세히 설명하도록 하겠습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;머신러닝 알고리즘&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 비지도 학습 (Unsupervised Learning)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답(레이블)이 없는 데이터에서 패턴이나 구조를 찾아내는 기법입니다. 질문하신 클러스터링과 연관분석이 여기에 해당합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;군집화 (Clustering)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;K-Means: 데이터를 K개의 그룹으로 묶는 가장 대표적인 알고리즘입니다.&lt;/li&gt;
&lt;li&gt;계층적 군집화 (Hierarchical Clustering): 계층적 트리 구조를 이용해 데이터를 군집화합니다.&lt;/li&gt;
&lt;li&gt;DBSCAN: 데이터의 밀도를 기반으로 군집을 형성하며, 노이즈 데이터 처리에 효과적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBIwoq/dJMcagR5hEd/LKd8Zxwkpq3J3DCPwD3tC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBIwoq/dJMcagR5hEd/LKd8Zxwkpq3J3DCPwD3tC0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBIwoq/dJMcagR5hEd/LKd8Zxwkpq3J3DCPwD3tC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBIwoq%2FdJMcagR5hEd%2FLKd8Zxwkpq3J3DCPwD3tC0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;511&quot; height=&quot;383&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;연관 분석 (Association Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Apriori: 장바구니 분석이라고도 불리며, &quot;A를 산 사람이 B도 사더라&quot;와 같은 규칙을 발견합니다.&lt;/li&gt;
&lt;li&gt;FP-Growth: Apriori보다 속도가 빠른 연관 규칙 알고리즘입니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;550&quot; data-origin-height=&quot;450&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KvO43/dJMcacvoAnn/EOgoqdtDpNf03ZKJJKOOAK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KvO43/dJMcacvoAnn/EOgoqdtDpNf03ZKJJKOOAK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KvO43/dJMcacvoAnn/EOgoqdtDpNf03ZKJJKOOAK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKvO43%2FdJMcacvoAnn%2FEOgoqdtDpNf03ZKJJKOOAK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;550&quot; height=&quot;450&quot; data-origin-width=&quot;550&quot; data-origin-height=&quot;450&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;차원 축소 (Dimensionality Reduction)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PCA (주성분 분석): 데이터의 분산을 최대한 보존하면서 변수의 개수를 줄입니다.&lt;/li&gt;
&lt;li&gt;t-SNE: 고차원 데이터를 시각화하기 위해 저차원으로 변환할 때 주로 사용합니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1130&quot; data-origin-height=&quot;912&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NBtDl/dJMcahDqvXF/4VbUsmghm7HKsR0cVUkGd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NBtDl/dJMcahDqvXF/4VbUsmghm7HKsR0cVUkGd0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NBtDl/dJMcahDqvXF/4VbUsmghm7HKsR0cVUkGd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNBtDl%2FdJMcahDqvXF%2F4VbUsmghm7HKsR0cVUkGd0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1130&quot; height=&quot;912&quot; data-origin-width=&quot;1130&quot; data-origin-height=&quot;912&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 지도 학습 (Supervised Learning)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답(레이블)이 있는 데이터를 학습하여 미래 데이터를 예측하거나 분류하는 기법입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;분류 (Classification)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;의사결정나무 (Decision Tree): 스무고개처럼 규칙을 만들어 데이터를 분류합니다.&lt;/li&gt;
&lt;li&gt;로지스틱 회귀 (Logistic Regression): 이름은 회귀지만 실제로는 이진 분류(예/아니오)에 주로 사용됩니다.&lt;/li&gt;
&lt;li&gt;랜덤 포레스트 (Random Forest): 여러 개의 의사결정나무를 합쳐 성능을 높인 앙상블 기법입니다.&lt;/li&gt;
&lt;li&gt;SVM (Support Vector Machine): 데이터 간의 경계를 가장 잘 나누는 선(초평면)을 찾습니다.&lt;/li&gt;
&lt;li&gt;나이브 베이즈 (Naive Bayes): 확률적 통계 이론을 적용한 분류 기법입니다.&lt;/li&gt;
&lt;li&gt;XGBoost / LightGBM: 부스팅 방식을 사용하여 속도와 성능을 높인 알고리즘입니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1590&quot; data-origin-height=&quot;1102&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JD4Zq/dJMcacIVrOu/Q8tPhiKHh6NYreTWxdegHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JD4Zq/dJMcacIVrOu/Q8tPhiKHh6NYreTWxdegHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JD4Zq/dJMcacIVrOu/Q8tPhiKHh6NYreTWxdegHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJD4Zq%2FdJMcacIVrOu%2FQ8tPhiKHh6NYreTWxdegHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1590&quot; height=&quot;1102&quot; data-origin-width=&quot;1590&quot; data-origin-height=&quot;1102&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;회귀 (Regression)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;선형 회귀 (Linear Regression): 데이터의 경향성을 가장 잘 나타내는 직선을 찾습니다.&lt;/li&gt;
&lt;li&gt;릿지 (Ridge) / 라쏘 (Lasso): 선형 회귀에 규제를 적용해 과적합을 방지합니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;936&quot; data-origin-height=&quot;616&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zkaKQ/dJMcafZUDCk/rWHPg6woKtzGfSQBxToXcK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zkaKQ/dJMcafZUDCk/rWHPg6woKtzGfSQBxToXcK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zkaKQ/dJMcafZUDCk/rWHPg6woKtzGfSQBxToXcK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzkaKQ%2FdJMcafZUDCk%2FrWHPg6woKtzGfSQBxToXcK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;936&quot; height=&quot;616&quot; data-origin-width=&quot;936&quot; data-origin-height=&quot;616&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 기타 분석 기법&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;시계열 분석 (Time Series)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ARIMA: 과거 데이터의 패턴을 기반으로 미래 수치를 예측합니다.&lt;/li&gt;
&lt;li&gt;Prophet: 페이스북에서 만든 시계열 예측 라이브러리로 계절성 반영에 강점이 있습니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;262&quot; data-origin-height=&quot;192&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b70WeA/dJMcadgJ08O/U0vjRNZl7pmQhDyKsJUmuk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b70WeA/dJMcadgJ08O/U0vjRNZl7pmQhDyKsJUmuk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b70WeA/dJMcadgJ08O/U0vjRNZl7pmQhDyKsJUmuk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb70WeA%2FdJMcadgJ08O%2FU0vjRNZl7pmQhDyKsJUmuk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;262&quot; height=&quot;192&quot; data-origin-width=&quot;262&quot; data-origin-height=&quot;192&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;텍스트 마이닝 (Text Mining)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TF-IDF: 문서 내에서 단어의 중요도를 계산합니다.&lt;/li&gt;
&lt;li&gt;토픽 모델링 (LDA): 문서 집합에서 추상적인 주제를 찾아냅니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;308&quot; data-origin-height=&quot;164&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IDLHT/dJMcac9U7je/eXbOIIORSWi8PPHY14cSck/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IDLHT/dJMcac9U7je/eXbOIIORSWi8PPHY14cSck/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IDLHT/dJMcac9U7je/eXbOIIORSWi8PPHY14cSck/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIDLHT%2FdJMcac9U7je%2FeXbOIIORSWi8PPHY14cSck%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;308&quot; height=&quot;164&quot; data-origin-width=&quot;308&quot; data-origin-height=&quot;164&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;분석 방법론&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 고객 행동 및 경험 분석 (Behavioral Analysis)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 서비스 내에서 어떻게 행동하는지를 파악하는 기법들입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;코호트 분석 (Cohort Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;특정 기간 동안 공통된 특성(예: 가입 시기, 첫 구매 시기)을 가진 사용자 그룹(코호트)으로 나누어 시간 경과에 따른 행동 변화(주로 재방문율, 유지율)를 추적합니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1047&quot; data-origin-height=&quot;556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mnrRy/dJMcacB83EQ/cjMEfKqJpZacd3Q36HyZDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mnrRy/dJMcacB83EQ/cjMEfKqJpZacd3Q36HyZDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mnrRy/dJMcacB83EQ/cjMEfKqJpZacd3Q36HyZDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmnrRy%2FdJMcacB83EQ%2FcjMEfKqJpZacd3Q36HyZDk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1047&quot; height=&quot;556&quot; data-origin-width=&quot;1047&quot; data-origin-height=&quot;556&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;퍼널 분석 (Funnel Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가 유입되어 최종 목표(예: 결제, 회원가입)까지 도달하는 과정을 단계별로 나누어, 어디서 이탈이 가장 많이 발생하는지 파악합니다. '깔때기 분석'이라고도 합니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;924&quot; data-origin-height=&quot;515&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLZpMu/dJMcab4h5pq/SMqOTjeHfVSCPpibYSC0M0/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLZpMu/dJMcab4h5pq/SMqOTjeHfVSCPpibYSC0M0/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLZpMu/dJMcab4h5pq/SMqOTjeHfVSCPpibYSC0M0/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLZpMu%2FdJMcab4h5pq%2FSMqOTjeHfVSCPpibYSC0M0%2Fimg.webp&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;924&quot; height=&quot;515&quot; data-origin-width=&quot;924&quot; data-origin-height=&quot;515&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;경로 분석 (Path Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가 웹사이트나 앱 내에서 이동하는 구체적인 순서와 흐름을 시각화하여 파악합니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfv4xW/dJMcadAYGbh/56wtyInpsYXe03j5DEht7k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfv4xW/dJMcadAYGbh/56wtyInpsYXe03j5DEht7k/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfv4xW/dJMcadAYGbh/56wtyInpsYXe03j5DEht7k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbfv4xW%2FdJMcadAYGbh%2F56wtyInpsYXe03j5DEht7k%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;720&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 고객 가치 및 세분화 (Customer Value &amp;amp; Segmentation)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고객의 중요도를 평가하고 그룹을 나누는 기법입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RFM 분석&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;R&lt;/b&gt;ecency (최근성), &lt;b&gt;F&lt;/b&gt;requency (빈도), &lt;b&gt;M&lt;/b&gt;onetary (금액) 세 가지 지표를 기준으로 고객의 가치를 점수화하여 등급을 매깁니다. VIP 고객 관리나 이탈 방지에 필수적입니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;467&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DVtED/dJMcabDe2bx/RBEYQX4UQJh2D5JXaJXLQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DVtED/dJMcabDe2bx/RBEYQX4UQJh2D5JXaJXLQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DVtED/dJMcabDe2bx/RBEYQX4UQJh2D5JXaJXLQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDVtED%2FdJMcabDe2bx%2FRBEYQX4UQJh2D5JXaJXLQK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;730&quot; height=&quot;467&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;467&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LTV 분석 (Life Time Value)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;고객 생애 가치 분석입니다. 한 명의 고객이 유입되어 이탈할 때까지 회사에 기여하는 총 수익을 예측합니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgg7tO/dJMcaiI5jsM/h4zp6d99mKop9KUZZHfdV0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgg7tO/dJMcaiI5jsM/h4zp6d99mKop9KUZZHfdV0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgg7tO/dJMcaiI5jsM/h4zp6d99mKop9KUZZHfdV0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcgg7tO%2FdJMcaiI5jsM%2Fh4zp6d99mKop9KUZZHfdV0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;506&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이탈 분석 (Churn Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;고객이 서비스를 그만두는(이탈) 징후를 미리 파악하고, 이탈률을 계산하거나 이탈 가능성이 높은 고객을 예측합니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;794&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d4Actt/dJMb99ZJ27v/4agOlMLqHTdc8DhjRFqjm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d4Actt/dJMb99ZJ27v/4agOlMLqHTdc8DhjRFqjm0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d4Actt/dJMb99ZJ27v/4agOlMLqHTdc8DhjRFqjm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd4Actt%2FdJMb99ZJ27v%2F4agOlMLqHTdc8DhjRFqjm0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;794&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;794&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 마케팅 및 성과 측정 (Marketing &amp;amp; Performance)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 전략이 효과적이었는지 검증하는 기법입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;A/B 테스트&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;두 가지 이상의 버전(A안, B안)을 무작위로 사용자에게 노출하여 어떤 안이 더 높은 성과(클릭률, 구매율 등)를 내는지 통계적으로 검증합니다.&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;822&quot; data-origin-height=&quot;481&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cwO4Xh/dJMcahXKw7D/zkLpUXxpray490D6Vkkar0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cwO4Xh/dJMcahXKw7D/zkLpUXxpray490D6Vkkar0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cwO4Xh/dJMcahXKw7D/zkLpUXxpray490D6Vkkar0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcwO4Xh%2FdJMcahXKw7D%2FzkLpUXxpray490D6Vkkar0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;822&quot; height=&quot;481&quot; data-origin-width=&quot;822&quot; data-origin-height=&quot;481&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기여도 분석 (Attribution Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;고객이 구매하기까지 거쳐간 여러 접점(광고, 검색, SNS 등) 중 어떤 채널이 구매에 얼마나 기여했는지를 분석합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. 인과 추론 (Causal Inference)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 &quot;데이터가 같이 움직인다(상관관계)&quot;가 아니라, &quot;이것 때문에 저것이 변했다(인과관계)&quot;를 밝혀내는 고급 분석입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;이중 차분법 (DID, Difference in Differences)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;특정 정책이나 이벤트의 효과를 측정할 때 사용합니다. (예: 쿠폰을 발행한 집단과 안 한 집단의 전후 변화량 차이를 비교)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인과 임팩트 (Causal Impact)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;마케팅 캠페인이나 UI 변경 후, 만약 그 이벤트가 없었다면 어땠을지 가상의 상황(Counterfactual)을 예측해 실제와 비교합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;5. 네트워크 분석 (Network Analysis / SNA)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 간의 '연결 관계'를 분석합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;중심성 분석 (Centrality Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네트워크에서 가장 영향력 있는 '허브'를 찾습니다. (예: 사내에서 정보가 가장 많이 거쳐가는 사람은 누구인가? 전염병 슈퍼 전파자는 누구인가?)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;커뮤니티 탐지 (Community Detection)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복잡한 네트워크 안에서 끼리끼리 뭉친 하위 그룹을 찾아냅니다. (예: SNS 팔로우 관계를 통한 관심사 그룹 발견)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;6. 텍스트 및 감성 분석 (Text &amp;amp; Sentiment Analysis)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자가 아닌 글자(비정형 데이터)를 분석합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;감성 분석 (Sentiment Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;리뷰나 댓글이 긍정적인지, 부정적인지 점수화합니다. (예: 신제품 출시에 대한 고객 반응 분석)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;키워드 추출 및 워드 클라우드&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;텍스트에서 가장 많이 등장하거나 중요한 핵심 단어를 시각화합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;7. 최적화 및 시뮬레이션 (Optimization &amp;amp; Simulation)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;앞으로 어떻게 될까?&quot;를 넘어 &quot;어떻게 하는 것이 최선인가?&quot;를 찾습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;몬테카를로 시뮬레이션 (Monte Carlo Simulation)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;불확실한 변수들을 무작위로 수만 번 대입해 보며 발생 가능한 모든 결과의 확률을 계산합니다. (예: 리스크 관리, 주식 포트폴리오 수익률 예측)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;선형 계획법 (Linear Programming)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;자원(돈, 시간, 인력)이 한정된 상황에서 이익을 최대화하거나 비용을 최소화하는 조합을 찾습니다. (예: 배송 경로 최적화, 생산 계획 수립)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;통계분석&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 기술 통계 (Descriptive Statistics)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 데이터를 표나 그래프, 대표값으로 요약해서 &quot;현재 데이터가 어떻게 생겼는지&quot; 보여주는 기법입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;중심 경향성 분석:&lt;/b&gt; 데이터의 중심이 어디인지 파악합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;평균 (Mean):&lt;/b&gt; 산술적인 평균값.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;중앙값 (Median):&lt;/b&gt; 데이터를 순서대로 나열했을 때 딱 중간에 있는 값 (이상치에 강함).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최빈값 (Mode):&lt;/b&gt; 가장 자주 등장하는 값.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;산포도 분석:&lt;/b&gt; 데이터가 얼마나 퍼져 있는지 파악합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;분산 (Variance) / 표준편차 (Standard Deviation):&lt;/b&gt; 평균으로부터 데이터가 얼마나 떨어져 있는지 나타냅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;범위 (Range) / 사분위수 (Quartiles):&lt;/b&gt; 데이터의 최소~최대 구간 및 25%, 50%, 75% 지점을 확인합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 추론 통계 (Inferential Statistics) - 가설 검정&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이 결과가 우연이 아님&quot;을 수학적으로 증명할 때 사용합니다. A/B 테스트나 품질 관리에서 필수적입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;T-검정 (T-test)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;두 집단&lt;/b&gt;의 평균 차이가 통계적으로 유의미한지 확인합니다.&lt;/li&gt;
&lt;li&gt;예: &quot;남녀 간의 평균 연봉 차이가 진짜 있는가?&quot;, &quot;신약 투여 전후의 혈압 차이가 있는가?&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 분석 (ANOVA)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;세 집단 이상&lt;/b&gt;의 평균 차이를 비교합니다.&lt;/li&gt;
&lt;li&gt;예: &quot;A, B, C 세 가지 마케팅 시안에 따른 클릭률 차이가 있는가?&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;카이제곱 검정 (Chi-square Test)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;범주형 데이터&lt;/b&gt;(예/아니오, A반/B반) 간의 연관성을 확인합니다.&lt;/li&gt;
&lt;li&gt;예: &quot;성별(남/녀)과 선호하는 자동차 색상(검/흰/빨) 사이에 관계가 있는가?&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 관계 및 예측 분석&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수들 사이의 관계를 파악하거나 미래를 예측합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;상관 분석 (Correlation Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;두 변수가 얼마나 강하게 연결되어 있는지 확인합니다. (-1 ~ 1 사이의 값)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;피어슨 상관계수:&lt;/b&gt; 일반적인 수치형 데이터 간의 선형 관계.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스피어만 상관계수:&lt;/b&gt; 서열(순위) 데이터 간의 관계.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;회귀 분석 (Regression Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단순히 관계가 있다를 넘어, &quot;x가 변할 때 y가 얼마나 변하는지&quot; 함수식을 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단순 선형 회귀:&lt;/b&gt; 독립변수가 1개일 때 (예: 기온이 오르면 아이스크림 판매량이 얼마나 오르나?).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;다중 선형 회귀:&lt;/b&gt; 독립변수가 여러 개일 때 (예: 기온, 습도, 요일이 아이스크림 판매량에 미치는 영향).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. 다변량 분석 (Multivariate Analysis)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수가 매우 많을 때 복잡성을 줄이고 구조를 파악합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;요인 분석 (Factor Analysis)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;수많은 변수 속에 숨어 있는 공통된 잠재 요인(Factor)을 찾아냅니다.&lt;/li&gt;
&lt;li&gt;예: 국어, 영어, 수학 점수 데이터에서 '언어 능력', '수리 능력'이라는 잠재 요인 추출.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;주성분 분석 (PCA)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터의 정보 손실을 최소화하면서 변수의 개수를 줄이는 차원 축소 기법입니다. (머신러닝 전처리로도 많이 쓰임)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;5. 생존 분석 (Survival Analysis)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 사건의 발생 여부가 아니라, **&quot;사건이 발생하기까지 걸린 시간&quot;**을 분석합니다. 의학 연구뿐만 아니라 비즈니스에서 고객 이탈 예측에 매우 중요하게 쓰입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;카플란-마이어 분석 (Kaplan-Meier Estimator):&lt;/b&gt; 시간 경과에 따른 생존 확률(고객이 남아있을 확률)을 추정하여 시각화합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;콕스 비례위험 모형 (Cox Proportional Hazards Model):&lt;/b&gt; 어떤 변수(나이, 성별, 가입금액 등)가 생존 시간(이탈 시점)에 얼마나 영향을 미치는지 분석합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;6. 비모수 통계 (Non-parametric Statistics)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 &lt;b&gt;정규분포(종 모양)를 따르지 않거나&lt;/b&gt;, 데이터 개수가 너무 적을 때(30개 미만 등) 사용하는 기법입니다. 평균 대신 '순위(Rank)'나 '중앙값'을 주로 사용합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;만-휘트니 U 검정 (Mann-Whitney U test):&lt;/b&gt; 두 집단의 차이를 비교할 때 사용합니다. (T-test의 대안)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;크러스칼-왈리스 검정 (Kruskal-Wallis test):&lt;/b&gt; 세 집단 이상의 차이를 비교할 때 사용합니다. (ANOVA의 대안)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;윌콕슨 부호 순위 검정 (Wilcoxon Signed-rank test):&lt;/b&gt; 짝을 이룬 두 데이터의 차이를 비교할 때 사용합니다. (Paired T-test의 대안)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;7. 베이지안 통계 (Bayesian Statistics)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 빈도주의 통계(P-value 중심)와 달리, **&quot;사전 지식(Prior)에 새로운 데이터를 더해 확률을 업데이트(Posterior)&quot;**하는 방식입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터가 추가될 때마다 확률이 계속 갱신되므로, 실시간 의사결정이나 데이터가 부족한 초기 단계 분석에 유리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MCMC (Markov Chain Monte Carlo):&lt;/b&gt; 복잡한 베이지안 모델의 파라미터를 추정하기 위해 시뮬레이션을 사용하는 기법입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;8. 구조방정식 모델링 (SEM, Structural Equation Modeling)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 변수 간의 복잡한 인과관계를 한 번에 검증하는 기법입니다. 설문 조사 분석이나 사회과학 연구에서 많이 쓰입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;경로 분석 (Path Analysis):&lt;/b&gt; 변수들 간의 직접적인 영향과 간접적인 영향을 동시에 파악합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;확인적 요인 분석 (CFA):&lt;/b&gt; 측정하려는 설문 문항들이 의도한 개념(잠재 변수)을 제대로 설명하고 있는지 검증합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;9. 공간 통계 (Spatial Statistics)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터에 **위치 정보(위도, 경도, 주소)**가 포함되어 있을 때 사용합니다. 지도 데이터 분석에 필수적입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;모란 지수 (Moran's I):&lt;/b&gt; 공간적 자기상관성을 측정합니다. (예: 범죄 발생 지역이 특정 구역에 뭉쳐 있는가, 퍼져 있는가?)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;크리깅 (Kriging):&lt;/b&gt; 특정 지점의 데이터를 바탕으로 데이터가 없는 주변 지점의 값을 예측/보간합니다. (예: 관측소가 없는 지역의 미세먼지 농도 추정)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;10. 다변량 분석의 확장 (Advanced Multivariate)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수 간의 관계를 더 깊게 파고드는 기법들입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;정준 상관 분석 (Canonical Correlation Analysis):&lt;/b&gt; 변수 '그룹'과 다른 변수 '그룹' 간의 상관관계를 분석합니다. (예: '신체적 건강 지표들'과 '정신적 건강 지표들' 사이의 관계)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;컨조인트 분석 (Conjoint Analysis):&lt;/b&gt; 소비자가 제품을 선택할 때 어떤 속성(가격, 디자인, 성능 등)에 가중치를 두는지 분석하여 최적의 상품 조합을 찾습니다. (마케팅 필수 기법)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;데이터 전처리&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 데이터 정제 (Data Cleaning)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터의 오류나 누락을 수정하는 과정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;결측치 처리 (Missing Values):&lt;/b&gt; 삭제, 평균/중앙값/최빈값 대치, 예측 모델 사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이상치 처리 (Outlier Handling):&lt;/b&gt; IQR 방식, Z-Score, 격리 또는 제거&lt;/li&gt;
&lt;li&gt;&lt;b&gt;노이즈 제거 (Noise Reduction):&lt;/b&gt; 구간화(Binning), 군집화, 회귀 분석&lt;/li&gt;
&lt;li&gt;&lt;b&gt;중복 데이터 제거 (Deduplication):&lt;/b&gt; 중복된 레코드 식별 및 삭제&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 데이터 변환 (Data Transformation)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분석에 적합한 형태로 데이터를 변경하는 과정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스케일링 (Scaling): Min-Max 정규화, Z-Score 표준화, Robust 스케일링&lt;/li&gt;
&lt;li&gt;인코딩 (Encoding): 레이블 인코딩(Label), 원-핫 인코딩(One-Hot)&lt;/li&gt;
&lt;li&gt;이산화 (Discretization): 연속형 변수를 범주형으로 변환 (Binning)&lt;/li&gt;
&lt;li&gt;함수 변환 (Function Transformation): 로그 변환, 제곱근 변환 (왜도 조정)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 데이터 축소 (Data Reduction)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터의 크기를 줄이면서 정보 손실을 최소화하는 과정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;차원 축소 (Dimensionality Reduction): PCA(주성분 분석), LDA, t-SNE&lt;/li&gt;
&lt;li&gt;특징 선택 (Feature Selection): Filter, Wrapper, Embedded 방식&lt;/li&gt;
&lt;li&gt;표본 추출 (Sampling): 단순 임의 추출, 층화 추출&lt;/li&gt;
&lt;li&gt;데이터 압축 (Data Compression): 수치적 압축 기법&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. 데이터 통합 (Data Integration)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 소스의 데이터를 하나로 합치는 과정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스키마 통합 (Schema Integration): 다른 데이터베이스의 구조 일치화&lt;/li&gt;
&lt;li&gt;엔티티 식별 (Entity Resolution): 같은 대상을 가리키는 데이터 연결&lt;/li&gt;
&lt;li&gt;데이터 결합 (Merging/Joining): Key 값을 기준으로 데이터 병합&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;5. 데이터 불균형 처리 (Imbalanced Data Handling)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스 간 비율 차이를 조정하는 과정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;오버샘플링 (Oversampling): SMOTE, ADASYN (소수 클래스 증식)&lt;/li&gt;
&lt;li&gt;언더샘플링 (Undersampling): Random Undersampling, Tomek links (다수 클래스 제거)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;6. 피처 엔지니어링 (Feature Engineering)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 데이터를 조합하여 모델 성능을 높일 수 있는 새로운 변수를 만드는 과정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파생 변수 생성: 기존 변수의 사칙연산 (예: 매출 / 방문자 수 = 객단가)&lt;/li&gt;
&lt;li&gt;다항 특성 (Polynomial Features): 변수의 제곱, 세제곱 등을 추가하여 비선형 관계 표현&lt;/li&gt;
&lt;li&gt;교호 작용 (Interaction Features): 두 변수를 곱하여 변수 간의 상호작용 반영&lt;/li&gt;
&lt;li&gt;도메인 특화 변수: 해당 비즈니스 로직에 맞는 지표 생성 (예: BMI 지수 계산)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;7. 텍스트 데이터 전처리 (NLP Specific)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비정형 텍스트 데이터를 분석 가능한 형태로 만드는 과정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토큰화 (Tokenization): 문장을 단어, 형태소, 글자 단위로 자르기&lt;/li&gt;
&lt;li&gt;정제 (Cleaning): 특수문자, HTML 태그, 이모티콘 제거&lt;/li&gt;
&lt;li&gt;불용어 제거 (Stopwords Removal): 조사, 관사 등 분석에 무의미한 단어 제거&lt;/li&gt;
&lt;li&gt;어간/표제어 추출 (Stemming/Lemmatization): 단어의 뿌리 형태나 기본형으로 통일&lt;/li&gt;
&lt;li&gt;벡터화 (Vectorization): 텍스트를 숫자로 변환 (BoW, TF-IDF, Word Embedding)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;8. 시계열 데이터 전처리 (Time-Series Specific)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간의 흐름이 있는 데이터에 특화된 처리 과정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;시차 특성 생성 (Lag Features): 과거 시점의 데이터를 현재의 변수로 추가 (t-1, t-2)&lt;/li&gt;
&lt;li&gt;이동 평균 (Rolling Window): 최근 N일간의 평균이나 합계를 변수로 추가&lt;/li&gt;
&lt;li&gt;차분 (Differencing): 비정상성 데이터를 정상성 데이터로 변환 (현재 값 - 과거 값)&lt;/li&gt;
&lt;li&gt;리샘플링 (Resampling): 시간 단위를 변경 (분 단위 -&amp;gt; 시간 단위 합계)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;9. 이미지 데이터 전처리 (Image Specific)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지 픽셀 데이터를 모델이 학습하기 좋게 만드는 과정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터 증강 (Augmentation): 회전, 반전, 자르기, 밝기 조절로 데이터 수 늘리기&lt;/li&gt;
&lt;li&gt;크기 조정 (Resizing/Cropping): 모든 이미지의 해상도 통일&lt;/li&gt;
&lt;li&gt;정규화 (Normalization): 픽셀 값을 0~1 사이 또는 -1~1 사이로 조정&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;10. 데이터 분할 (Data Splitting)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 전 반드시 수행해야 하는 단계입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Train / Validation / Test 분리: 학습용, 검증용, 평가용 데이터로 나누기&lt;/li&gt;
&lt;li&gt;교차 검증 분할 (K-Fold): 데이터가 적을 때 효율적인 학습을 위해 데이터를 여러 겹으로 나누기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;데이터 시각화&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 비교와 추세 (Comparison &amp;amp; Trend)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 간의 크기를 비교하거나 시간의 흐름에 따른 변화를 파악할 때 사용합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;막대 차트 (Bar Chart): 범주형 데이터 간의 값을 비교할 때 가장 흔히 사용됩니다. 수직 또는 수평으로 표현합니다.&lt;/li&gt;
&lt;li&gt;선 차트 (Line Chart): 시계열 데이터의 추세나 변동을 볼 때 적합합니다. 주식 차트가 대표적입니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;영역 차트 (Area Chart): 선 차트 아래 영역을 색으로 채워, 추세와 함께 전체적인 규모(Volume)를 강조할 때 씁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 분포와 통계 (Distribution)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 어떻게 퍼져 있는지, 중심 위치는 어디인지 파악할 때 사용합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;히스토그램 (Histogram): 연속형 변수의 구간별 빈도수를 나타내어 데이터의 분포 모양을 확인합니다.&lt;/li&gt;
&lt;li&gt;박스 플롯 (Box Plot): 데이터의 최소값, 최대값, 중앙값, 사분위수를 요약하여 보여줍니다. 이상치(Outlier)를 탐지하는 데 매우 유용합니다.&lt;/li&gt;
&lt;li&gt;바이올린 플롯 (Violin Plot): 박스 플롯과 비슷하지만 데이터의 밀도(Density)까지 함께 보여줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 관계와 상관성 (Relationship)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 개 이상의 변수가 서로 어떤 영향을 주고받는지 확인할 때 사용합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;산점도 (Scatter Plot): 두 변수의 좌표를 점으로 찍어 상관관계(양의 상관, 음의 상관 등)를 파악합니다.&lt;/li&gt;
&lt;li&gt;버블 차트 (Bubble Chart): 산점도에 점의 크기(Size)라는 제3의 변수를 추가하여 3차원 정보를 표현합니다.&lt;/li&gt;
&lt;li&gt;히트맵 (Heatmap): 값의 크기를 색상으로 표현합니다. 변수 간의 상관계수 행렬을 시각화하거나, 시간대별 활동량을 볼 때 유용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. 비중과 구성 (Composition)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 데이터에서 각 항목이 차지하는 비율을 볼 때 사용합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파이 차트 / 도넛 차트 (Pie / Donut Chart): 전체를 100%로 보았을 때 각 부분의 비율을 보여줍니다. 항목이 많으면 가독성이 떨어집니다.&lt;/li&gt;
&lt;li&gt;트리맵 (Treemap): 계층 구조가 있는 데이터를 사각형의 크기로 시각화합니다. 카테고리 내의 하위 카테고리 비중을 볼 때 좋습니다.&lt;/li&gt;
&lt;li&gt;누적 막대 차트 (Stacked Bar Chart): 막대 하나를 여러 범주로 나누어 전체 크기와 내부 구성을 동시에 비교합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;5. 특수 목적 및 지리 정보 (Specialized &amp;amp; Geospatial)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 비즈니스 로직이나 지리적 정보를 표현할 때 사용합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캔들스틱 차트 (Candlestick Chart): 주식 시장에서 시가, 고가, 저가, 종가를 동시에 표현할 때 사용합니다.&lt;/li&gt;
&lt;li&gt;코호트 차트 (Cohort Chart): 특정 기간에 진입한 사용자 그룹의 시간 경과에 따른 잔존율 등을 히트맵 형태로 보여줍니다.&lt;/li&gt;
&lt;li&gt;퍼널 차트 (Funnel Chart): 마케팅이나 영업 깔때기 단계별 전환율과 이탈률을 시각화합니다.&lt;/li&gt;
&lt;li&gt;등치 지역도 (Choropleth Map): 지도상의 지역을 데이터 값에 따라 색상으로 구분하여 표시합니다&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/190</guid>
      <comments>https://ojko.tistory.com/190#entry190comment</comments>
      <pubDate>Sun, 22 Feb 2026 23:05:14 +0900</pubDate>
    </item>
    <item>
      <title>[어플리케이션 개발] 3. 학원 입지 분석 대시보드 앱 개발 - 아키텍처 및 결과물</title>
      <link>https://ojko.tistory.com/189</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;아키텍처&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;765&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o8b4A/dJMcajnEwDW/oteCpERWg9oIGHaUnIBEAK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o8b4A/dJMcajnEwDW/oteCpERWg9oIGHaUnIBEAK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o8b4A/dJMcajnEwDW/oteCpERWg9oIGHaUnIBEAK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo8b4A%2FdJMcajnEwDW%2FoteCpERWg9oIGHaUnIBEAK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1024&quot; height=&quot;765&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;765&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;앱 구조 (App.js)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashRouter 기반으로 3개 페이지를 라우팅합니다. / &amp;rarr; 스플래시, /home &amp;rarr; 홈, /map &amp;rarr; 지도 순서로 이동하며, Capacitor의 backButton 이벤트를 감지해 홈/스플래시에서 뒤로 가기 시 앱을 종료합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스플래시 (Splash.js)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 진입 시 로고 + 텍스트를 2.5초 동안 페이드인/아웃 애니메이션으로 보여주고 홈으로 이동합니다. 네이티브 앱 빌드 시 Capacitor의 기본 스플래시를 즉시 숨기고 React 애니메이션으로 대체합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;지도 (MapPage.js)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase Storage에서 GeoJSON을 fetch해 Deck.gl의 GeoJsonLayer로 행정동 경계를 렌더링합니다. 선택한 컬럼 타입에 따라 색상 로직이 분기됩니다. 클러스터 타입이면 CLUSTER_COLORS 딕셔너리로 고정색을 쓰고, 수치 타입이면 min/max 기준 0~1 비율을 getHeatColorRgb로 파란색&amp;rarr;빨간색 히트맵으로 변환합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사이드바는 COLUMN_GROUPS 객체로 7개 카테고리(클러스터, 분석지표, 인구, 학교, 학원 등)를 정의하고, 학원수별&amp;middot;학원경쟁도별처럼 GeoJSON 중첩 객체에서 동적으로 컬럼을 추출하는 카테고리도 별도 처리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일/데스크탑 반응형은 useIsMobile 커스텀 훅으로 분기합니다. 데스크탑은 좌측 사이드바 + 우측 팝업 카드, 모바일은 바텀시트 UI를 사용합니다. 바텀시트는 none / menu / detail 세 가지 모드와 half / full 두 가지 높이를 터치 제스처(onTouchStart / onTouchEnd)로 전환합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동 클릭 시 해당 동의 경계 좌표 평균을 계산해 지도를 자동으로 이동시키고, 상위 20개 랭킹 팝업과 동 이름 검색 기능도 함께 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1844&quot; data-origin-height=&quot;932&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qs4gy/dJMcad12OG6/omnksVPCMkEki6Pru7FKV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qs4gy/dJMcad12OG6/omnksVPCMkEki6Pru7FKV1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qs4gy/dJMcad12OG6/omnksVPCMkEki6Pru7FKV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqs4gy%2FdJMcad12OG6%2FomnksVPCMkEki6Pru7FKV1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1844&quot; height=&quot;932&quot; data-origin-width=&quot;1844&quot; data-origin-height=&quot;932&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서비스워커 (index.js / sw.js)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 캐시 문제를 방지하기 위해 index.js에서 앱 로드 시 기존 서비스워커를 전부 unregister하고 캐시를 삭제합니다. sw.js는 index.html만 캐싱하는 최소 구성으로 유지합니다.&lt;/p&gt;</description>
      <category>Data Analyst Project</category>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/189</guid>
      <comments>https://ojko.tistory.com/189#entry189comment</comments>
      <pubDate>Sat, 21 Feb 2026 18:25:02 +0900</pubDate>
    </item>
    <item>
      <title>[어플리케이션 개발] 2. 학원 입지 분석 대시보드 앱 개발 - 분석지표 생성 및 클러스터링</title>
      <link>https://ojko.tistory.com/188</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot;&gt;학원명당 - 원스토어&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1771657009872&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;학원명당 - 원스토어&quot; data-og-description=&quot;학원명당: 데이터 기반 학원 입지 분석 서비스&quot; data-og-host=&quot;m.onestore.co.kr&quot; data-og-source-url=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot; data-og-url=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bbdRSo/dJMb8T9V9WQ/M321cIRa3HkGTYVcuFFod1/img.png?width=1024&amp;amp;height=578&amp;amp;face=0_0_1024_578,https://scrap.kakaocdn.net/dn/nb2hF/dJMb8VNr2Ml/k5Kt4Uy519PSbBPBt84Ar1/img.png?width=1024&amp;amp;height=578&amp;amp;face=0_0_1024_578,https://scrap.kakaocdn.net/dn/MlDiM/dJMb8950iWT/Fq4BkZgVugJL8M7XuU4TB0/img.png?width=470&amp;amp;height=940&amp;amp;face=0_0_470_940&quot;&gt;&lt;a href=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bbdRSo/dJMb8T9V9WQ/M321cIRa3HkGTYVcuFFod1/img.png?width=1024&amp;amp;height=578&amp;amp;face=0_0_1024_578,https://scrap.kakaocdn.net/dn/nb2hF/dJMb8VNr2Ml/k5Kt4Uy519PSbBPBt84Ar1/img.png?width=1024&amp;amp;height=578&amp;amp;face=0_0_1024_578,https://scrap.kakaocdn.net/dn/MlDiM/dJMb8950iWT/Fq4BkZgVugJL8M7XuU4TB0/img.png?width=470&amp;amp;height=940&amp;amp;face=0_0_470_940');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;학원명당 - 원스토어&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;학원명당: 데이터 기반 학원 입지 분석 서비스&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;m.onestore.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스틩은 저번 포스팅에 이은 분석지표 생성과 클러스터링을 통한 특성에 따른 학원 입지 군집화를 해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저번과 마찬가지로 코드와 함께 밑에 설명을 첨부하는 식으로 진행하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 파생 지표 계산&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;EPI (평균아파트가격 &amp;times; 총학생수) &amp;mdash; 시장 구매력 지수&lt;/li&gt;
&lt;li&gt;ROI_GAP (총학생수 / 평당가격 % 상가평당가격) &amp;mdash; 입지 가성비 지수&lt;/li&gt;
&lt;li&gt;과목별 학원경쟁도 계산 (학생 수 대비 학원 수)&lt;/li&gt;
&lt;li&gt;각 지표의 상위 % 백분위 컬럼 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. K-means 클러스터링&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;엘보우 + 실루엣 점수로 최적 k 탐색&lt;/li&gt;
&lt;li&gt;6개 클러스터 확정 및 라벨링 (교육밀집지, 블루오션 등)&lt;/li&gt;
&lt;li&gt;이상치 제거 (남양주 도농동 등 특정 동 수동 제외)&lt;/li&gt;
&lt;li&gt;1% 클리핑 및 QuantileTransformer 정규화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. GeoJSON 생성&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서울/경기 SHP 파일 로드 &amp;rarr; WGS84 좌표계 변환&lt;/li&gt;
&lt;li&gt;시군구 코드 매핑으로 시도 + dong 생성&lt;/li&gt;
&lt;li&gt;부천시 행정구역 개편 보정 (오정구&amp;middot;원미구&amp;middot;소사구 &amp;rarr; 부천시)&lt;/li&gt;
&lt;li&gt;dong_data와 GeoJSON 키 매칭 후 dong_map.geojson 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 학원 카테고리 통합 및 최종 저장&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;134개 교습과정을 9개 대분류로 통합 (입시/보습, 음악/예술, 외국어 등)&lt;/li&gt;
&lt;li&gt;최종 dong_map.geojson 재생성&lt;/li&gt;
&lt;li&gt;simplify로 지도 용량 최적화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 파생 지표 계산&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;① 총학원수 집계 및 수치 타입 변환&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQywQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;# 수치형 데이터 변환 및 총학원수 산출
final = pd.read_csv(f'{base_path}final_merged.csv')
academy_cols = [col for col in final.columns if col not in ['시도', 'dong', '노령화지수', '평균나이', '총인구', '총주택(거처)수', '10세미만', '10대', '20대', '30대', '40대', '50대', '60대', '70대', '80대', '90대', '100세이상', '평균아파트가격', '평당가격', '거래건수', '평균상가가격', '상가평당가격', '상가거래건수', '초등학생수', '중학생수', '고등학생수', '초등학교수', '중학교수', '고등학교수', '총학생수', '총학교수']]
final['총학원수'] = final[academy_cols].sum(axis=1)
final['총학생수'] = pd.to_numeric(final['총학생수'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
final['총학원수'] = pd.to_numeric(final['총학원수'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
final['평균아파트가격'] = pd.to_numeric(final['평균아파트가격'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
final['평당가격'] = pd.to_numeric(final['평당가격'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
final['상가평당가격'] = pd.to_numeric(final['상가평당가격'].astype(str).str.replace(',', ''), errors='coerce').fillna(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;이전 단계까지 문자열로 저장된 수치 컬럼들을 지표 계산에 앞서 float으로 변환합니다. 앞서 거래금액과 동일하게 콤마를 제거한 뒤 pd.to_numeric으로 변환하고, 변환 실패 시 fillna(0)으로 처리합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;② EPI &amp;middot; ROI_GAP 지표 계산&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQzAQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;# 핵심 입지 지표 계산
final['EPI'] = np.where(final['평균아파트가격'].notna() &amp;amp; final['총학생수'].notna(), final['평균아파트가격'] * final['총학생수'], np.nan)
final['ROI_GAP'] = np.where(final['평당가격'].notna() &amp;amp; final['상가평당가격'].notna(), (final['총학생수'] / final['평당가격']) % final['상가평당가격'], np.nan)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;두 지표 모두 np.where로 양쪽 값이 모두 존재할 때만 계산하고, 한쪽이라도 NaN이면 NaN을 반환합니다. EPI는 평균 아파트 가격과 총학생 수를 곱해 해당 동의 교육 수요와 구매력을 동시에 반영하는 시장 구매력 지수입니다. 값이 높을수록 학원 수요가 높고 지불 여력도 높은 지역으로 해석합니다. ROI_GAP은 학생 수를 아파트 평당가격으로 나눈 뒤 상가 평당가격으로 나머지 연산(%)을 적용한 입지 가성비 지수입니다. 임대 비용 대비 학생 유입 효율을 측정합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;③ 과목별 학원경쟁도 계산&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQzQQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;# 과목별 학원경쟁도 산출
academy_cols = [col for col in final.columns if col not in ['시도', 'dong', '노령화지수', '평균나이', '총인구', '총주택(거처)수', '10세미만', '10대', '20대', '30대', '40대', '50대', '60대', '70대', '80대', '90대', '100세이상', '평균아파트가격', '평당가격', '거래건수', '평균상가가격', '상가평당가격', '상가거래건수', '초등학생수', '중학생수', '고등학생수', '초등학교수', '중학교수', '고등학교수', '총학생수', '총학교수', '총학원수', 'EPI', 'EPI_상위%', 'ROI_GAP', 'ROI_GAP_상위%', '학원경쟁도', '학원경쟁도_상위%']]
for col in academy_cols:
    final[f'{col}_학원경쟁도'] = np.where(final[col].notna() &amp;amp; final['총학생수'].notna() &amp;amp; (final['총학생수'] &amp;gt; 0), final[col] / final['총학생수'] * 1000, np.nan)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;과목별 학원 수를 총학생 수로 나눠 학생 1,000명당 해당 과목 학원 수를 산출합니다. 값이 높을수록 경쟁이 치열한 과목&amp;middot;지역이며, 총학생 수가 0인 동은 분모 오류를 방지하기 위해 NaN으로 처리합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;④ 상위 % 백분위 컬럼 추가 및 저장&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQzgQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;# 지표별 상위 퍼센트 산출 및 저장
final = final.replace(0, np.nan)
경쟁도_cols = [f'{col}_학원경쟁도' for col in academy_cols]
indicator_cols = ['EPI', 'ROI_GAP'] + 경쟁도_cols
for col in indicator_cols:
    valid_count = final[col].notna().sum()
    ranks = final[col].rank(ascending=False, method='min')
    final[f'{col}_상위%'] = np.where(final[col].notna(), (ranks / valid_count * 100).round(1).astype(str) + '%', np.nan)
final.to_csv(f'{base_path}final_with_indicators.csv', index=False, encoding='utf-8-sig')
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;0을 NaN으로 대체해 데이터가 없는 동을 백분위 계산에서 제외합니다. 이후 각 지표별로 rank(ascending=False)로 내림차순 순위를 구한 뒤 전체 유효 개수로 나눠 상위 X% 문자열 컬럼을 생성합니다. 예를 들어 EPI가 전체 중 5번째로 높은 동이면 &quot;상위 1.2%&quot;와 같이 저장됩니다. 최종 결과는 final_with_indicators.csv로 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. K-means 클러스터링&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;① 초기 피처 선정 및 탐색&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ9AQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;# 초기 피처 정규화 및 최적 군집 탐색 준비
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

final = pd.read_csv(f'{base_path}final_with_indicators.csv')
feature_cols = ['EPI', 'ROI_GAP', '총학생수', '총학원수', '평균아파트가격']
df_cluster = final[feature_cols].dropna()
scaler = StandardScaler()
X = scaler.fit_transform(df_cluster)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;초기에는 EPI와 ROI_GAP 등 파생 지표 중심으로 피처를 구성합니다. 결측치가 있는 행은 제거하고 StandardScaler로 정규화한 뒤 최적 k 탐색에 사용합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;② 엘보우 및 실루엣 점수로 최적 k 탐색&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ9QQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 엘보우 및 실루엣 점수를 통한 최적 K 탐색
inertia = []
silhouette = []
for k in range(2, 11):
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    kmeans.fit(X)
    inertia.append(kmeans.inertia_)
    silhouette.append(silhouette_score(X, kmeans.labels_))

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].plot(range(2, 11), inertia, marker='o')
axes[0].set_title('Elbow Method')
axes[1].plot(range(2, 11), silhouette, marker='o', color='orange')
axes[1].set_title('Silhouette Score')
plt.tight_layout()
plt.show()

best_k = silhouette.index(max(silhouette)) + 2
print(f&quot;최적 K: {best_k} (Silhouette: {max(silhouette):.3f})&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;k=2부터 10까지 반복하며 두 가지 기준으로 최적 k를 탐색합니다. 군집 내 거리 합이 꺾이는 엘보우 지점과 각 데이터가 자기 군집에 얼마나 잘 속하는지 측정하는 실루엣 점수를 종합해 최종 k=6으로 확정합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;③ 파생 피처 추가&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ9gQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;# 인구 대비 학원밀도 및 유소년 파생 피처 추가
final['학원밀도'] = final['총학원수'] / final['총인구_값'].replace(0, None)
final['유소년비율'] = (final['유아_값'] + final['초중고_값']) / final['총인구_값'].replace(0, None)
final['유소년수'] = final['유아_값'] + final['초중고_값']
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;단순 학원 수 대신 인구 대비 학원 수인 학원밀도와 유소년수를 피처로 추가합니다. 인구 규모 차이가 큰 동 간의 비교를 보정하기 위한 처리이며, 0인 경우 None으로 대체해 분모 오류를 방지합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;④ 이상치 수동 제거 및 최소 인구 필터링&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ9wQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;# 분석 왜곡을 방지하기 위한 이상치 및 소규모 동 제거
final = final[~((final['시도'].str.contains('남양주시')) &amp;amp; (final['dong'] == '도농동'))].copy()
final = final[~((final['시도'].str.contains('남양주시')) &amp;amp; (final['dong'] == '지금동'))].copy()
exclude_list = [('과천시', '갈현동'), ('종로구', '서린동'), ('중구', '소공동')]
for 시도_keyword, dong in exclude_list:
    final = final[~((final['시도'].str.contains(시도_keyword)) &amp;amp; (final['dong'] == dong))].copy()
final = final[final['총인구_값'] &amp;gt;= 1000].copy()
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;EDA 과정에서 발견된 이상치 동들을 수동으로 제거합니다. 개발 지구 특성상 인구 구조가 비정상적이거나 총인구가 1,000명 미만인 동은 분석 대상에서 제외하여 군집화 결과의 왜곡을 막습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;⑤ 1% 클리핑&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ-AQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;# 상위 1% 극단값을 99분위수로 제한하는 클리핑 처리
for col in ['학원밀도', '총학원수', '상가거래건수', '청년_값', '노년_값', '평균상가가격', '상가평당가격']:
    upper = final[col].quantile(0.99)
    final[col] = final[col].clip(upper=upper)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;상위 1% 극단값을 99퍼센타일 값으로 대체하는 이상치 처리 방법입니다. 데이터 손실 없이 분포를 균일하게 만들어 시각화 시 색상 분포가 특정 동에 쏠리는 현상을 방지합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;⑥ 최종 클러스터링 및 라벨링&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ-QQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 최종 6개 군집 형성 및 직관적인 라벨링 부여
feature_cols = ['총인구_값', '초중고_값', '유소년수', '총학원수', '학원밀도', '평균아파트가격', '총학생수']
df_cluster = final[feature_cols].fillna(0)
X = StandardScaler().fit_transform(df_cluster)
kmeans = KMeans(n_clusters=6, random_state=42, n_init=10)
final['cluster'] = kmeans.fit_predict(X)
label_map = {0: '소규모주거지', 1: '학원밀집지', 2: '일반주거지', 3: '블루오션', 4: '교육저활성지', 5: '고급주거지'}
final['cluster_label'] = final['cluster'].map(label_map)
print(final.groupby('cluster')[['총인구_값', '총학원수', '학원밀도', '평균아파트가격']].mean().round(2))
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;학원밀도, 유소년수 등을 추가한 7개 피처로 최종 클러스터링을 수행합니다. 군집별 평균을 확인하여 각 군집의 특성에 맞는 직관적인 라벨을 부여합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;⑦ QuantileTransformer 정규화&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ-gQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 시각화 색상 스펙트럼 최적화를 위한 분위수 정규화
from sklearn.preprocessing import QuantileTransformer
exclude_cols = ['시도', 'dong', 'cluster', 'cluster_label']
numeric_cols = [col for col in final.columns if col not in exclude_cols and not col.endswith('_상위%') and final[col].dtype in ['int64', 'float64']]
qt = QuantileTransformer(output_distribution='uniform', random_state=42)
final_normalized = final.copy()
final_normalized[numeric_cols] = qt.fit_transform(final[numeric_cols].fillna(0))
final_normalized.to_csv(f'{base_path}final_normalized.csv', index=False, encoding='utf-8-sig')
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;각 컬럼의 값을 분위수 기준으로 0~1 사이로 변환하여 균등하게 분포시킵니다. 이 정규화를 적용하면 지도 시각화에서 극단값에 의한 색상 편향 없이 스펙트럼 전체를 고르게 활용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. GeoJSON 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;① SHP 파일 로드 및 WGS84 좌표계 변환&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQmgU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# SHP 파일 로드 및 WGS84 좌표계 변환
import geopandas as gpd
import pandas as pd
import json

gdf_seoul = gpd.read_file(f'{base_path}LSMD_ADM_SECT_UMD_11_202602.shp', encoding='cp949').to_crs(epsg=4326)
gdf_gg = gpd.read_file(f'{base_path}LSMD_ADM_SECT_UMD_41_202602.shp', encoding='cp949').to_crs(epsg=4326)
gdf = pd.concat([gdf_seoul, gdf_gg], ignore_index=True)

with open(f'{base_path}dong_data.json', 'r', encoding='utf-8') as f:
    dong_data = json.load(f)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;국토부에서 제공하는 행정동 경계 SHP 파일을 서울과 경기로 나눠 로드합니다. 원본 좌표계인 한국 표준 좌표계를 웹 지도에서 사용 가능한 WGS84로 변환한 뒤 하나로 병합하고, 앞서 생성한 json 데이터를 함께 불러옵니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;② 시군구 코드 매핑으로 시도&amp;middot;동 컬럼 생성&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQmwU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 시군구 코드 매핑 및 매칭 키 생성
import re

sigungu_code_map = {'11110': '종로구', '11140': '중구', '41191': '부천시 오정구', '41193': '부천시 원미구', '41195': '부천시 소사구', '41192': '부천시', '41194': '부천시', '41196': '부천시'}
sido_map = {'11': '서울특별시', '41': '경기도'}

gdf['시도'] = gdf['EMD_CD'].astype(str).str[:2].map(sido_map) + ' ' + gdf['EMD_CD'].astype(str).str[:5].map(sigungu_code_map)
gdf['dong'] = gdf['EMD_NM'].apply(lambda x: re.sub(r'[0-9\.&amp;middot;]+[가]*동$', '동', str(x)) if str(x).endswith(('동', '읍', '면')) else str(x))
gdf['key'] = gdf['시도'] + '_' + gdf['dong']
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;SHP 파일의 행정동 코드 앞자리를 활용해 시도를 식별합니다. 미리 정의한 코드 매핑 딕셔너리로 치환해 시도 컬럼을 생성하고, 행정동명에서 숫자나 특수문자를 정규화하여 dong 컬럼을 만듭니다. 마지막으로 데이터 매칭을 위한 key 컬럼을 생성합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;③ 부천시 행정구역 개편 보정&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQnAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;# 부천시 행정구역 개편에 따른 키 보정
new_dong_data = {}
for key, val in dong_data.items():
    if any(gu in key for gu in ['부천시 오정구', '부천시 원미구', '부천시 소사구']):
        new_key = key.replace('부천시 오정구', '부천시').replace('부천시 원미구', '부천시').replace('부천시 소사구', '부천시')
        val['시도'] = new_key.split('_')[0]
        new_dong_data[new_key] = val
    else:
        new_dong_data[key] = val
dong_data = new_dong_data
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;부천시는 행정구역 개편으로 하위 구가 폐지되어 부천시로 통합되었습니다. 기존 데이터는 구 단위로 기록되어 있고 SHP 파일은 통합 기준으로 제공되어 키가 불일치하므로, dong_data의 키를 SHP 기준으로 일괄 치환하여 보정합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;④ dong_data와 GeoJSON 키 매칭 및 저장&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQnQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# GeoJSON 구조 생성 및 파일 저장
import os

features = []
for _, row in gdf.iterrows():
    if row['key'] in dong_data:
        data = dong_data[row['key']]
        features.append({
            'type': 'Feature',
            'geometry': row['geometry'].__geo_interface__,
            'properties': {
                'key': row['key'], '시도': row['시도'], 'dong': row['dong'],
                **{k: v for k, v in data.items() if k not in ['학원수별', '학원경쟁도별']},
                '학원수별': data.get('학원수별', {}),
                '학원경쟁도별': data.get('학원경쟁도별', {})
            }
        })

with open(f'{base_path}dong_map.geojson', 'w', encoding='utf-8') as f:
    json.dump({'type': 'FeatureCollection', 'features': features}, f, ensure_ascii=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;dong_data와 GDF 양쪽에 모두 존재하는 키를 확인하여, 매칭되는 동만 GeoJSON Feature로 변환합니다. 각 Feature에 행정동 경계 폴리곤과 분석 지표 전체를 담아 최종적으로 dong_map.geojson 파일로 저장하여 프론트엔드에서 사용할 수 있게 구성합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;0&quot; data-ke-size=&quot;size23&quot;&gt;4. 학원 카테고리 통합 및 최종 저장&lt;/h3&gt;
&lt;p data-path-to-node=&quot;1&quot; data-ke-size=&quot;size16&quot;&gt;① 134개 교습과정 &amp;rarr; 9개 대분류 통합&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;피벗 테이블로 생성된 134개 교습과정 컬럼을 9개 대분류로 묶습니다. 각 대분류에 해당하는 원본 컬럼들을 행 방향으로 합산해 학원_입시/보습, 학원_외국어 등의 새 컬럼을 생성합니다. existing 필터를 통해 서울/경기 어느 한쪽에만 존재하는 교습과정 컬럼이 없어도 오류 없이 처리합니다. 이 통합 작업으로 프론트엔드에서 사용자에게 노출하는 학원 분류가 134개에서 9개로 줄어들어 UX가 크게 개선됩니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQuAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;# 134개 교습과정을 9개 대분류로 통합
category_map = {'입시/보습': ['보습', '입시', '보습/논술', '입시/논술', '보통교과', '검정', '성인고시', '진학지도', '진학상담지도', '대학편입', '전문교과제외', '정보교과', '학교교과교습학원'], '음악/예술': ['음악', '미술', '피아노조율', '실용음악', '국악', '서예', '공예', '도예', '펜글씨', '방송', '영상', '영화', '연극', '연기(연극,뮤지컬,오페라)'], '외국어': ['외국어', '어학(성인)', '실용외국어(유아/초&amp;middot;중&amp;middot;고)', '통역(성인)', '번역(성인)', '국제'], '체육/무용': ['무용', '댄스', '무용(전통무용,현대무용)', '전통무용'], 'IT/컴퓨터': ['컴퓨터', '소프트웨어', '정보', '정보처리', '전자상거래', '게임', '로봇'], '예능/취미': ['바둑', '만화', '사진', '마술(매직)', '모델', '웅변', '속독', '속셈', '주산'], '직업/자격증': ['경영', '회계', '부동산', '자동차', '전기', '건축', '이/미용', '식음료품', '금융', '보험', '행정', '간호조무사', '항공', '관광'], '독서실': ['독서실', '독서실(유아/초&amp;middot;중&amp;middot;고)', '독서실(일반인)'], '기타': ['기타(소)', '기타(중)', '기예(중)', '예능(중)', '인문사회(중)']}
for cat, cols in category_map.items():
    final[f'학원_{cat}'] = final[[c for c in cols if c in final.columns]].fillna(0).sum(axis=1)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;② dong_data 재구성 및 GeoJSON 재생성&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;학원수별은 9개 대분류별 학원 수를 중첩 딕셔너리로 저장하되, 값이 0인 카테고리는 제외해 용량을 줄입니다. 학원경쟁도별은 과목별 경쟁도를 소수점 4자리로 반올림해 저장합니다. 부천시 키 보정은 dong_data 재구성 시마다 재적용합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQuQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;# 데이터 재구성 및 부천시 키 보정
경쟁도_cols = [col for col in final.columns if col.endswith('_학원경쟁도')]
academy_cols = [f'학원_{cat}' for cat in category_map.keys()]
dong_data = {}
for _, row in final.iterrows():
    key = f&quot;{row['시도']}_{row['dong']}&quot;
    entry = {col: (None if pd.isna(row.get(col)) else row.get(col)) for col in final.columns if col not in ['시도', 'dong']}
    entry.update({'시도': row['시도'], 'dong': row['dong']})
    entry['학원수별'] = {col: int(row[col]) for col in academy_cols if not pd.isna(row[col]) and row[col] &amp;gt; 0}
    entry['학원경쟁도별'] = {col.replace('_학원경쟁도', ''): round(float(row[col]), 4) for col in 경쟁도_cols if not pd.isna(row[col])}
    dong_data[key] = entry

new_dong_data = {}
for key, val in dong_data.items():
    if any(x in key for x in ['부천시 오정구', '부천시 원미구', '부천시 소사구']):
        new_key = key.replace('부천시 오정구', '부천시').replace('부천시 원미구', '부천시').replace('부천시 소사구', '부천시')
        val['시도'] = new_key.split('_')[0]
        new_dong_data[new_key] = val
    else:
        new_dong_data[key] = val
dong_data = new_dong_data
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;③ 최종 GeoJSON 저장&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;리스트 컴프리헨션으로 GDF를 순회하며 dong_data에 키가 존재하는 동만 Feature로 변환합니다. 학원수별과 학원경쟁도별은 중첩 객체로 분리해 properties 최상위에 배치합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQugU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;# 최종 GeoJSON 생성 및 저장
features = [{'type': 'Feature', 'geometry': row['geometry'].__geo_interface__, 'properties': {'key': row['key'], '시도': row['시도'], 'dong': row['dong'], **{k: v for k, v in dong_data[row['key']].items() if k not in ['학원수별', '학원경쟁도별']}, '학원수별': dong_data[row['key']].get('학원수별', {}), '학원경쟁도별': dong_data[row['key']].get('학원경쟁도별', {})}} for _, row in gdf.iterrows() if row['key'] in dong_data]
with open(f'{base_path}dong_map.geojson', 'w', encoding='utf-8') as f:
    json.dump({'type': 'FeatureCollection', 'features': features}, f, ensure_ascii=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;④ 지도 용량 최적화 (simplify)&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;행정동 경계는 수천 개의 좌표점으로 구성되어 있어 원본 GeoJSON 파일이 수십 MB에 달합니다. simplify는 경계선의 좌표점 수를 줄여 파일 크기를 낮추는 방법으로, tolerance 값이 클수록 더 많이 단순화됩니다. preserve_topology=True를 설정해 인접한 동 간 경계가 어긋나거나 폴리곤이 뒤집히는 위상 오류를 방지합니다. 단순화 후 buffer(0)을 추가로 적용해 자기교차 등 남은 위상 오류를 보정합니다. 두 단계로 나눠 tolerance를 조정한 것은 용량 감소폭과 경계 정밀도 사이의 균형을 실험적으로 탐색한 결과입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQuwU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# GeoJSON 용량 최적화 및 위상 보정
gdf = gpd.read_file(f'{base_path}dong_map.geojson')
gdf['geometry'] = gdf['geometry'].simplify(tolerance=0.0005, preserve_topology=True).buffer(0)
gdf.to_file(f'{base_path}dong_map_optimized.geojson', driver='GeoJSON')&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>Data Analyst Project</category>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/188</guid>
      <comments>https://ojko.tistory.com/188#entry188comment</comments>
      <pubDate>Sat, 21 Feb 2026 16:20:50 +0900</pubDate>
    </item>
    <item>
      <title>[어플리케이션 개발] 1. 학원 입지 분석 대시보드 앱 개발 - 수집 및 전처리</title>
      <link>https://ojko.tistory.com/187</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot;&gt;학원명당 - 원스토어&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1771517203580&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;학원명당 - 원스토어&quot; data-og-description=&quot;학원명당: 데이터 기반 학원 입지 분석 서비스&quot; data-og-host=&quot;m.onestore.co.kr&quot; data-og-source-url=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot; data-og-url=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/eyFZvz/dJMb9kl9nq9/uKuqNkjhXkXy75AhUpvwiK/img.png?width=1024&amp;amp;height=578&amp;amp;face=0_0_1024_578,https://scrap.kakaocdn.net/dn/Mko6i/dJMb9gxhXDK/PSem0e3ZOLtkBPUIODwPyk/img.png?width=1024&amp;amp;height=578&amp;amp;face=0_0_1024_578,https://scrap.kakaocdn.net/dn/c8IDly/dJMb9b3ODWy/defOdcKkUl5QMhMU9Wyx0k/img.png?width=470&amp;amp;height=940&amp;amp;face=0_0_470_940&quot;&gt;&lt;a href=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://m.onestore.co.kr/v2/ko-kr/app/0001004443&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/eyFZvz/dJMb9kl9nq9/uKuqNkjhXkXy75AhUpvwiK/img.png?width=1024&amp;amp;height=578&amp;amp;face=0_0_1024_578,https://scrap.kakaocdn.net/dn/Mko6i/dJMb9gxhXDK/PSem0e3ZOLtkBPUIODwPyk/img.png?width=1024&amp;amp;height=578&amp;amp;face=0_0_1024_578,https://scrap.kakaocdn.net/dn/c8IDly/dJMb9b3ODWy/defOdcKkUl5QMhMU9Wyx0k/img.png?width=470&amp;amp;height=940&amp;amp;face=0_0_470_940');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;학원명당 - 원스토어&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;학원명당: 데이터 기반 학원 입지 분석 서비스&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;m.onestore.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 지인이 학원 개업을 생각하게 되면서, 어디가 학원입지로서 적합한지에 대해서 고민이라는 이야기를 듣게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지인의 이런 고민을 데이터 분석을 통해서 해결할 수 있다고 생각하게 되었고, 이런 고민을 가지고 있는 학원 창업자들에게 도움이 되고자 학원입지 분석 어플리케이션을 개발하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫번째로는, 지인의 요구사항 파악과 최종적인 결과물의 형식과 대시보드에 나타낼 정보들을 정리해보았습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;2,0,0&quot;&gt;요구사항 파악&lt;/b&gt;: 지인의 핵심 고민인 &lt;i&gt;&lt;u&gt;&lt;b&gt;'유동 인구 대비 경쟁 강도'와 '임대료 수준'&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;을 최우선 분석 지표로 설정했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;2,1,0&quot;&gt;지표 정의&lt;/b&gt;: &lt;i&gt;&lt;u&gt;&lt;b&gt;시장 구매력(EPI), 입지 가성비(ROI_GAP), 유소년 비율, 학원 밀도&lt;/b&gt;&lt;/u&gt;&lt;/i&gt; 등 4가지 핵심 지표를 도출했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;2,2,0&quot;&gt;결과물 형식&lt;/b&gt;: 행정동별 데이터를 지도상에 색상으로 표현하는 &lt;i&gt;&lt;u&gt;&lt;b&gt;히트맵 기반의 웹 어플리케이션&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;으로 형식을 확정했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;2,3,0&quot;&gt;정보 구성&lt;/b&gt;: 클러스터링을 통한 상권 유형 분류, 인구 통계, 학교 현황, 주변 아파트 시세 정보를 한 화면에 구성하여 종합적인 판단이 가능하도록 기획했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;1&quot; data-ke-size=&quot;size16&quot;&gt;두번째로는, 앞서 정의한 요구사항을 실질적인 데이터 모델로 구현하기 위한 분석 프로세스를 설계했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;2&quot;&gt;&lt;b&gt;지표의 구체화:&lt;/b&gt; '시장 구매력'을 단순히 인구수로 판단하지 않고, 해당 지역의 아파트 평균 실거래가와 학생 수를 결합한 EPI(Market Purchasing Power) 지수로 수식화했습니다. 또한, 상가 임대료 대비 학생 수 비중을 계산하여 초기 진입 비용 대비 효율성을 측정하는 ROI_GAP 지표를 설계하여 분석의 객관성을 확보했습니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;3&quot;&gt;&lt;b&gt;상권 유형화 전략:&lt;/b&gt; 단순한 데이터 나열에서 벗어나 학원 창업자에게 직관적인 인사이트를 제공하기 위해 클러스터링 분석을 계획했습니다. 입지별 특성에 따라 &lt;i&gt;&lt;u&gt;&lt;b&gt;'학원 밀집지', '고급 주거지', '블루오션'&lt;/b&gt;&lt;/u&gt;&lt;/i&gt; 등으로 그룹화하여 본인의 자본금과 타겟층에 맞는 지역을 빠르게 필터링할 수 있는 구조를 설계했습니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;4&quot;&gt;&lt;b&gt;공간 단위 설정:&lt;/b&gt; 분석의 최소 단위를 법정 경계동인 '&lt;i&gt;&lt;u&gt;&lt;b&gt;법정동&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;'으로 설정했습니다. 이는 학원 선택 시 거주지와의 인접성이 중요하다는 점을 반영한 것이며, 향후 지도 시각화 단계에서 사용자에게 익숙한 지역 정보를 제공하기 위한 결정이었습니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;5&quot;&gt;&lt;b&gt;시각화 시나리오 구성:&lt;/b&gt; 사용자가 특정 동을 선택했을 때 해당 지역의 인구 통계, 학교 리스트, 주변 시세가 연동되어 나타나는 상호작용 시나리오를 구성하여, 데이터 분석 결과가 실제 의사결정 도구로 기능할 수 있도록 기획했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;수집&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 수집은 &lt;i&gt;&lt;u&gt;&lt;b&gt;공공데이터포털, 경기데이터드림, 서울열린데이터광장, 국토교통부&lt;/b&gt;&lt;/u&gt;&lt;/i&gt; 등에서 공공데이터를 수집하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수집을 할때에는 각 데이터들이 내가 원하는 &lt;i&gt;&lt;u&gt;&lt;b&gt;데이터 칼럼이 존재하는지&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;를 확실히 확인하고 데이터를 수집하였습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한것은 &quot;&lt;i&gt;&lt;u&gt;&lt;b&gt;법정동&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;&quot; 기준으로 데이터들이 이루어져있나 입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&lt;u&gt;&lt;b&gt;행정동으로 수집이 되어있다면, 전처리 과정중 병합부분에서 데이터가 꼬이며&lt;/b&gt;&lt;/u&gt;&lt;/i&gt; 결측치가 많아지기 때문에, 이부분에 특히 유의하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.data.go.kr/tcs/dss/selectDataSetList.do&quot;&gt;데이터목록 | 공공데이터포털&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1771510382296&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;공공데이터 포털&quot; data-og-description=&quot;국가에서 보유하고 있는 다양한 데이터를『공공데이터의 제공 및 이용 활성화에 관한 법률(제11956호)』에 따라 개방하여 국민들이 보다 쉽고 용이하게 공유&amp;bull;활용할 수 있도록 공공데이터(Datase&quot; data-og-host=&quot;www.data.go.kr&quot; data-og-source-url=&quot;https://www.data.go.kr/tcs/dss/selectDataSetList.do&quot; data-og-url=&quot;https://www.data.go.kr/tcs/dss/selectDataSetList.do&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/m2Yh8/dJMb9frB1Yn/oh520MlLKk8BGL6qX542uK/img.png?width=390&amp;amp;height=158&amp;amp;face=0_0_390_158&quot;&gt;&lt;a href=&quot;https://www.data.go.kr/tcs/dss/selectDataSetList.do&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.data.go.kr/tcs/dss/selectDataSetList.do&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/m2Yh8/dJMb9frB1Yn/oh520MlLKk8BGL6qX542uK/img.png?width=390&amp;amp;height=158&amp;amp;face=0_0_390_158');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;공공데이터 포털&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;국가에서 보유하고 있는 다양한 데이터를『공공데이터의 제공 및 이용 활성화에 관한 법률(제11956호)』에 따라 개방하여 국민들이 보다 쉽고 용이하게 공유&amp;bull;활용할 수 있도록 공공데이터(Datase&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.data.go.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전처리&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 학교 데이터 전처리 (서울 + 경기도)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;① 서울 데이터 로드 및 동 추출&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQlwM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 서울 데이터 전처리
import pandas as pd
import numpy as np
import re
import os

base_path = '/content/drive/MyDrive/'
df_seoul = pd.read_csv(f'{base_path}서울시 학교, 학생 데이터.csv', encoding='cp949')
df_seoul['temp_addr'] = df_seoul['도로명상세주소'].fillna(df_seoul['도로명주소']).astype(str)
df_seoul['dong'] = df_seoul['temp_addr'].apply(lambda x: re.search(r'\(([가-힣]+[동읍면])', str(x)).group(1) if re.search(r'\(([가-힣]+[동읍면])', str(x)) else None)
df_seoul['시도'] = df_seoul['도로명주소'].astype(str).str.extract(r'^([가-힣]+\s+[가-힣]+)')[0]
df_seoul = df_seoul.sort_values('공시연도', ascending=False).drop_duplicates('학교명')
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;도로명상세주소가 없는 경우 &lt;i&gt;&lt;u&gt;&lt;b&gt;도로명주소로 대체&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;합니다. 이후 주소의 괄호 () 안에서 &lt;i&gt;&lt;u&gt;&lt;b&gt;동/읍/면으로 끝나는 행정동 이름을 정규식으로&lt;/b&gt;&lt;/u&gt;&lt;/i&gt; 추출합니다. 또한 공시연도 기준 내림차순 정렬 후 학교명 중복을 제거해 최신 데이터만 남깁니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;② 서울 피벗 테이블 생성&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQmAM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# 서울 피벗 테이블 생성
# 시도동을 인덱스로, 학교급 코드별로 학생수 합산, 학교명 카운트
seoul_std = df_seoul.pivot_table(index=['시도', 'dong'], columns='학교급코드', values='학생수(계)', aggfunc='sum', fill_value=0).reset_index()
seoul_sch = df_seoul.pivot_table(index=['시도', 'dong'], columns='학교급코드', values='학교명', aggfunc='count', fill_value=0).reset_index()
# 각 칼럼을 재정의
seoul_std.rename(columns={2: '초등학생수', 3: '중학생수', 4: '고등학생수'}, inplace=True)
seoul_sch.rename(columns={2: '초등학교수', 3: '중학교수', 4: '고등학교수'}, inplace=True)
# 시도동을 기준으로 병합
seoul_merged = pd.merge(seoul_std, seoul_sch, on=['시도', 'dong'], how='outer').fillna(0)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;학교급코드 (2=초등, 3=중, 4=고등) 기준으로 &lt;i&gt;&lt;u&gt;&lt;b&gt;피벗 테이블을 두 개 생성&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;합니다. 하나는 &lt;i&gt;&lt;u&gt;&lt;b&gt;학생 수 합계, 다른 하나는 학교 수 카운트&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;입니다. 두 테이블을 &lt;i&gt;&lt;u&gt;&lt;b&gt;시도 + dong 기준으로 outer join 후&lt;/b&gt;&lt;/u&gt;&lt;/i&gt; 결측치를 0으로 채웁니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;③ 경기도 데이터 로드 및 동 추출&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQmQM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# 경기 데이터 전처리
df_gg = pd.read_csv(f'{base_path}경기도경기부동산포털학교정보.csv', encoding='cp949')
# 학년이나 학생수가 칼럼에 있으면, 합산
df_gg['총학생수'] = df_gg[[c for c in df_gg.columns if '학년' in c and '학생수' in c]].sum(axis=1)
df_gg['dong'] = df_gg['학교주소'].apply(lambda x: re.search(r'\(([가-힣]+[동읍면])', str(x)).group(1) if re.search(r'\(([가-힣]+[동읍면])', str(x)) else None)
df_gg['시도'] = df_gg['학교주소'].astype(str).str.extract(r'^([가-힣]+\s+[가-힣]+)')[0]&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;경기도 데이터는 학년별 학생 수가 컬럼으로 분리되어 있어, &lt;i&gt;&lt;u&gt;&lt;b&gt;학년 + 학생수가 동시에 포함된 컬럼&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;들을 찾아 행 방향으로 합산해 &lt;i&gt;&lt;u&gt;&lt;b&gt;총학생수를 생성&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;합니다. 동 추출 방식은 서울과 동일합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;④ 경기도 피벗 테이블 생성&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQmgM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 경기 피벗 테이블 생성
gg_std = df_gg.pivot_table(index=['시도', 'dong'], columns='학교구분', values='총학생수', aggfunc='sum', fill_value=0).reset_index()
gg_sch = df_gg.pivot_table(index=['시도', 'dong'], columns='학교구분', values='학교명', aggfunc='count', fill_value=0).reset_index()
gg_std.rename(columns={'초등학교': '초등학생수', '중학교': '중학생수', '고등학교': '고등학생수'}, inplace=True)
gg_sch.rename(columns={'초등학교': '초등학교수', '중학교': '중학교수', '고등학교': '고등학교수'}, inplace=True)
gg_merged = pd.merge(gg_std, gg_sch, on=['시도', 'dong'], how='outer').fillna(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;서울과 동일한 구조이나, 경기도 원본의 &lt;i&gt;&lt;u&gt;&lt;b&gt;학교구분 컬럼 값이 문자열('초등학교')&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;이므로 컬럼명 변환 방식이 다릅니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;⑤ 컬럼 통일 및 서울 + 경기 병합&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQmwM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;# 컬럼 통일 및 병합
cols_std = ['초등학생수', '중학생수', '고등학생수']
cols_sch = ['초등학교수', '중학교수', '고등학교수']
for col in cols_std + cols_sch:
    if col not in seoul_merged.columns: seoul_merged[col] = 0
    if col not in gg_merged.columns: gg_merged[col] = 0
school_by_dong = pd.concat([seoul_merged, gg_merged]).groupby(['시도', 'dong'])[cols_std + cols_sch].sum().reset_index()
school_by_dong['총학생수'] = school_by_dong[cols_std].sum(axis=1)
school_by_dong['총학교수'] = school_by_dong[cols_sch].sum(axis=1)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;두 데이터셋 간 컬럼 누락 가능성을 방어적으로 처리한 뒤, &lt;i&gt;&lt;u&gt;&lt;b&gt;concat 후 groupby로 동별 최종 합산&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;합니다. 초/중/고 학생 수와 학교 수를 합산한 총학생수, 총학교수 파생 컬럼도 추가합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;⑥ 이상 데이터 제거 및 저장&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQnAM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 이상 데이터 제거
school_by_dong = school_by_dong[school_by_dong['시도'].notna() &amp;amp; (school_by_dong['시도'].astype(str) != '0')]
school_by_dong = school_by_dong[school_by_dong['dong'].notna()]
school_by_dong.to_csv(f'{base_path}school_by_dong.csv', index=False, encoding='utf-8-sig')
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;시도가 비어있거나 '0'으로 잘못 파싱된 행, &lt;i&gt;&lt;u&gt;&lt;b&gt;dong이 추출되지 않은 행을 제거&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;합니다. 최종 결과를 school_by_dong.csv로 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 아파트 실거래가 전처리 (서울 + 경기도)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;① 서울 엑셀 로드 및 컬럼 재정의&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQtwM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;df_apt_seoul = pd.read_excel(f'{base_path}아파트(매매)_실거래가_20260202002906.xlsx', skiprows=16)
seoul_data = pd.DataFrame({
    '시군구동': df_apt_seoul.iloc[:, 1],
    '아파트명': df_apt_seoul.iloc[:, 5],
    '전용면적': df_apt_seoul.iloc[:, 6],
    '거래금액': df_apt_seoul.iloc[:, 9]
})
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;국토부 실거래가 엑셀은 상단에 메타 정보 행이 포함되어 있어 &lt;i&gt;&lt;u&gt;&lt;b&gt;skiprows=16&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;으로 헤더 이전 행을 건너뜁니다. 원본 컬럼명이 복잡하거나 위치 기반으로만 식별 가능하므로 &lt;i&gt;&lt;u&gt;&lt;b&gt;iloc으로 필요한 컬럼만 추출&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;해 새 DataFrame을 구성합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;② 경기도 엑셀 로드 및 컬럼 재정의&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQuAM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;df_apt_gg = pd.read_excel(f'{base_path}경기도 아파트(매매)_실거래가_20260202003251.xlsx', skiprows=12)
gg_data = pd.DataFrame({
    '시군구동': df_apt_gg['시군구'],
    '아파트명': df_apt_gg['단지명'],
    '전용면적': df_apt_gg['전용면적(㎡)'],
    '거래금액': df_apt_gg['거래금액(만원)']
})
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;경기도 파일은 헤더가 12행부터 시작하며, 서울과 달리 컬럼명이 명시되어 있어 컬럼명으로 직접 접근합니다. 두 지역의 컬럼 구조를 동일하게 맞추기 위해 &lt;i&gt;&lt;u&gt;&lt;b&gt;시군구동, 아파트명, 전용면적, 거래금액&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;으로 통일합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;③ 병합 및 평수&amp;middot;평당가격 계산&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQuQM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;apt_data = pd.concat([seoul_data, gg_data], ignore_index=True)
apt_data['거래금액_숫자'] = apt_data['거래금액'].astype(str).str.replace(',', '').astype(float)
apt_data['전용면적_숫자'] = pd.to_numeric(apt_data['전용면적'], errors='coerce')
apt_data['평수'] = apt_data['전용면적_숫자'] / 3.3058
apt_data['평당가격'] = apt_data['거래금액_숫자'] / apt_data['평수']
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;서울과 경기도 데이터를 concat으로 합칩니다. 거래금액은 1,000 형태의 문자열로 저장되어 있어 콤마를 제거한 뒤 &lt;i&gt;&lt;u&gt;&lt;b&gt;float으로 변환&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;합니다. 전용면적(㎡)을 3.3058로 나눠 평수를 구하고, &lt;i&gt;&lt;u&gt;&lt;b&gt;거래금액을 평수로 나눠 평당가격을 산출&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;④ 시도 및 동 추출&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQugM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;apt_data['시도'] = apt_data['시군구동'].astype(str).str.extract(r'^([가-힣]+\s+[가-힣]+)')[0]
apt_data['dong'] = apt_data['시군구동'].apply(
    lambda x: re.search(r'([가-힣]+[동읍면])$', str(x).split()[-1]).group(1) 
    if len(str(x).split()) &amp;gt;= 3 and re.search(r'([가-힣]+[동읍면])$', str(x).split()[-1]) 
    else None
)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;시군구동 컬럼은 서울특별시 강남구 역삼동 형태입니다. 앞 두 단어를 &lt;u&gt;&lt;i&gt;&lt;b&gt;정규식으로 추출해 시도&lt;/b&gt;&lt;/i&gt;&lt;/u&gt;를 만들고, 마지막 단어가 &lt;i&gt;&lt;u&gt;&lt;b&gt;동/읍/면으로 끝나는 경우에만 dong으로 저장&lt;/b&gt;&lt;/u&gt;&lt;/i&gt;합니다. 주소 토큰이 3개 미만이면 None을 반환해 불완전한 데이터를 걸러냅니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;⑤ 동별 집계 및 저장&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQuwM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;ceylon&quot;&gt;&lt;code&gt;apt_by_dong = apt_data.groupby(['시도', 'dong']).agg({
    '거래금액_숫자': 'mean',
    '평당가격': 'mean',
    '아파트명': 'count'
}).reset_index()
apt_by_dong.columns = ['시도', 'dong', '평균아파트가격', '평당가격', '거래건수']
apt_by_dong = apt_by_dong[apt_by_dong['시도'].notna() &amp;amp; apt_by_dong['dong'].notna()]
apt_by_dong.to_csv(f'{base_path}apt_by_dong.csv', index=False, encoding='utf-8-sig')
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;동별로 거래금액과 평당가격의 평균을 집계하고, 아파트명 카운트로 거래 건수를 구합니다. 시도나 dong이 추출되지 않은 행을 제거한 뒤 apt_by_dong.csv로 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 학원 데이터 전처리 (서울 + 경기도)&lt;/h4&gt;
&lt;p data-path-to-node=&quot;1&quot; data-ke-size=&quot;size16&quot;&gt;① 서울 CSV 로드 및 동 추출&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ3QM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 서울 학원 데이터 로드 및 행정동 추출
df_seoul_academy = pd.read_csv(f'{base_path}서울시 학원 교습소정보.csv', encoding='cp949')
df_seoul_academy['temp_addr'] = df_seoul_academy['도로명상세주소'].fillna(df_seoul_academy['도로명주소']).astype(str)
df_seoul_academy['dong'] = df_seoul_academy['temp_addr'].apply(lambda x: re.search(r'\(([가-힣]+[동읍면])', str(x)).group(1) if re.search(r'\(([가-힣]+[동읍면])', str(x)) else None)
df_seoul_academy['시도'] = df_seoul_academy['도로명주소'].astype(str).str.extract(r'^([가-힣]+\s+[가-힣]+)')[0]
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;학교 데이터와 동일하게 도로명상세주소 결측 시 도로명주소로 대체하고, 괄호 안에서 동/읍/면으로 끝나는 행정동명을 정규식으로 추출합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;② 서울 피벗 테이블 생성&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ3gM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 동별 과목별 학원 수 피벗 테이블 생성
seoul_pivot = df_seoul_academy.pivot_table(index=['시도', 'dong'], columns='교습과정명', values='학원명', aggfunc='count', fill_value=0).reset_index()
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;교습과정명을 컬럼으로 펼쳐 동별 &amp;times; 과목별 학원 수 매트릭스를 만듭니다. 학원명 카운트를 값으로 사용하며, 해당 과목 학원이 없는 동은 0으로 채웁니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;③ 경기도 멀티시트 엑셀 로드&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ3wM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# 경기도 학원 데이터 시트 병합
excel_file = pd.ExcelFile(f'{base_path}경기도교육청_경기도 학원 정보_20251231.xlsx')
df_gg_list = []
for sheet in excel_file.sheet_names:
    df_sheet = pd.read_excel(excel_file, sheet_name=sheet, skiprows=4)
    df_gg_list.append(df_sheet)
df_gg_academy = pd.concat(df_gg_list, ignore_index=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;경기도 학원 데이터는 시트가 여러 개로 분리된 엑셀 파일입니다. pd.ExcelFile로 파일을 열어 시트 목록을 확인한 뒤, 반복문으로 모든 시트를 읽어 하나의 DataFrame으로 합칩니다. 각 시트 상단 4행은 메타 정보이므로 skiprows=4로 건너뜁니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;④ 경기도 동 추출 및 피벗 테이블 생성&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ4AM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 경기도 행정동 추출 및 피벗 테이블 생성
df_gg_academy['시도'] = df_gg_academy['주소'].astype(str).str.extract(r'^([가-힣]+\s+[가-힣]+)')[0]
df_gg_academy['dong'] = df_gg_academy['주소'].apply(lambda x: re.search(r'\(([가-힣]+[동읍면])', str(x)).group(1) if re.search(r'\(([가-힣]+[동읍면])', str(x)) else None)
gg_pivot = df_gg_academy.pivot_table(index=['시도', 'dong'], columns='교습과정', values='학원명', aggfunc='count', fill_value=0).reset_index()
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;서울과 동 추출 방식은 동일하나, 컬럼명이 교습과정명 대신 교습과정으로 다릅니다. 피벗 테이블 구조 자체는 동일합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;⑤ 두 지역 컬럼 통일 및 병합&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ4QM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;# 컬럼 통일 및 서울 경기 데이터 병합
all_courses = set(seoul_pivot.columns[2:]) | set(gg_pivot.columns[2:])
for course in all_courses:
    if course not in seoul_pivot.columns: seoul_pivot[course] = 0
    if course not in gg_pivot.columns: gg_pivot[course] = 0
academy_by_dong = pd.concat([seoul_pivot, gg_pivot]).groupby(['시도', 'dong']).sum().reset_index()
academy_by_dong = academy_by_dong[academy_by_dong['시도'].notna() &amp;amp; academy_by_dong['dong'].notna()]
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;서울과 경기도의 교습과정 종류가 완전히 일치하지 않을 수 있어, 합집합(|)으로 전체 과정 목록을 구한 뒤 한쪽에만 없는 컬럼을 0으로 채워 구조를 통일합니다. 이후 concat + groupby로 동별 최종 합산합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;⑥ 총학원수 파생 컬럼 추가 및 저장&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ4gM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 총학원수 산출 및 최종 저장
academy_by_dong.to_csv(f'{base_path}academy_by_dong_detailed.csv', index=False, encoding='utf-8-sig')
academy_cols = [col for col in academy_by_dong.columns if col not in ['시도', 'dong']]
academy_by_dong['총학원수'] = academy_by_dong[academy_cols].sum(axis=1)
zero_academy = academy_by_dong[academy_by_dong['총학원수'] == 0]
print(f&quot;학원이 없는 동: {len(zero_academy)}개&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;과목별 학원 수를 행 방향으로 합산해 총학원수 컬럼을 추가합니다. 학원이 단 하나도 없는 동을 별도로 출력해 데이터 품질을 확인하고, 최종 결과를 academy_by_dong_detailed.csv로 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 상가 실거래가 전처리 (서울 + 경기도)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;① 엑셀 로드 및 병합&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ_gM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# 서울과 경기 상가 실거래가 데이터 병합
df_sang_seoul = pd.read_excel(f'{base_path}서울시 상업업무용(매매)_실거래가_20260202003444.xlsx', skiprows=12)
df_sang_gg = pd.read_excel(f'{base_path}경기도 상업업무용(매매)_실거래가_20260202003459.xlsx', skiprows=12)
df_sang = pd.concat([df_sang_seoul, df_sang_gg])
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;아파트와 달리 서울/경기 파일 모두 skiprows=12로 동일합니다. 두 파일을 바로 concat으로 합쳐 이후 처리를 한 번에 진행합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;② 컬럼 재정의&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQ_wM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# 필요한 컬럼 추출
sang_data = pd.DataFrame({'시군구동': df_sang.iloc[:, 1], '면적': df_sang.iloc[:, 8], '거래금액': df_sang.iloc[:, 9]})
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;아파트 데이터와 마찬가지로 원본 컬럼 위치 기반으로 필요한 컬럼만 추출합니다. 상가는 아파트명 대신 면적을 사용하며, 나중에 거래 건수 집계에 활용합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;③ 평수 및 평당가격 계산&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQgAQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;# 평수 및 평당가격 계산
sang_data['거래금액_숫자'] = pd.to_numeric(sang_data['거래금액'].astype(str).str.replace(',', ''), errors='coerce')
sang_data['면적_숫자'] = pd.to_numeric(sang_data['면적'], errors='coerce')
sang_data['평수'] = sang_data['면적_숫자'] / 3.3058
sang_data['평당가격'] = sang_data['거래금액_숫자'] / sang_data['평수']
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;아파트와 동일한 방식으로 계산합니다. 다만 아파트는 astype(float)으로 변환했던 반면, 상가는 pd.to_numeric(..., errors='coerce')를 사용해 변환 불가 값을 NaN으로 처리하는 방어 로직이 추가되어 있습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;④ 시도 및 동 추출&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQgQQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 행정동 추출
sang_data['시도'] = sang_data['시군구동'].astype(str).str.extract(r'^([가-힣]+\s+[가-힣]+)')[0]
sang_data['dong'] = sang_data['시군구동'].apply(lambda x: re.search(r'([가-힣]+[동읍면])$', str(x).split()[-1]).group(1) if len(str(x).split()) &amp;gt;= 3 and re.search(r'([가-힣]+[동읍면])$', str(x).split()[-1]) else None)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;아파트와 완전히 동일한 방식입니다. 주소 마지막 단어가 동/읍/면으로 끝나는 경우에만 dong으로 저장하고, 토큰이 3개 미만이면 None을 반환합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;⑤ 동별 집계 및 저장&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQggQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 평균 집계 및 저장
sang_by_dong = sang_data.groupby(['시도', 'dong']).agg({'거래금액_숫자': 'mean', '평당가격': 'mean', '면적': 'count'}).reset_index()
sang_by_dong.columns = ['시도', 'dong', '평균상가가격', '상가평당가격', '상가거래건수']
sang_by_dong = sang_by_dong[sang_by_dong['시도'].notna() &amp;amp; sang_by_dong['dong'].notna()]
sang_by_dong.to_csv(f'{base_path}sanga_by_dong.csv', index=False, encoding='utf-8-sig')
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;동별로 거래금액과 평당가격의 평균을 집계하고, 면적 카운트로 상가 거래 건수를 구합니다. 이상 데이터를 제거한 뒤 sanga_by_dong.csv로 저장합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size20&quot;&gt;5. 인구 데이터 전처리&lt;/h4&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;① CSV 로드 및 시도&amp;middot;동 추출&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQmgQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 행정구역명에서 시도 및 행정동 추출
pop = pd.read_csv(f'{base_path}population_seoul_gyeonggi_dong.csv')
pop['시도'] = pop['행정구역명'].astype(str).str.extract(r'^([가-힣]+\s+[가-힣]+)')[0]
pop['dong'] = pop['행정구역명'].apply(lambda x: re.sub(r'[0-9\.&amp;middot;]+[가]*동$', '동', str(x).split()[-1]) if str(x).split()[-1].endswith(('동', '읍', '면')) else None)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;다른 데이터셋과 달리 주소가 아닌 행정구역명 컬럼에서 추출합니다. 시도는 앞 두 단어를 정규식으로 추출하는 방식으로 동일하지만, dong 추출 방식이 다릅니다. 가-힣 뿐만 아니라 숫자&amp;middot;점&amp;middot;가운뎃점(&amp;middot;)이 섞인 동명(예: 1.2동, 가&amp;middot;나동)을 re.sub으로 정규화해 동으로 통일합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;② 연령대 구간 합산&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQmwQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;perl&quot;&gt;&lt;code&gt;# 5세 단위 연령대를 10세 단위로 병합
pop['10세미만'] = pop['4세이하'] + pop['5세이상~9세이하']
pop['10대'] = pop['10세이상~14세이하'] + pop['15세이상~19세이하']
pop['20대'] = pop['20세이상~24세이하'] + pop['25세이상~29세이하']
pop['30대'] = pop['30세이상~34세이하'] + pop['35세이상~39세이하']
pop['40대'] = pop['40세이상~44세이하'] + pop['45세이상~49세이하']
pop['50대'] = pop['50세이상~54세이하'] + pop['55세이상~59세이하']
pop['60대'] = pop['60세이상~64세이하'] + pop['65세이상~69세이하']
pop['70대'] = pop['70세이상~74세이하'] + pop['75세이상~79세이하']
pop['80대'] = pop['80세이상~84세이하'] + pop['85세이상~89세이하']
pop['90대'] = pop['90세이상~94세이하'] + pop['95세이상~99세이하']
pop['100세이상'] = pop['100세이상']
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;원본 데이터는 5세 단위로 세분화된 컬럼 구조입니다. 이를 10세 단위 연령대로 묶어 분석에 용이한 형태로 변환합니다. 이 연령대 데이터는 이후 클러스터링에서 유소년 인구 비율 등의 피처로 활용됩니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;③ 동별 집계 (연령대 + 기본 지표)&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQnAQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;# 컬럼 성격에 따른 동별 데이터 집계
age_groups = ['10세미만', '10대', '20대', '30대', '40대', '50대', '60대', '70대', '80대', '90대', '100세이상']
base_cols = ['노령화지수', '인구밀도', '평균나이', '총인구', '총주택(거처)수', '아파트']
agg_dict = {}
for col in base_cols:
    agg_dict[col] = 'mean' if col in ['노령화지수', '인구밀도', '평균나이'] else 'sum'
for col in age_groups:
    agg_dict[col] = 'sum'
pop_by_dong = pop.groupby(['시도', 'dong']).agg(agg_dict).reset_index()
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;집계 방식을 컬럼 성격에 따라 구분합니다. 노령화지수, 인구밀도, 평균나이처럼 비율&amp;middot;평균 성격의 지표는 mean으로, 총인구, 총주택수, 연령대별 인구처럼 개수 성격의 지표는 sum으로 집계합니다. 이 구분이 중요한 이유는 동일한 groupby에서 잘못된 집계 함수를 사용하면 값이 과대 계산될 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;④ 주요 지표 재집계 및 저장&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQnQQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 주요 지표 재추출 및 최종 병합용 집계
df_pop = pd.read_csv(f'{base_path}population_seoul_gyeonggi_dong.csv')
selected_cols = ['행정구역명', '노령화지수', '인구밀도', '평균나이', '총인구', '총주택(거처)수']
df_pop_selected = df_pop[selected_cols].copy()
df_pop_selected['시도'] = df_pop_selected['행정구역명'].astype(str).str.extract(r'^([가-힣]+\s+[가-힣]+)')[0]
df_pop_selected['dong'] = df_pop_selected['행정구역명'].apply(lambda x: re.sub(r'[0-9\.&amp;middot;]+[가]*동$', '동', str(x).split()[-1]) if str(x).split()[-1].endswith(('동', '읍', '면')) else None)
pop_by_dong = df_pop_selected.groupby(['시도', 'dong']).agg({'노령화지수': 'mean', '인구밀도': 'mean', '평균나이': 'mean', '총인구': 'sum', '총주택(거처)수': 'sum'}).reset_index()
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;연령대 구간 집계와 별도로 최종 병합에 사용할 핵심 지표만 추려 다시 집계합니다. 원본을 새로 로드해 필요한 컬럼만 selected_cols로 선택하고, 동일한 방식으로 동별 집계를 수행합니다. 이 결과가 이후 final_dong 병합 시 인구 정보로 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;6. 최종 통합&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;① 저장된 CSV 파일 로드&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQswQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# 저장된 데이터 로드
school_by_dong = pd.read_csv(f'{base_path}school_by_dong.csv')
apt_by_dong = pd.read_csv(f'{base_path}apt_by_dong.csv')
academy_by_dong = pd.read_csv(f'{base_path}academy_by_dong.csv')
sanga_by_dong = pd.read_csv(f'{base_path}sanga_by_dong.csv')
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;앞서 각 단계에서 저장해둔 CSV 파일을 다시 불러옵니다. pop_by_dong은 파일로 저장하지 않고 메모리에 남아있는 상태를 그대로 사용합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;② 데이터 통합 (Left Join)&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQtAQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# 데이터 병합
final_dong = academy_by_dong.copy()
final_dong = final_dong.merge(apt_by_dong, on=['시도', 'dong'], how='left')
final_dong = final_dong.merge(school_by_dong, on=['시도', 'dong'], how='left')
final_dong = final_dong.merge(pop_by_dong, on=['시도', 'dong'], how='left')
final_dong = final_dong.merge(sanga_by_dong, on=['시도', 'dong'], how='left')
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;academy_by_dong을 베이스로 설정하는 이유는 학원 데이터가 분석의 핵심 지표이기 때문입니다. 나머지 데이터를 모두 left 방식으로 조인함으로써, 학원 데이터에 존재하는 동은 반드시 유지되고 다른 데이터에만 있는 동은 제외됩니다. 병합 기준은 시도 + dong 두 컬럼의 조합으로, 서울과 경기도에 동명이 겹치는 경우(예: 역삼동)를 시도로 구분합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;③ 결측치 처리 및 저장&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiW0uStseWSAxUAAAAAHQAAAAAQtQQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 결측치 처리 및 최종 저장
final_dong = final_dong.fillna(0)
final_dong.to_csv(f'{base_path}final_dong.csv', index=False, encoding='utf-8-sig')
print(f&quot;통합 완료: {len(final_dong)}개 동&quot;)
print(f&quot;총 컬럼: {len(final_dong.columns)}개&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;left join 특성상 학원 데이터에는 있지만 아파트&amp;middot;학교&amp;middot;인구&amp;middot;상가 데이터에는 없는 동이 존재할 수 있습니다. 이런 경우 발생하는 NaN을 fillna(0)으로 일괄 처리합니다. 이는 해당 동에 거래 이력이나 학교가 없는 것으로 간주하는 방식으로, 클러스터링 알고리즘이 결측치 없이 모든 피처를 사용할 수 있도록 보장합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 진행하면 데이터 전처리는 완료입니다. 주로 도로명주소나 시군 법정동 명의 이름을 통일시키는 텍스트 전처리가 주요 요인이었습니다. 이후에는 피벗테이블을 통해서 데이터프레임을 만들고 병합하는 과정을 가졌습니다. 다음 포스트에서는 세부적인 분석 프로세스에 대해서 작성하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Data Analyst Project</category>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/187</guid>
      <comments>https://ojko.tistory.com/187#entry187comment</comments>
      <pubDate>Fri, 20 Feb 2026 01:36:54 +0900</pubDate>
    </item>
    <item>
      <title>[Excel] 은행 마케팅 데이터 분석: 피벗 테이블과 대시보드 시각화</title>
      <link>https://ojko.tistory.com/186</link>
      <description>&lt;h2 data-path-to-node=&quot;2&quot; data-ke-size=&quot;size26&quot;&gt;1단계: 데이터 가져오기 및 저장&lt;/h2&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;분석을 시작하기 위해 원본 데이터를 엑셀로 불러온다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;4&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;엑셀을 실행하고 데이터 탭의 텍스트/CSV에서를 클릭한다.&lt;/li&gt;
&lt;li&gt;준비된 bank-additional-full.csv 파일을 선택하고 가져오기를 클릭한다.&lt;/li&gt;
&lt;li&gt;데이터 미리보기 창에서 로드(Load)를 클릭하여 워크시트에 데이터를 불러온다.&lt;/li&gt;
&lt;li&gt;파일 이름을 Bank_Marketing_Analysis.xlsx로 저장하여 작업을 시작한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1916&quot; data-origin-height=&quot;962&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xRCzj/dJMcabiG3P3/eeYKxcwQGv3lahF3Zn5hhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xRCzj/dJMcabiG3P3/eeYKxcwQGv3lahF3Zn5hhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xRCzj/dJMcabiG3P3/eeYKxcwQGv3lahF3Zn5hhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxRCzj%2FdJMcabiG3P3%2FeeYKxcwQGv3lahF3Zn5hhk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1916&quot; height=&quot;962&quot; data-origin-width=&quot;1916&quot; data-origin-height=&quot;962&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;5&quot; data-ke-size=&quot;size26&quot;&gt;2단계: 캠페인 성과 분석 (가입 성공률)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;마케팅 전화가 실제 정기 예금 가입으로 이어졌는지 전체적인 성공률을 파악한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;7&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;삽입 탭에서 피벗 테이블을 클릭하고 새 워크시트를 생성한다. 시트 이름은 Campaign Results로 변경한다.&lt;/li&gt;
&lt;li&gt;행(Rows)과 값(Values) 영역에 모두 y 필드(가입 여부)를 드래그한다.&lt;/li&gt;
&lt;li&gt;값 영역의 개수 항목을 우클릭하여 값 표시 형식을 총합계 비율(% of Grand Total)로 변경한다.&lt;/li&gt;
&lt;li&gt;삽입 &amp;gt; 원형 차트(Pie Chart)를 생성하고 제목을 '캠페인 성공률'로 수정하여 직관적으로 비율을 확인한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;726&quot; data-origin-height=&quot;690&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFC4kU/dJMcabpskKf/02I5y9QSG7PabypkEfZz10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFC4kU/dJMcabpskKf/02I5y9QSG7PabypkEfZz10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFC4kU/dJMcabpskKf/02I5y9QSG7PabypkEfZz10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFC4kU%2FdJMcabpskKf%2F02I5y9QSG7PabypkEfZz10%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;726&quot; height=&quot;690&quot; data-origin-width=&quot;726&quot; data-origin-height=&quot;690&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size26&quot;&gt;3단계: 인구통계학적 분석 (연령 및 직업별 패턴)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;어떤 나이와 직업을 가진 고객이 주로 가입하는지 분석한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;10&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;새 피벗 테이블을 생성하고 시트 이름을 Demographics Analysis로 변경한다.&lt;/li&gt;
&lt;li&gt;행(Rows)에 age, 열(Columns)에 job, 값(Values)에 y를 배치한다.&lt;/li&gt;
&lt;li&gt;행 영역의 나이 값을 우클릭하고 그룹(Group)을 선택, 단위를 10으로 설정하여 연령대로 묶는다.&lt;/li&gt;
&lt;li&gt;값 영역을 우클릭하여 값 표시 형식을 행 합계 비율(% of Row Total)로 변경한다. 이는 각 연령대 안에서 특정 직업군이 차지하는 비중을 파악하는 데 유용하다.&lt;/li&gt;
&lt;li&gt;묶은 세로 막대형 차트(Column Chart)를 생성하여 시각화한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1411&quot; data-origin-height=&quot;1015&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0JkO2/dJMcahXwv8g/CYo808NPHTKJYIfZ25pUT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0JkO2/dJMcahXwv8g/CYo808NPHTKJYIfZ25pUT0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0JkO2/dJMcahXwv8g/CYo808NPHTKJYIfZ25pUT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0JkO2%2FdJMcahXwv8g%2FCYo808NPHTKJYIfZ25pUT0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1411&quot; height=&quot;1015&quot; data-origin-width=&quot;1411&quot; data-origin-height=&quot;1015&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size26&quot;&gt;4단계: 캠페인 시기 분석 (히트맵 시각화)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;요일 및 월별로 통화 효율성을 분석한다. 이때 통화 건수(양)와 통화 시간(질)을 동시에 비교하는 것이 핵심이다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;13&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;새 피벗 테이블을 만들고 시트 이름을 Campaign Timing으로 변경한다.&lt;/li&gt;
&lt;li&gt;행에 month, 열에 day_of_week를 배치한다.&lt;/li&gt;
&lt;li&gt;값 영역에 두 가지 필드를 추가한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;13,2,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;값 1: y (개수) - 통화 시도 량 파악&lt;/li&gt;
&lt;li&gt;값 2: duration (평균) - 통화의 질 파악 (표시 형식: 소수점 없는 숫자)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1373&quot; data-origin-height=&quot;1015&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buGc6W/dJMcachAhGG/04hw1jFHKv7QOie8FuXkvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buGc6W/dJMcachAhGG/04hw1jFHKv7QOie8FuXkvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buGc6W/dJMcachAhGG/04hw1jFHKv7QOie8FuXkvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbuGc6W%2FdJMcachAhGG%2F04hw1jFHKv7QOie8FuXkvk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1373&quot; height=&quot;1015&quot; data-origin-width=&quot;1373&quot; data-origin-height=&quot;1015&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;핵심 팁: 이중 조건부 서식 적용하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;단순히 범위를 드래그하여 색조를 적용하면, '개수(수천 단위)'와 '시간(수백 단위)'의 스케일 차이로 인해 데이터가 왜곡된다. 따라서 피벗 테이블 전용 규칙을 사용하여 각각 적용해야 한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;16&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;통화 건수(Count) 시각화
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;16,0,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;개수 : y 값이 있는 셀 하나를 클릭한다.&lt;/li&gt;
&lt;li&gt;홈 &amp;gt; 조건부 서식 &amp;gt; 새 규칙을 클릭한다.&lt;/li&gt;
&lt;li&gt;규칙 적용 대상에서 &quot; 'month' 및 'day_of_week'에 대한 '개수 : y' 셀 모든 셀&quot;을 선택한다.&lt;/li&gt;
&lt;li&gt;서식 스타일을 [2가지 색조] (예: 파랑)로 설정하고 적용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;통화 시간(Duration) 시각화
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;16,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;평균 : duration 값이 있는 셀 하나를 클릭한다.&lt;/li&gt;
&lt;li&gt;동일하게 새 규칙으로 진입하여 적용 대상을 &quot;...대한 '평균 : duration' 셀 모든 셀&quot;로 선택한다.&lt;/li&gt;
&lt;li&gt;색상을 다르게(예: 주황) 설정하여 적용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 데이터가 추가되거나 필터가 변경되어도 서식이 유지되며, 통화량이 많은 시기와 상담이 길게 이어진 시기를 한눈에 구분할 수 있다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size26&quot;&gt;5단계: 대시보드 구축 및 상호작용 설정&lt;/h2&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;생성한 차트들을 한곳에 모아 종합적인 인사이트를 도출한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;20&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Dashboard라는 새 시트를 만들고 앞서 만든 3개의 차트를 배치한다.&lt;/li&gt;
&lt;li&gt;피벗 차트 분석 탭에서 슬라이서 삽입을 클릭하고 education, marital, loan을 선택한다.&lt;/li&gt;
&lt;li&gt;생성된 슬라이서를 우클릭하여 보고서 연결(Report Connections)을 선택하고, 모든 피벗 테이블을 체크한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;586&quot; data-origin-height=&quot;701&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nTv3U/dJMcafSV4EY/iKcYMNaGSRIrmOnrt3kak0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nTv3U/dJMcafSV4EY/iKcYMNaGSRIrmOnrt3kak0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nTv3U/dJMcafSV4EY/iKcYMNaGSRIrmOnrt3kak0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnTv3U%2FdJMcafSV4EY%2FiKcYMNaGSRIrmOnrt3kak0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;586&quot; height=&quot;701&quot; data-origin-width=&quot;586&quot; data-origin-height=&quot;701&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;6단계: 분석 및 결론&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;완성된 대시보드에서 슬라이서를 조작하며 다음과 같은 인사이트를 얻을 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;23&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;히트맵을 통해 통화 건수는 5월에 가장 많았으나, 고객과의 진지한 소통(긴 통화 시간)은 12월이나 특정 요일에 집중됨을 파악할 수 있다.&lt;/li&gt;
&lt;li&gt;연령대별 직업 분포 차트에서 은퇴자(retired) 그룹이 특정 고연령층에서 압도적인 비중을 차지함을 확인할 수 있다.&lt;/li&gt;
&lt;li&gt;교육 수준이나 결혼 여부에 따라 캠페인 성공률이 어떻게 달라지는지 슬라이서를 통해 실시간으로 필터링하여 전략을 수립한다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Excel</category>
      <author>ParkS2</author>
      <guid isPermaLink="true">https://ojko.tistory.com/186</guid>
      <comments>https://ojko.tistory.com/186#entry186comment</comments>
      <pubDate>Fri, 23 Jan 2026 00:45:44 +0900</pubDate>
    </item>
  </channel>
</rss>