Casa 가격 추정값을 범위와 신뢰도로 표현하기

Casa 가격 추정값을 범위와 신뢰도로 표현하기

한눈에 보기

중고 상품의 가격은 정답 하나가 아니라 조건부 분포에 가깝다. 그래서 가중 중앙값을 대표값으로 쓰고, 가중 분위수 구간·유효 표본 수·최신성·검색 적합도를 함께 반환했다. 중요한 것은 confidence = 0.82라는 숫자를 만드는 공식보다, 그 숫자가 실제 성공 확률과 비슷하도록 검증하고 보정하는 과정이다.

가격 검색 기능을 처음 만들 때 가장 자연스러운 출력은 숫자 하나다.

예상 가격: 1,240,000원

사용자도 이해하기 쉽고 API 스키마도 단순하다. 하지만 이 숫자는 실제 데이터가 가진 불확실성을 거의 모두 지운다. 같은 모델도 상태, 구성품, 거래 시점, 판매 채널에 따라 가격이 다르고, 비교 가능한 거래가 세 건뿐일 때와 백 건일 때의 124만 원은 의미가 전혀 다르다.

이 글은 Casa 가격 검색을 만들며 가격 추정 결과를 점 하나가 아니라 설명 가능한 묶음으로 바꾼 과정을 정리한다. 특정 저장소 코드를 옮긴 구현 문서가 아니라, 같은 문제에 재사용할 수 있도록 예시 데이터와 코드를 새로 구성한 글이다.

목차

가격은 하나의 값보다 분포에 가깝다

중고 거래 가격에는 여러 종류의 변동이 섞여 있다.

변동 요인 예시 모델이 할 수 있는 일
상품 차이 크기, 색상, 소재, 연식 특징으로 분리하거나 가중치에 반영
상태 차이 미사용, 생활 사용감, 손상 상태 등급을 정규화하고 비교 표본을 제한
거래 조건 구성품, 배송비, 급매 가능한 항목만 구조화하고 나머지는 불확실성에 포함
시간 변화 계절성, 유행, 단종 최신 거래에 높은 가중치 부여
채널 차이 경매 낙찰가, 판매 희망가 출처별로 분리하거나 신뢰도 조정
관측 오류 잘못된 모델 매칭, 오타 이상치 완화와 검색 품질 평가

따라서 우리가 가진 관측값은 다음과 같은 형태에 가깝다.

P(가격 | 모델, 상태, 시점, 구성품, 거래 채널, ...)

서비스가 반환하는 124만 원은 이 분포를 한 점으로 요약한 값이다. 요약값 자체가 나쁜 것은 아니다. 다만 사용자가 판단하려면 적어도 다음 질문에 답할 수 있어야 한다.

핵심 구분

estimated_price는 가격 분포의 중심을 요약한다. confidence는 그 추정이 허용 오차 안에 들어올 가능성을 설명한다. 두 값은 목적이 다르므로 같은 공식에서 우연히 함께 나오게 두지 않는 편이 좋다.

먼저 추정 대상부터 고정한다

통계 공식을 선택하기 전에 무엇을 추정하는지 먼저 정해야 한다. 판매자가 올린 희망 가격과 실제 낙찰 가격을 한데 모으면서 ‘시장 가격’이라고 부르면 어떤 분위수를 써도 해석이 흐려진다.

예를 들어 Casa의 가상 요구사항을 다음처럼 고정할 수 있다.

추정 대상

오늘 기준으로 동일하거나 매우 유사한 모델이 일반적인 중고 상태에서 거래될 가능성이 높은 금액을 추정한다. 판매 완료 또는 낙찰 관측을 우선하고, 판매 중인 가격은 보조 근거로만 사용한다.

이 정의에 따라 입력 관측값에도 역할을 부여한다.

from dataclasses import dataclass
from datetime import date
from typing import Literal


@dataclass(frozen=True)
class PriceObservation:
    price_krw: int
    observed_on: date
    source: Literal["auction", "sold_listing", "active_listing"]
    condition_grade: Literal["S", "A", "B", "C"]
    match_score: float
    listing_id: str

위 코드는 설명을 위해 만든 예시다. 핵심은 가격 배열만 받지 않고 가격이 생성된 맥락을 함께 보존하는 데 있다. 그래야 나중에 결과가 이상할 때 어떤 관측이 얼마만큼 영향을 줬는지 추적할 수 있다.

평균 하나로 시작했을 때 생긴 문제

가장 단순한 구현은 검색된 가격의 산술 평균이다.

def naive_average(prices: list[int]) -> int | None:
    if not prices:
        return None
    return round(sum(prices) / len(prices))

다음 관측값을 넣어 보자.

prices = [
    1_090_000,
    1_130_000,
    1_160_000,
    1_210_000,
    3_900_000,  # 모델이 잘못 매칭된 관측
]

평균은 169만 8천 원이지만, 다섯 건 중 네 건은 109만~121만 원에 모여 있다. 마지막 한 건이 입력 오류인지 특별판인지 알 수 없어도 평균은 크게 끌려간다.

이상치를 IQR이나 고정 비율로 먼저 제거할 수도 있다. 그러나 표본이 적을 때 기계적으로 제거하면 오히려 실제 고가 거래를 지울 수 있다. 무엇보다 ‘이상치 제거 후 평균’도 사용자가 보게 되는 것은 여전히 근거가 사라진 숫자 하나다.

대표값 후보를 비교하면 다음과 같다.

방법 장점 약점 적합한 상황
산술 평균 쉽고 모든 값을 반영 이상치에 매우 민감 대칭적이고 정제된 대규모 표본
절사 평균 극단값 영향을 줄임 적은 표본에서 제거 기준이 불안정 표본이 충분하고 오류율이 일정할 때
중앙값 이상치에 강함 표본의 품질·최신성을 반영하지 못함 단순하고 강건한 기준선
가중 평균 품질을 가중치로 반영 큰 가격의 영향은 여전히 큼 오차가 작고 가중치가 신뢰 가능할 때
가중 중앙값 이상치에 강하고 품질 반영 가중치 설계에 따라 급변 가능 이질적인 중고 거래 관측

Casa에서는 대표값의 출발점을 가중 중앙값으로 잡는 편이 자연스러웠다. 검색 적합도와 최신성을 반영하면서도 한두 개의 큰 가격이 전체 결과를 끌고 가지 않기 때문이다.

가중 중앙값과 가중 분위수

일반 분위수는 값을 정렬하고 순서상의 위치를 고른다. 가중 분위수는 각 값이 차지하는 질량을 다르게 본다. 가격과 가중치가 다음과 같다고 하자.

가격 가중치 누적 가중치 비율
1,100,000 0.8 0.20
1,180,000 1.4 0.55
1,250,000 1.2 0.85
2,900,000 0.6 1.00

누적 비율이 처음 0.5를 넘는 118만 원이 가중 중앙값이다. 290만 원 관측도 완전히 버리지는 않지만 대표값을 지배하지 못한다.

간단한 구현은 다음과 같다.

from collections.abc import Iterable


def weighted_quantile(
    values: Iterable[tuple[int, float]],
    quantile: float,
) -> int:
    if not 0.0 <= quantile <= 1.0:
        raise ValueError("quantile must be between 0 and 1")

    rows = sorted(
        ((price, weight) for price, weight in values if weight > 0),
        key=lambda row: row[0],
    )
    if not rows:
        raise ValueError("at least one positive weight is required")

    total_weight = sum(weight for _, weight in rows)
    threshold = total_weight * quantile
    cumulative = 0.0

    for price, weight in rows:
        cumulative += weight
        if cumulative >= threshold:
            return price

    return rows[-1][0]

이 구현은 관측된 가격 중 하나를 그대로 반환하는 계단형 추정이다. 선형 보간을 사용하면 더 부드러운 결과를 얻을 수 있지만, 돈 단위 결과가 더 정교해 보인다고 실제 정보가 늘어나는 것은 아니다. 표본이 열 건인데 1,183,427원처럼 표시하면 오히려 거짓 정밀도를 만든다.

def round_for_display(price: int) -> int:
    """가격대에 맞게 표시 단위를 낮춘 가상 예시."""
    unit = 10_000 if price < 5_000_000 else 50_000
    return round(price / unit) * unit
표시 정밀도

계산 내부에서는 원 단위를 유지해도 화면에서는 만 원 단위처럼 반올림할 수 있다. 정밀한 숫자보다 데이터가 뒷받침하는 해상도를 보여 주는 것이 중요하다.

가중치는 무엇을 의미해야 하는가

가중치는 원하는 결과가 나오도록 조정하는 마법 숫자가 아니다. 각 관측이 추정 대상에 얼마나 많은 증거를 제공하는지 나타내야 한다.

가상의 가중치를 네 요소로 나눠 보자.

관측 가중치
= 검색 적합도
× 상태 유사도
× 최신성
× 출처 신뢰도

곱셈을 쓰면 한 요소가 거의 0일 때 전체 영향도 작아진다. 반면 모든 요소를 그대로 곱하면 값이 지나치게 작아지고 한 규칙이 결과를 독점할 수 있다. 각 요소의 최솟값과 범위를 정책으로 관리해야 한다.

from datetime import date
from math import exp, log


SOURCE_WEIGHT = {
    "auction": 1.00,
    "sold_listing": 0.90,
    "active_listing": 0.45,
}

CONDITION_DISTANCE_WEIGHT = {
    0: 1.00,
    1: 0.72,
    2: 0.35,
    3: 0.10,
}


def recency_weight(age_days: int, half_life_days: int = 180) -> float:
    safe_age = max(0, age_days)
    return exp(-log(2) * safe_age / half_life_days)


def observation_weight(
    row: PriceObservation,
    query_grade: str,
    today: date,
) -> float:
    grades = {"S": 0, "A": 1, "B": 2, "C": 3}
    grade_distance = abs(grades[row.condition_grade] - grades[query_grade])

    match = max(0.05, min(1.0, row.match_score))
    condition = CONDITION_DISTANCE_WEIGHT[grade_distance]
    freshness = recency_weight((today - row.observed_on).days)
    source = SOURCE_WEIGHT[row.source]
    return match * condition * freshness * source

여기서 180일 반감기는 설명용 정책이다. 상품군에 따라 거래 가격이 빠르게 변하면 짧게, 안정적이면 길게 잡아야 한다. 중요한 점은 상수의 근거와 변경 이력을 남기는 것이다.

상관된 점수를 두 번 반영하지 않기

검색 종합 점수에 이미 텍스트·이미지 적합도가 들어갔는데 동일한 두 점수를 다시 곱하면 검색 유사도를 과도하게 강조할 수 있다. 가중치의 각 요소가 독립적으로 어떤 정보를 추가하는지 확인해야 한다.

가중치 상한도 필요하다. 동일 판매글을 여러 크롤러가 수집하거나 한 출처가 결과 대부분을 차지하면 관측 수만 많아 보인다. listing_id로 중복을 제거하고, 출처 하나가 전체 가중치의 일정 비율을 넘지 않도록 제한할 수 있다.

표본 수보다 유효 표본 수가 중요하다

검색 결과가 30건이라고 해서 독립적인 증거가 30개인 것은 아니다. 거의 같은 관측 하나에 가중치가 몰려 있거나 동일 출처의 복제 데이터일 수 있다.

가중치의 집중도를 표현할 때 유효 표본 크기(effective sample size)를 사용할 수 있다.

ESS = (Σwᵢ)² / Σ(wᵢ²)

모든 가중치가 같으면 ESS는 실제 표본 수와 같다. 한 관측의 가중치만 매우 크면 ESS는 1에 가까워진다.

def effective_sample_size(weights: list[float]) -> float:
    positive = [weight for weight in weights if weight > 0]
    if not positive:
        return 0.0

    numerator = sum(positive) ** 2
    denominator = sum(weight ** 2 for weight in positive)
    return numerator / denominator

예를 들어 표본 수가 10개여도 가중치가 [9, 1, 1, ...]처럼 한 건에 몰리면 ESS는 훨씬 작다. API에 sample_counteffective_sample_size를 모두 노출하면 ‘자료는 많지만 근거가 편향된 결과’를 구분할 수 있다.

독립성 문제는 ESS만으로 해결되지 않는다. 같은 판매글의 재수집, 한 판매자의 반복 등록, 같은 경매의 재게시도 군집으로 묶어 한 단위로 세는 것이 좋다.

def deduplicate_observations(
    rows: list[PriceObservation],
) -> list[PriceObservation]:
    best_by_listing: dict[str, PriceObservation] = {}
    for row in rows:
        current = best_by_listing.get(row.listing_id)
        if current is None or row.observed_on > current.observed_on:
            best_by_listing[row.listing_id] = row
    return list(best_by_listing.values())

가격 범위를 어떻게 정할까

대표값 옆의 범위는 두 가지로 해석될 수 있다.

  1. 과거 비교 거래 대부분이 모여 있던 구간
  2. 추정한 대표값 자체의 통계적 불확실성 구간

둘은 다르다. P20~P80은 관측 가격 분포의 중간 60%를 보여 주는 거래 범위다. 부트스트랩으로 중앙값을 반복 추정해 얻는 구간은 대표값 추정의 불확실성에 가깝다. 서비스 화면에서 둘을 모두 ‘예상 범위’라고 부르면 사용자가 혼동한다.

초기 제품에서는 관측 기반 범위가 설명하기 쉽다.

def summarize_weighted_prices(
    weighted_prices: list[tuple[int, float]],
) -> tuple[int, int, int]:
    center = weighted_quantile(weighted_prices, 0.50)
    low = weighted_quantile(weighted_prices, 0.20)
    high = weighted_quantile(weighted_prices, 0.80)
    return low, center, high

왜 P25~P75가 아니라 P20~P80인지에는 보편적인 정답이 없다. 범위를 좁히면 유용해 보이지만 실제 가격이 자주 벗어나고, 넓히면 적중률은 높지만 정보 가치가 떨어진다. 상품군과 사용 목적에 맞춰 다음 두 지표를 함께 보며 결정한다.

def interval_metrics(
    predictions: list[tuple[int, int]],
    actuals: list[int],
) -> dict[str, float]:
    covered = [low <= actual <= high for (low, high), actual in zip(predictions, actuals)]
    widths = [high - low for low, high in predictions]
    return {
        "coverage": sum(covered) / len(covered),
        "mean_width": sum(widths) / len(widths),
    }

범위는 넓히기만 하면 coverage가 올라간다. 그래서 coverage 하나만 최적화하면 ‘0원부터 1억 원’ 같은 쓸모없는 구간도 좋은 모델이 된다. 동일 coverage에서 더 좁은 구간을 만들거나, 목표 coverage를 만족하는 가장 좁은 구간을 찾는 방식으로 비교해야 한다.

신뢰도를 별도의 문제로 분리한다

가장 만들기 쉬운 신뢰도는 검색 점수 평균과 표본 수를 더한 값이다.

# 단순하지만 의미가 불명확한 예시
confidence = 0.6 * mean_match_score + 0.4 * min(sample_count / 10, 1)

이 값은 내부 품질 휴리스틱으로는 쓸 수 있지만 0.8이 무엇을 뜻하는지 설명하기 어렵다. 더 큰 문제는 0.8인 결과 중 실제로 80%가 잘 맞는다는 보장이 없다는 점이다.

신뢰도를 확률처럼 보여 주려면 먼저 성공 사건을 정의한다.

success = |추정 가격 - 실제 거래 가격| / 실제 거래 가격 <= 15%

그러면 신뢰도 0.8은 ‘비슷한 특성의 과거 예측에서 15% 오차 이내였던 비율이 약 80%’라는 해석을 가질 수 있다. 성공 기준은 제품 요구에 따라 10%, 20% 또는 절대 금액 기준으로 달라질 수 있다.

신뢰도 모델의 입력 특성은 가격 추정값과 분리한다.

특성 낮은 신뢰도의 신호
유효 표본 수 ESS가 작음
평균 검색 적합도 비교 상품이 덜 유사함
상위 후보 점수 차이 어떤 모델인지 모호함
가격 구간 상대 폭 관측 가격이 넓게 퍼짐
데이터 최신성 오래된 관측 비중이 큼
출처 다양성 한 출처에 집중됨
fallback 단계 넓은 카테고리까지 완화됨

초기에는 해석 가능한 규칙 기반 점수로 시작할 수 있다.

from dataclasses import dataclass


@dataclass(frozen=True)
class ConfidenceFeatures:
    effective_samples: float
    mean_match_score: float
    relative_interval_width: float
    recent_weight_ratio: float
    source_count: int
    fallback_level: int


def heuristic_confidence(features: ConfidenceFeatures) -> float:
    evidence = min(features.effective_samples / 8.0, 1.0)
    agreement = max(0.0, 1.0 - features.relative_interval_width)
    diversity = min(features.source_count / 3.0, 1.0)

    raw = (
        0.30 * evidence
        + 0.25 * features.mean_match_score
        + 0.20 * agreement
        + 0.15 * features.recent_weight_ratio
        + 0.10 * diversity
        - 0.10 * features.fallback_level
    )
    return max(0.0, min(1.0, raw))

이 코드는 운영 공식이 아니라 사고 과정을 보여 주는 가상 예시다. 휴리스틱으로 시작하더라도 예측 당시의 특성과 이후 확인된 실제 거래 가격을 저장해야 나중에 보정할 수 있다.

신뢰도 점수를 보정하는 방법

검증 데이터에서 휴리스틱 점수와 성공 여부를 모으면 신뢰도 구간별 실제 성공률을 확인할 수 있다.

표시 신뢰도 구간 예측 건수 실제 성공률
0.0~0.2 120 0.18
0.2~0.4 310 0.31
0.4~0.6 440 0.48
0.6~0.8 390 0.66
0.8~1.0 180 0.73

마지막 구간에서 평균 표시값이 0.88인데 실제 성공률이 0.73이라면 모델은 과신하고 있다. 이때 isotonic regression이나 Platt scaling 같은 보정 방법을 적용할 수 있다.

# scikit-learn을 사용한 개념 예시
from sklearn.isotonic import IsotonicRegression


calibrator = IsotonicRegression(out_of_bounds="clip")
calibrator.fit(
    X=validation_raw_scores,
    y=validation_success_labels,
)

calibrated = float(calibrator.predict([raw_score])[0])

시계열 데이터에서는 무작위 분할보다 과거로 학습하고 미래로 검증해야 한다. 같은 상품의 유사한 거래가 학습과 검증 양쪽에 섞이면 실제보다 보정이 잘된 것처럼 보인다.

flowchart LR
    A[과거 관측으로 가격 추정] --> B[이후 실제 거래 결과 연결]
    B --> C[허용 오차 내 성공 여부 생성]
    C --> D[시간 순서 검증셋 구성]
    D --> E[신뢰도 보정기 학습]
    E --> F[새 예측 점수 보정]
    F --> G[구간별 실제 성공률 감시]

보정 품질은 Brier score와 reliability diagram으로 볼 수 있다. 다만 전체 평균만 보면 희귀 브랜드나 표본이 적은 그룹의 실패가 숨는다. 브랜드, 가격대, fallback 단계별로도 분해한다.

confidence라는 이름의 책임

검증하지 않은 휴리스틱이라면 API 필드를 evidence_scoredata_quality_score로 부르는 편이 정직하다. confidence라고 공개하면 사용자는 자연스럽게 확률로 받아들인다.

표본이 부족할 때의 fallback

비교 거래가 부족하다고 무조건 빈 결과를 반환할 필요는 없다. 대신 검색 조건을 단계적으로 완화하고 결과에 그 사실을 포함한다.

flowchart TD
    A[동일 모델 + 동일 상태] -->|ESS 충분| R1[정상 추정]
    A -->|부족| B[동일 모델 + 인접 상태]
    B -->|ESS 충분| R2[상태 보정 추정]
    B -->|부족| C[동일 라인 + 유사 사양]
    C -->|ESS 충분| R3[넓은 비교군 추정]
    C -->|부족| N[추정 보류]

완화 단계가 올라가면 결과를 만들 수 있는 비율은 높아지지만 편향도 커진다. 따라서 fallback_level은 신뢰도를 낮추는 입력인 동시에 사용자 설명에 필요한 메타데이터다.

from dataclasses import dataclass


@dataclass(frozen=True)
class CandidatePool:
    observations: list[PriceObservation]
    fallback_level: int
    comparison_basis: str


def choose_pool(pools: list[CandidatePool]) -> CandidatePool | None:
    for pool in pools:
        weights = [max(row.match_score, 0.0) for row in pool.observations]
        if effective_sample_size(weights) >= 3.0:
            return pool
    return None

여기서 ESS 3은 설명용 값이다. 가격대가 크고 의사결정 비용이 높은 상품이라면 더 보수적으로 정해야 한다. 최종 단계에서도 근거가 부족하면 0원이나 카테고리 평균을 정상 추정값처럼 반환하지 않고 insufficient_evidence 상태를 반환한다.

API와 화면에 불확실성을 표현하기

가격 API는 숫자 세 개만 반환하기보다 계산 근거와 상태를 함께 제공하는 편이 좋다.

{
  "status": "estimated",
  "currency": "KRW",
  "estimated_price": 1180000,
  "observed_range": {
    "low": 1070000,
    "high": 1320000,
    "quantiles": [0.2, 0.8]
  },
  "confidence": {
    "score": 0.74,
    "level": "medium",
    "meaning": "within_15_percent"
  },
  "evidence": {
    "sample_count": 11,
    "effective_sample_size": 6.4,
    "source_count": 3,
    "latest_observed_on": "2026-08-08",
    "fallback_level": 0
  }
}

화면에서는 모든 통계 용어를 그대로 보여 줄 필요가 없다.

예상 거래가  약 118만 원
주요 거래 범위  107만~132만 원
신뢰도  보통

최근 비교 거래 11건을 참고했어요.
상품 상태와 구성품에 따라 실제 가격은 달라질 수 있어요.

상세 보기를 열면 비교 기준과 최근성, 범위 의미를 설명한다. 색상 하나로 신뢰도를 표현하면 접근성이 떨어지므로 높음·보통·낮음 레이블과 문장을 함께 사용한다.

내부 상태 화면 표현 행동 유도
높은 신뢰도, 좁은 범위 “비교 거래가 충분해요” 대표값 중심으로 안내
중간 신뢰도 “상태에 따라 차이가 있어요” 범위를 함께 강조
낮은 신뢰도 “참고 가능한 거래가 적어요” 추가 정보 입력 유도
근거 부족 “가격을 추정하기 어려워요” 억지 숫자 대신 재검색
범위가 곧 보장 가격은 아니다

‘예상 거래 범위’라는 표현과 함께 과거 유사 거래에 기반했다는 설명을 둔다. 특히 매입 보장가나 견적처럼 오해될 수 있는 서비스에서는 용어 검토가 필요하다.

오프라인 평가와 운영 지표

가격 추정 모델은 대표값 오차, 구간 품질, 신뢰도 보정을 따로 평가해야 한다.

대표값 평가

가격대가 넓다면 원 단위 MAE보다 비율 오차가 해석하기 쉽다.

def absolute_percentage_error(predicted: int, actual: int) -> float:
    if actual <= 0:
        raise ValueError("actual price must be positive")
    return abs(predicted - actual) / actual

구간 평가

신뢰도 평가

운영에서는 정답 가격이 즉시 생기지 않는 경우가 많다. 예측 시점의 입력과 버전을 저장해 두었다가 실제 거래 완료 시 연결해야 한다.

{
  "prediction_id": "price_pred_demo_7f3c",
  "model_version": "pricing-example-2026-08",
  "policy_version": "confidence-example-v3",
  "query_fingerprint": "fictional_hash",
  "predicted_at": "2026-08-11T10:00:00+09:00",
  "estimated_price": 1180000,
  "confidence": 0.74
}

이 정보가 없으면 신뢰도가 나빠졌을 때 데이터 변화인지 코드 배포인지 정책 변경인지 구분하기 어렵다.

구현하면서 놓치기 쉬운 경계

판매 희망가와 실제 거래가를 섞지 않는다

판매 중인 가격은 시장의 공급 신호지만 실제 지불 가격과 같지 않다. 같은 가중치로 섞으면 체계적으로 높게 추정될 수 있다. 목적에 따라 데이터셋을 분리하고 출처별 편향을 평가한다.

시간 누수를 막는다

2026년 5월 거래를 예측하는 평가에서 6월에 수집한 가격을 후보로 쓰면 미래 정보를 본 셈이다. 모든 후보는 예측 시점 이전 관측으로 제한한다.

-- 설명용 쿼리: 예측 시점 이후 관측을 제외한다.
SELECT price_krw, observed_at, source
FROM price_observations
WHERE product_group = :product_group
  AND observed_at < :prediction_time
ORDER BY observed_at DESC
LIMIT 100;

검색 실패와 가격 변동을 구분한다

구간이 넓은 이유가 정말 시장 가격 변동 때문인지, 서로 다른 모델을 잘못 묶었기 때문인지 확인해야 한다. 후보의 모델 일관성과 검색 점수 분포를 가격 분산과 함께 기록한다. 이 지점은 Casa 가격 검색에서 이미지와 텍스트 유사도 결합하기의 검색 품질과 직접 연결된다.

작은 표본의 좁은 범위를 과신하지 않는다

관측 두 건의 가격이 우연히 같으면 P20과 P80도 같아질 수 있다. 좁은 구간이 항상 높은 신뢰도를 뜻하지 않는다. ESS가 작을 때는 최소 구간 폭을 적용하거나 범위를 표시하지 않는 정책이 필요하다.

통화와 수수료 기준을 통일한다

배송비 포함 여부, 구매자 수수료, 판매자 정산액, 환율 적용 시점이 다르면 같은 price 필드라도 비교할 수 없다. 정규화된 금액과 원본 금액을 모두 보존한다.

정책 상수에 버전을 붙인다

분위수 20/80, 반감기 180일, 성공 기준 15%는 모두 제품 정책이다. 코드에 흩어진 상수로 두면 과거 결과를 재현할 수 없다. 설정 객체와 버전을 결과에 기록한다.

from dataclasses import dataclass


@dataclass(frozen=True)
class PricingPolicy:
    version: str
    low_quantile: float
    high_quantile: float
    recency_half_life_days: int
    success_tolerance_ratio: float
    minimum_effective_samples: float


POLICY = PricingPolicy(
    version="example-v3",
    low_quantile=0.20,
    high_quantile=0.80,
    recency_half_life_days=180,
    success_tolerance_ratio=0.15,
    minimum_effective_samples=3.0,
)

정리

가격 추정에서 가장 위험한 결과는 약간 틀린 숫자가 아니라, 불확실한 숫자를 확실한 정답처럼 보여 주는 것이다.

Casa 가격 검색의 결과를 설계하며 다음 원칙을 세웠다.

  1. 추정하려는 가격의 의미를 먼저 정의한다.
  2. 대표값은 이상치에 강한 가중 중앙값으로 시작한다.
  3. 관측 범위는 가중 분위수로 표현하되 coverage와 폭을 함께 평가한다.
  4. 단순 표본 수 대신 중복, 가중치 집중도, 출처 다양성을 본다.
  5. 가격 추정과 신뢰도 추정을 서로 다른 문제로 다룬다.
  6. 신뢰도를 확률처럼 노출하려면 실제 성공률에 맞게 보정한다.
  7. 근거가 부족할 때는 fallback 단계를 공개하고, 끝내 부족하면 추정을 보류한다.
  8. 화면에는 대표값만 강조하지 않고 범위와 데이터 한계를 함께 설명한다.

좋은 가격 추정 API는 숫자를 더 정밀하게 보이게 만드는 API가 아니라, 숫자가 어디까지 유효한지를 함께 전달하는 API다.

관련 노트