o2o-negosium-original/lps/services/pipeline/outliers.py
민헌 fb72ab625f feat(lps): 코어 파이프라인 — mall 필터·IQR 이상치·top-N 최저가
검색 결과를 실제 최저가로 정제하는 파이프라인을 핸들러에 결합한다.
검색→필터→이상치→정렬→top-N. AI 유사도 판정 슬롯은 비워둠(키 대기).

- pipeline/filters: keep_only_mall·filter_out_malls·filter_by_price_band(요청 현재가 기준 targeted 컷)
- pipeline/outliers: IQR 기반 이상치 제거(z-score 대신 — 분포 가정 없음, stdlib만)
- pipeline/core: STAGE in/out 관측 로깅 + top-N 최저가(레퍼런스 pipeline_log 계승)
- handler: 검색→run_price_pipeline 결합, 요청 price 를 밴드 기준으로 사용
- tests: 정렬/IQR(극단 저·고가 제거)/밴드/mall/빈입력/STAGE/실 fixture 7건 → 전체 27/27
- 라이브 확인: 스탠리 텀블러 40건→top-5 최저가, STAGE 카운트 노출

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:06:15 +09:00

33 lines
1.3 KiB
Python

"""가격 이상치 제거.
레퍼런스는 반복 z-score(정규분포 가정)를 썼으나, 가격 분포는 한쪽으로 치우친(로그정규) 경우가
많아 취약하다. 대신 분포 가정이 없는 **IQR(사분위 범위) 방식**을 쓴다(stdlib 만, numpy 불필요).
목적: '엉뚱한 초저가(액세서리/오매칭)·초고가(묶음)'가 최저가 산정을 오염시키는 것을 막는다.
"""
import statistics
from services.search.contract import NormalizedProduct
def remove_price_outliers(
products: list[NormalizedProduct], k: float = 1.5, min_items: int = 4
) -> tuple[list[NormalizedProduct], list[NormalizedProduct]]:
"""IQR 기반 이상치 제거. [Q1 - k·IQR, Q3 + k·IQR] 밖을 제거.
데이터가 min_items 미만이면 통계가 무의미하므로 그대로 통과. (kept, removed) 반환."""
prices = [p.price for p in products if p.price and p.price > 0]
if len(prices) < min_items:
return list(products), []
q1, _, q3 = statistics.quantiles(prices, n=4)
iqr = q3 - q1
lo, hi = q1 - k * iqr, q3 + k * iqr
kept, removed = [], []
for p in products:
if p.price is None or lo <= p.price <= hi:
kept.append(p)
else:
removed.append(p)
return kept, removed