1d3d61d31e
배포 후 관측 결과 (2026-05-13 새벽): - 126 docs / 7 countries 인데 THRESHOLD=0.78 로 raw_clusters=124, dropped_min_articles=122, kept=1. - 거의 매 article 이 별 cluster 로 갈려 토픽 묶음 실패. - 같은 cron 어제 (5/12) 는 101 docs 에서 6 topics 성공 — 그날 뉴스가 우연히 같은 토픽으로 더 모인 case. 수동 측정 (5/13 동일 docs): - 0.78 → kept=1 - 0.70 → kept=5 (allowed) 영구 변경 = THRESHOLD=0.70. cross-country 필터 (MIN_COUNTRIES≥2) + min_articles(≥2) 그대로 유지하므로 noise topic 위험은 제한적. 원본 주석 (0.75~0.80 중간값) 도 갱신.
81 lines
2.7 KiB
Python
81 lines
2.7 KiB
Python
"""야간 뉴스 topic-first 클러스터링.
|
|
|
|
Phase 4 와 axis 반대: country 별 cluster 가 아닌 **전체 doc 합쳐서 topic cluster**.
|
|
각 cluster 안에 country 분포가 자동으로 들어감 (doc dict 의 country field).
|
|
|
|
파라미터 (5h 윈도우용):
|
|
- LAMBDA = ln(2)/2h ≈ 0.347 (2시간 반감기, 야간 5h 윈도우라 빠른 감쇠)
|
|
- threshold = 0.70 (2026-05-13 조정 — 0.78 에서 spread case kept=1 발생 후 완화)
|
|
- MIN_ARTICLES_PER_TOPIC = 2 (야간 sparse 대비 완화)
|
|
- MIN_COUNTRIES_PER_TOPIC = 2 (cross-country 가치 핵심)
|
|
- MAX_TOPICS = 7 (1페이지 분량)
|
|
"""
|
|
|
|
import math
|
|
|
|
from core.utils import setup_logger
|
|
from services.clustering_common import (
|
|
greedy_assign_cluster,
|
|
normalize_importance_scores,
|
|
)
|
|
|
|
logger = setup_logger("briefing_clustering")
|
|
|
|
LAMBDA = math.log(2) / (2.0 / 24.0) # 2시간 반감기 (단위: 일)
|
|
THRESHOLD = 0.70
|
|
CENTROID_ALPHA = 0.7
|
|
MIN_ARTICLES_PER_TOPIC = 2
|
|
MIN_COUNTRIES_PER_TOPIC = 2
|
|
MAX_TOPICS = 7
|
|
|
|
|
|
def _count_distinct_countries(cluster: dict) -> int:
|
|
return len({m.get("country") for m in cluster["members"] if m.get("country")})
|
|
|
|
|
|
def cluster_global(docs: list[dict]) -> list[dict]:
|
|
"""모든 country docs 를 합쳐 topic cluster 생성.
|
|
|
|
Args:
|
|
docs: loader.load_night_window 의 출력 (각 dict 에 country field 포함).
|
|
|
|
Returns:
|
|
[{centroid, members, weight_sum, raw_weight_sum, importance_score, country_count}, ...]
|
|
- MIN_ARTICLES + MIN_COUNTRIES 둘 다 충족 cluster 만
|
|
- importance_score 내림차순, MAX_TOPICS 개 cap
|
|
"""
|
|
if not docs:
|
|
logger.info("[briefing] docs=0 → skip")
|
|
return []
|
|
|
|
clusters, raw_count = greedy_assign_cluster(
|
|
docs,
|
|
threshold=THRESHOLD,
|
|
centroid_alpha=CENTROID_ALPHA,
|
|
min_articles=MIN_ARTICLES_PER_TOPIC,
|
|
max_topics=MAX_TOPICS * 4, # MIN_COUNTRIES 필터 전 buffer
|
|
lambda_val=LAMBDA,
|
|
)
|
|
|
|
# MIN_COUNTRIES_PER_TOPIC 필터 — single-country cluster drop
|
|
pre_country_filter = len(clusters)
|
|
filtered = []
|
|
for c in clusters:
|
|
cc = _count_distinct_countries(c)
|
|
if cc >= MIN_COUNTRIES_PER_TOPIC:
|
|
c["country_count"] = cc
|
|
filtered.append(c)
|
|
clusters = filtered[:MAX_TOPICS]
|
|
dropped_country = pre_country_filter - len(clusters)
|
|
dropped_min_articles = raw_count - pre_country_filter
|
|
|
|
# MIN_COUNTRIES + MAX_TOPICS 필터 후 importance 재정규화 (briefing 내 0~1)
|
|
normalize_importance_scores(clusters)
|
|
|
|
logger.info(
|
|
f"[briefing] docs={len(docs)} threshold={THRESHOLD} "
|
|
f"raw_clusters={raw_count} dropped_min_articles={dropped_min_articles} "
|
|
f"dropped_single_country={dropped_country} kept={len(clusters)}"
|
|
)
|
|
return clusters
|