뉴스 관련성 필터링 — 신호 대 잡음비(SNR) 개선
무관한 뉴스를 걸러내는 Phase 6B 설계
이 글은 AI_Finance(ALCHEMETRIC) 프로젝트의 뉴스 센티먼트 피처에서 신호 대 잡음비(Signal-to-Noise Ratio)를 개선하기 위해 도입한 Phase 6B 뉴스 관련성 필터링을 다룹니다. 네이버 뉴스 API에서 종목명으로 검색하면 직접 관련 기사와 간접 언급 기사가 뒤섞여 들어오는데, 이 노이즈를 걸러내는 것이 핵심입니다. 수동 라벨링이나 복잡한 NER 없이, "제목에 종목명이 포함되어 있는가"라는 단순 휴리스틱만으로 효과적인 필터링을 구현합니다.
목차
1. 문제 정의 — 뉴스 센티먼트의 노이즈
Phase 4에서 도입한 뉴스 센티먼트 피처(news_sentiment)는 네이버 뉴스 API에서 종목명으로 검색한 모든 기사의 센티먼트 평균이다. 문제는 검색 결과에 간접 언급 기사가 상당수 포함된다는 점이다.
"삼성전자" 검색 결과 예시
| 뉴스 제목 | 센티먼트 | 관련성 |
|---|---|---|
| 삼성전자, HBM4 양산 일정 앞당겨… "하반기 본격 출하" | +0.82 | 직접 |
| 삼성전자 주가 52주 신고가 경신… 외국인 순매수 | +0.71 | 직접 |
| 삼성전자 납품사 A기업, 실적 부진으로 적자 전환 | -0.65 | 간접 |
| "삼성전자 사옥 인근 재개발" 부동산 이슈 논란 | -0.42 | 간접 |
| 반도체 업황 회복… 삼성전자 수혜 기대 | +0.58 | 간접 |
Phase 4 news_sentiment = 전체 5건 평균 = (+0.82 +0.71 -0.65 -0.42 +0.58) / 5 = +0.208
Phase 6B news_sentiment_filtered = 직접 관련 2건 평균 = (+0.82 +0.71) / 2 = +0.765
간접 기사의 부정 센티먼트가 삼성전자의 실제 뉴스 분위기를 왜곡하고 있었다.
네이버 뉴스 API는 키워드 검색 기반이므로, "삼성전자"라는 단어가 본문 어딘가에 한 번이라도 등장하면 검색 결과에 포함된다. 이때 제목에 종목명이 없는 기사는 대부분 간접 언급(납품사, 경쟁사, 부동산 등)이다. Phase 6B는 이 간단한 관찰을 필터링 로직으로 구현한다.
2. Phase 6B 피처 3개
Phase 6B는 _merge_news_refined_features() 메서드에서 3개 피처를 계산한다. 이 피처들은 Phase 4의 기존 뉴스 피처를 대체하지 않고 보완한다.
| 피처명 | 범위 | 설명 |
|---|---|---|
| news_relevance_ratio | 0 ~ 1 | 제목에 종목명이 직접 포함된 뉴스의 비율. 0.3이면 검색 결과 10건 중 3건만 직접 관련. |
| news_sentiment_filtered | -1 ~ +1 | 제목에 종목명이 포함된 뉴스만의 평균 센티먼트. 노이즈 제거된 "깨끗한" 센티먼트. |
| sector_news_sentiment | -1 ~ +1 | 동일 섹터 종목들의 news_sentiment 평균. feature_store 피어 데이터 기반 "업종 분위기". |
피처 상수는 feature_engineer.py 상단에 정의된다.
# Phase 6B 뉴스 정제 피처 내부 상수 _NEWS_REFINED_FEATURE_COLUMNS = [ "news_relevance_ratio", "news_sentiment_filtered", "sector_news_sentiment", ] # Phase 6 피처 컬럼 (Phase 5 + 섹터/상대강도 + 뉴스 정제) PHASE6_FEATURE_COLUMNS = PHASE5_FEATURE_COLUMNS + [ # Phase 6A: 섹터/상대강도 (7개) "sector_return_1d", "sector_return_5d", "relative_strength_1d", "relative_strength_5d", "relative_strength_20d", "sector_momentum_rank", "sector_breadth", # Phase 6B: 뉴스 정제 (3개) "news_relevance_ratio", "news_sentiment_filtered", "sector_news_sentiment", ]
3. 관련성 필터링 로직 — SQL CASE 기반
필터링의 핵심은 NewsRepository.get_daily_sentiment_filtered() 메서드다. 단일 쿼리로 전체 건수, 관련 건수, 관련 뉴스만의 평균 센티먼트를 동시에 계산한다.
Repository 레이어 — news_repository.py
def get_daily_sentiment_filtered( self, code: str, stock_name: str, start_date: date, end_date: date, ) -> list[dict]: """ 종목별 일별 센티먼트 (제목에 종목명 포함 필터링, Phase 6B) """ from sqlalchemy import case # 핵심: 제목에 종목명이 포함되어 있으면 "관련" is_relevant = NewsSentiment.title.contains(stock_name) rows = ( self.session.query( NewsSentiment.date, func.count(NewsSentiment.id).label("total_count"), func.sum(case((is_relevant, 1), else_=0)).label("filtered_count"), func.avg( case( (is_relevant, NewsSentiment.sentiment_score), else_=None, ) ).label("filtered_avg_score"), ) .filter( NewsSentiment.code == code, NewsSentiment.date >= start_date, NewsSentiment.date <= end_date, ) .group_by(NewsSentiment.date) .order_by(NewsSentiment.date) .all() )
title.contains(stock_name)은 SQLAlchemy에서 LIKE '%종목명%'으로 변환된다. SQL의 CASE WHEN을 활용하여 한 번의 쿼리로 두 가지 값을 동시에 집계한다.
반환값 변환
return [{ "date": r.date, "news_sentiment_filtered": ( round(float(r.filtered_avg_score), 4) if r.filtered_avg_score else None ), "news_relevance_ratio": ( round(int(r.filtered_count) / int(r.total_count), 4) if r.total_count and int(r.total_count) > 0 else None ), } for r in rows]
왜 "제목 포함"인가? 네이버 뉴스 API 검색 결과는 본문 전체를 대상으로 하지만, 기사의 주제가 해당 종목인 경우 제목에 종목명이 명시될 확률이 매우 높다. 반면 "삼성전자 납품사 A기업 실적 부진"처럼 간접 언급 기사는 제목에 해당 종목명이 아닌 다른 기업명이 들어간다. NER이나 학습 기반 분류기 없이도 이 단순 휴리스틱이 놀라울 만큼 잘 작동한다.
SQL 실행 예시
위 코드가 생성하는 SQL은 아래와 같은 형태다.
SELECT date, COUNT(id) AS total_count, SUM(CASE WHEN title LIKE '%삼성전자%' THEN 1 ELSE 0 END) AS filtered_count, AVG(CASE WHEN title LIKE '%삼성전자%' THEN sentiment_score ELSE NULL END) AS filtered_avg_score FROM news_sentiment WHERE code = '005930' AND date BETWEEN '2026-01-01' AND '2026-03-11' GROUP BY date ORDER BY date;
4. 섹터 뉴스 센티먼트 — 업종 분위기 피처
Phase 6A에서 이미 StockInfo.sector를 통해 동일 섹터 피어를 파악해 두었다. Phase 6B는 이 피어 종목들의 news_sentiment(Phase 4에서 이미 계산된 값)를 날짜별로 평균내어 "업종 뉴스 분위기"를 수치화한다.
_merge_news_refined_features() — 섹터 센티먼트 부분
# --- 2) 섹터 뉴스 센티먼트 --- sector = stock_info.sector if stock_info else None if sector: sector_codes = stock_repo.get_codes_in_sector(sector, market) if len(sector_codes) >= 2: ml_repo = MLRepository(session) sector_rows = ml_repo.get_sector_features_bulk( market, sector_codes, min_date, max_date, ) if sector_rows: s_df = pd.DataFrame([{ "date": r.date, "news_sentiment": float(r.news_sentiment) if r.news_sentiment is not None else None, } for r in sector_rows]) # 날짜별 섹터 평균 (NULL 제외) sector_sent = s_df.dropna(subset=["news_sentiment"]) \ .groupby("date").agg( sector_news_sentiment=("news_sentiment", "mean"), ).reset_index() sector_sent["sector_news_sentiment"] = \ sector_sent["sector_news_sentiment"].round(4) df = df.merge(sector_sent, on="date", how="left")
데이터 흐름을 정리하면 아래와 같다.
┌──────────────────────────────────────────────────────────────┐ │ sector_news_sentiment 계산 흐름 │ ├──────────────────────────────────────────────────────────────┤ │ │ │ stock_info.sector = "반도체" │ │ ▼ │ │ get_codes_in_sector("반도체", "KOSPI") │ │ ▼ │ │ ["005930", "000660", "042700", ...] (같은 섹터 종목들) │ │ ▼ │ │ feature_store에서 이 종목들의 news_sentiment 벌크 조회 │ │ ▼ │ │ 날짜별 groupby("date").mean() │ │ ▼ │ │ sector_news_sentiment = 섹터 평균 뉴스 분위기 │ │ │ └──────────────────────────────────────────────────────────────┘
왜 feature_store를 재활용하는가? Pass 1에서 각 종목의 Phase 4 news_sentiment가 이미 feature_store에 저장되어 있다. 이 데이터를 피어 종목에 대해 벌크 조회하면, 별도의 뉴스 API 호출이나 센티먼트 재분석 없이 섹터 뉴스 분위기를 계산할 수 있다. 이것이 2-Pass 아키텍처의 핵심 이점이다.
5. Before/After — Phase 4 vs Phase 6B
비교 다이어그램
피처 비교 테이블
| 항목 | Phase 4 (기존) | Phase 6B (개선) |
|---|---|---|
| 피처명 | news_sentiment | news_sentiment_filtered |
| 대상 뉴스 | 종목 코드로 검색된 전체 기사 | 제목에 종목명 포함된 기사만 |
| 노이즈 수준 | 높음 (간접 기사 포함) | 낮음 (직접 관련만) |
| 필터링 기법 | 없음 | SQL CASE + title LIKE |
| 계산 시점 | Pass 1 (독립 계산 가능) | Pass 2 (섹터 피어 의존) |
| 추가 데이터 | — | + news_relevance_ratio + sector_news_sentiment |
| ML 활용 | 단독 사용 | Phase 4와 함께 제공 → 모델이 노이즈 수준 학습 |
핵심 인사이트: Phase 4의 news_sentiment를 삭제하지 않고 Phase 6B 피처와 함께 ML에 제공한다. 두 값의 차이 자체가 "노이즈 수준"을 나타내는 유용한 정보이기 때문이다. ML 모델은 news_sentiment와 news_sentiment_filtered의 괴리가 큰 종목에서 뉴스 신뢰도를 낮게 학습할 수 있다.
6. 2-Pass 아키텍처와 Phase 6B의 위치
Phase 6B의 sector_news_sentiment는 동일 섹터 피어 종목들의 news_sentiment(Phase 4)를 필요로 한다. 따라서 모든 종목의 Phase 1~5+7이 완료된 이후에만 계산이 가능하다. 이것이 2-Pass 아키텍처가 필요한 이유다.
┌─────────────────────────────────────────────────────────────────┐ │ compute_all() — 2-Pass 오케스트레이션 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Pass 1: 종목별 compute_features(include_phase6=False) │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Phase 1 기술적 지표 (23개) │ │ │ │ Phase 2 펀더멘털 ( 9개) │ │ │ │ Phase 3 거시경제 (11개) │ │ │ │ Phase 4 뉴스 센티먼트 ( 5개) ← news_sentiment 계산 │ │ │ │ Phase 5 공시 + 수급 (11개) │ │ │ │ Phase 7 대안 데이터 ( 6개) │ │ │ └──────────────────────────┬───────────────────────────────┘ │ │ ▼ feature_store 저장 │ │ │ │ Pass 2: 종목별 _compute_phase6_only() │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Phase 6A 섹터/상대강도 ( 7개) ← 피어 return_1d/5d/20d │ │ │ │ Phase 6B 뉴스 정제 ( 3개) ← 피어 news_sentiment │ │ │ └──────────────────────────┬───────────────────────────────┘ │ │ ▼ feature_store UPDATE │ │ │ └─────────────────────────────────────────────────────────────────┘
_compute_phase6_only() 핵심 코드
Pass 2에서 호출되는 _compute_phase6_only()는 feature_store에서 Phase 6 미계산 레코드(sector_return_1d IS NULL)만 선별하여 Phase 6A/6B를 순차 실행한다.
def _compute_phase6_only(self, market, code, start_date, end_date): """Phase 6 피처만 계산하여 feature_store 업데이트""" with database.session() as session: ml_repo = MLRepository(session) features = ml_repo.get_features(market, code, start_date, end_date) # Phase 6 미계산 레코드만 처리 features = [f for f in features if f.sector_return_1d is None] df = pd.DataFrame([{ "date": f.date, "return_1d": float(f.return_1d) if f.return_1d else None, "return_5d": float(f.return_5d) if f.return_5d else None, "return_20d": float(f.return_20d) if f.return_20d else None, "news_sentiment": float(f.news_sentiment) if f.news_sentiment else None, } for f in features]) # Phase 6A: 섹터/상대강도 df = self._merge_sector_features(df, market, code, session) # Phase 6B: 뉴스 정제 df = self._merge_news_refined_features(df, market, code, session) # Phase 6 컬럼만 추출하여 upsert phase6_cols = _SECTOR_FEATURE_COLUMNS + _NEWS_REFINED_FEATURE_COLUMNS records = [] for _, row in df.iterrows(): record = {"market": market, "code": code, "date": row["date"]} for col in phase6_cols: ... # 값 변환 후 record에 추가 records.append(record) saved = ml_repo.upsert_features(records)
증분 모드에서는 _get_phase6_targets()가 Pass 1에서 갱신된 종목 + Phase 6 미계산 종목만 선별하여 Pass 2의 처리량을 최소화한다.
7. 설계 철학 — 수동 라벨 없는 품질 개선
Phase 6B의 접근법은 "가장 단순한 방법으로 가장 큰 효과"를 추구한다. 복잡한 NER(Named Entity Recognition)이나 학습 기반 관련성 분류기를 도입하지 않고, 뉴스 제목에 종목명이 있는지만 확인하는 것으로 충분히 유효한 필터링이 가능하다.
설계 원칙 3가지
| # | 원칙 | Phase 6B 적용 |
|---|---|---|
| 1 | 대체하지 않고 보완한다 | Phase 4의 news_sentiment를 삭제하지 않고, 정제된 버전을 추가로 제공. ML이 두 피처의 차이에서 노이즈 수준을 학습. |
| 2 | 수동 라벨 비용을 0으로 유지 | 종목명 제목 포함 여부는 자동 판단 가능. 사람이 "이 기사가 관련 있는지" 라벨링할 필요 없음. |
| 3 | 기존 인프라를 최대 활용 | sector_news_sentiment는 feature_store에 이미 저장된 Phase 4 데이터를 재활용. 추가 API 호출 제로. |
news_relevance_ratio의 숨겨진 가치
news_relevance_ratio는 단순히 필터링 비율을 기록하는 것 이상의 의미를 갖는다. 이 값이 낮을수록(예: 0.2) 해당 종목의 뉴스 검색 결과에 간접 언급이 많다는 뜻이다. 이는 다음과 같은 해석이 가능하다.
news_relevance_ratio가 낮은 종목 → 검색 결과의 대부분이 간접 언급 → Phase 4 news_sentiment의 신뢰도가 낮음 → ML 모델이 이 종목의 뉴스 피처에 낮은 가중치 부여 가능 news_relevance_ratio가 높은 종목 (예: 대형주) → 검색 결과가 대부분 직접 관련 → Phase 4 news_sentiment ≈ Phase 6B news_sentiment_filtered → ML 모델이 뉴스 피처를 높은 신뢰도로 활용
전체 피처 파이프라인에서의 위치
Phase 6B의 3개 피처가 추가됨에 따라 ALCHEMETRIC의 전체 피처 수는 다음과 같이 구성된다.
| Phase | 카테고리 | 피처 수 | 누적 |
|---|---|---|---|
| Phase 1 | 기술적 지표 | 23 | 23 |
| Phase 2 | 펀더멘털 | 9 | 32 |
| Phase 3 | 거시경제 | 11 | 43 |
| Phase 4 | 뉴스 센티먼트 | 5 | 48 |
| Phase 5 | 공시 + 수급 | 11 | 59 |
| Phase 6A | 섹터 / 상대강도 | 7 | 66 |
| Phase 6B | 뉴스 정제 (SNR 개선) | 3 | 69 |
| Phase 7 | 대안 데이터 | 6 | 75 |
정리: Phase 6B는 3개의 피처만 추가하지만, 기존 5개 뉴스 피처(Phase 4)의 해석 가능성과 ML 활용도를 크게 높인다. "얼마나 관련 있는 뉴스인지"를 피처로 만들어 ML에게 판단을 위임하는 것이 핵심이다. 제목에 종목명이 있는지 확인하는 한 줄의 SQL CASE 문이, 뉴스 센티먼트 파이프라인 전체의 SNR을 개선하는 가성비 높은 솔루션이다.
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #13 [모듈1-7] DART 공시 7유형 자동 분류 + 감성 분석 (0) | 2026.03.09 |
|---|---|
| #12 [모듈1-6] opendartreader + 규칙 기반 공시 분류 (0) | 2026.03.06 |
| #10 [모듈1-4] 종목 뉴스 vs 시장 뉴스 분리 전략 (0) | 2026.02.26 |
| #9 [모듈1-3] 뉴스 센티먼트 분석 & 일간 리포트 시스템 (0) | 2026.02.19 |
| #8 [모듈1-2] 금융 BERT 모델 메모리 관리 (0) | 2026.02.19 |