MINERVA Phase C-4 — 사용자 세그멘테이션 (부서·역할·행동·토픽 코호트)

Contextual Bandit의 context, 개인화의 전제, Simpson’s paradox 방어 — 세 역할을 동시에 한다

Contextual Bandit(C16)의 context feature, 개인화 전략(C18)의 전제, A/B 분석(22편)에서 Simpson’s paradox 방어 — 세 가지 모두가 사용자를 잘 분류했음을 가정한다. 본 편은 세그멘테이션의 4가지 차원 (명시적 속성·행동 기반·토픽 기반·시간 기반), 클러스터링 알고리즘 선택, 세그먼트 안정성 평가(silhouette·ARI), 운영 관리(라벨링·갱신·콜드 사용자), 자주 발생 함정을 정리한다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 06일

1 왜 세그멘테이션이 필요한가

세그멘테이션은 단독 기능이 아니라 세 시스템이 공통으로 의존하는 토대다.

의존 시스템 세그먼트 사용처
C15 A/B 심화 Simpson’s paradox 방어 — 세그먼트별 분석으로 전체 평균에 가려진 효과 발견
C16 Contextual Bandit LinUCB·Logistic TS의 context feature — 세그먼트 one-hot 또는 임베딩
C18 개인화 전략 세그먼트별 프롬프트·응답 스타일·지식 범위 분기

세그멘테이션이 부정확하면 세 시스템이 모두 손실을 본다 — Bandit이 잘못된 context로 학습, A/B가 가짜 세그먼트별 효과를 보고, 개인화가 엉뚱한 사용자에 적용된다. 본 편은 그래서 분류 자체의 품질에 집중한다.

2 4가지 세그멘테이션 차원

세그멘테이션 차원
├── 명시적 속성 (Attribute-based)
│   └── 부서·역할·직급·지역 — DB에서 직접
├── 행동 기반 (Behavior-based)
│   └── 사용 빈도·세션 길이·기능 사용 패턴 — 로그 집계
├── 토픽 기반 (Topic-based)
│   └── 질의 토픽 분포·도메인 키워드 — 임베딩 + 클러스터링
└── 시간 기반 (Lifecycle-based)
    └── 신규/활성/이탈 위험/이탈 — RFM 또는 cohort

차원이 직교하지 않다 — 한 사용자에 여러 세그먼트 라벨이 동시 부착된다 (예: “Sales 부서 + heavy user + 영업제안 토픽 + 활성”). 따라서 단일 세그먼트 ID가 아니라 feature 벡터로 다루는 것이 운영 유연성에 좋다.

3 차원 1 — 명시적 속성

가장 단순하고 안정적. DB의 사용자 메타데이터를 그대로 사용.

# app/segments/explicit.py
from pydantic import BaseModel
from enum import Enum


class Department(str, Enum):
    sales = "sales"
    rnd = "rnd"
    support = "support"
    finance = "finance"


class ExplicitSegment(BaseModel):
    user_id: str
    department: Department
    role: str                            # "manager"·"engineer"·"analyst"
    seniority: int                        # 0~5 (junior~executive)
    region: str                           # "kr-seoul"·"us-east"


def derive_explicit_segment(user_record: dict) -> ExplicitSegment:
    return ExplicitSegment(
        user_id=user_record["id"],
        department=user_record["department"],
        role=user_record["role"],
        seniority=user_record.get("seniority_level", 0),
        region=user_record.get("region", "unknown"),
    )

강점: 즉시 사용 가능, 라벨 명확, 콜드 사용자도 즉시 분류. 약점: 같은 부서 안에서도 행동이 천차만별 — 세분화 부족.

4 차원 2 — 행동 기반

로그를 집계해 RFM(Recency·Frequency·Monetary) 같은 행동 feature 추출.

# app/segments/behavior.py
from datetime import datetime, timedelta
import numpy as np


def behavior_features(user_id: str, sessions: list[dict],
                       lookback: timedelta = timedelta(days=90)) -> dict:
    cutoff = datetime.utcnow() - lookback
    recent = [s for s in sessions if s["ended_at"] > cutoff]

    if not recent:
        return {"is_cold": True}

    return {
        "queries_per_week": len(recent) / (lookback.days / 7),
        "avg_session_length_min": np.mean([s["duration_sec"] / 60 for s in recent]),
        "thumbs_up_rate": np.mean([s.get("thumbs_up", 0) for s in recent]),
        "feedback_rate": np.mean([1 if s.get("has_feedback") else 0 for s in recent]),
        "p95_latency_tolerance": np.percentile([s["latency_ms"] for s in recent], 95),
        "is_cold": False,
    }

이 feature를 클러스터링하거나 직접 buckets:

def bucket_frequency(qpw: float) -> str:
    if qpw < 1: return "occasional"
    if qpw < 5: return "regular"
    if qpw < 20: return "active"
    return "heavy"

운영에서는 단순 bucket이 클러스터링보다 안정적이다 — 라벨이 명확하고 변동성 낮음.

5 차원 3 — 토픽 기반

질의 임베딩을 평균/모드 → 사용자별 “관심 토픽” 벡터.

# app/segments/topic.py
import numpy as np
from sentence_transformers import SentenceTransformer


def user_topic_centroid(queries: list[str], model: SentenceTransformer) -> np.ndarray:
    if not queries:
        return None
    emb = model.encode(queries, normalize_embeddings=True)
    return emb.mean(axis=0)              # 정규화 후 평균


# 모든 사용자의 centroid를 K-means로 클러스터
from sklearn.cluster import KMeans

centroids = np.stack([user_topic_centroid(q, model) for q in user_queries])
km = KMeans(n_clusters=8, random_state=42).fit(centroids)
topic_segment = km.labels_                # 사용자별 토픽 클러스터 ID

각 클러스터의 대표 키워드로 라벨링 — TF-IDF 상위 또는 LLM 요약:

def label_topic_cluster(cluster_queries: list[str]) -> str:
    sample = "\n".join(cluster_queries[:50])
    prompt = f"다음 질의들의 공통 주제를 한 단어 라벨로 요약: {sample}"
    return llm_summarize(prompt)

강점: 명시적 부서·역할이 못 잡는 미세 그룹 발견. 약점: - 임베딩 모델 의존 — 모델 교체 시 라벨 안정성 깨짐 - 사용자 신규/이탈 시 centroid 갱신 필요 - 라벨이 모호 (“technical issues” vs “product question” 경계)

6 차원 4 — 시간 기반 (Lifecycle)

# app/segments/lifecycle.py
def lifecycle_stage(user: dict) -> str:
    days_since_signup = (datetime.utcnow() - user["created_at"]).days
    days_since_last = (datetime.utcnow() - user["last_active"]).days

    if days_since_signup < 14: return "onboarding"
    if days_since_last > 60: return "churned"
    if days_since_last > 21: return "at_risk"
    if user["queries_per_week"] >= 20: return "power"
    return "active"

LLM 도구의 onboarding은 특히 중요 — 신규 사용자에게 templates·예시 질의를 더 많이 보여주는 개인화의 토대.

7 클러스터링 알고리즘 선택

알고리즘 적합
K-means 토픽 임베딩 (구형 클러스터, 차원 100~1000)
Hierarchical 작은 N (<10K), 덴드로그램으로 적정 K 시각 결정
GMM 클러스터 크기·밀도 다른 경우, soft assignment
HDBSCAN 클러스터 수 자동, 노이즈 탐지
Gaussian Mixture + EM 확률적 멤버십이 필요한 경우 (Bandit weighting)
# 일반적 패턴 — K-means + silhouette로 K 결정
from sklearn.metrics import silhouette_score

scores = {}
for k in range(2, 15):
    km = KMeans(n_clusters=k, random_state=42, n_init=10).fit(X)
    scores[k] = silhouette_score(X, km.labels_)

best_k = max(scores, key=scores.get)

대부분의 운영에서는 8~12개 세그먼트가 라벨링·해석·라우팅 운영에 적당하다. 너무 많으면 small-segment overfitting (다음 함정 절).

8 세그먼트 안정성 평가

세그먼트는 시간이 지나도 같은 사용자가 같은 클러스터에 머물러야 신뢰할 수 있다.

# app/segments/stability.py
from sklearn.metrics import adjusted_rand_score


def cohort_stability(labels_t1: dict, labels_t2: dict) -> float:
    """t1·t2 두 시점의 라벨링 일치도 (Adjusted Rand Index)."""
    common = set(labels_t1) & set(labels_t2)
    a = [labels_t1[u] for u in common]
    b = [labels_t2[u] for u in common]
    return adjusted_rand_score(a, b)


# 매 분기 점검 — ARI > 0.7이면 안정적
ari = cohort_stability(snapshot_q3, snapshot_q4)
assert ari > 0.7, f"Segment drift detected: ARI={ari:.2f}"

ARI < 0.7이면: - 클러스터링 시드를 고정해도 갱신 시점마다 라벨이 다르게 부착됨 - 운영 시스템(라우팅·개인화)이 매 갱신마다 사용자 처치를 바꿔 신규 노이즈 발생

해법: - Anchored clustering — 이전 시점 centroid를 새 K-means 초기값으로 - Hungarian matching — 새 클러스터를 이전 라벨로 매칭 - 고정 라벨 정의 — 데이터에서 발견하지 않고 비즈니스가 정의 (부서·역할 같은 명시적 차원 우선)

9 세그먼트 운영 — 라벨링·갱신·콜드 사용자

9.1 라벨 스키마

-- segments 테이블
CREATE TABLE segments (
    user_id VARCHAR(64) PRIMARY KEY,
    department VARCHAR(32),
    role VARCHAR(64),
    behavior_bucket VARCHAR(32),         -- occasional/regular/active/heavy
    topic_cluster INT,
    topic_label VARCHAR(64),
    lifecycle_stage VARCHAR(32),
    feature_vector VECTOR(64),           -- Bandit context용 임베딩
    updated_at TIMESTAMP DEFAULT NOW()
);

명시적 차원과 자동 추론 차원을 모두 한 행에 보관. Bandit은 feature_vector 또는 one-hot 결합을 사용.

9.2 갱신 주기

차원 갱신 주기 이유
명시적 (부서·역할) DB 변경 시 즉시 HR 시스템 webhook 또는 일일 sync
행동 (queries_per_week) 일일 7~30일 rolling window
토픽 클러스터 주간 임베딩 재계산 비용
Lifecycle (active·at_risk) 일일 churn 예측의 즉시성
Centroid 자체 분기 안정성 확보

매 갱신 시 이전 세그먼트와 비교해 차이가 큰 사용자만 라우팅 시스템에 통보 (incremental update).

9.3 콜드 사용자

가입 후 N일 미만 또는 질의 수 < threshold:

def assign_segment(user: dict, history: list[dict]) -> dict:
    explicit = derive_explicit_segment(user)
    if len(history) < 5 or user["age_days"] < 7:
        return {**explicit.dict(), "is_cold": True, "lifecycle_stage": "onboarding"}

    behavior = behavior_features(user["id"], history)
    return {**explicit.dict(), **behavior, "is_cold": False}

콜드 사용자는: - Bandit context에서 명시적 feature만 사용 (행동·토픽은 0 또는 default) - 개인화는 부서·역할 기반 fallback - A/B 분석 시 별도 세그먼트로 — 일반 분석에 섞이면 noise

10 MINERVA 적용 — 세그먼트와 Bandit 통합

# app/routing/contextual_bandit.py
import numpy as np

def context_vector(segment: dict) -> np.ndarray:
    """세그먼트 dict → Bandit context 벡터."""
    return np.concatenate([
        one_hot(segment["department"], DEPARTMENTS),       # 4 dim
        one_hot(segment["role"], ROLES),                    # 8 dim
        one_hot(segment["behavior_bucket"], BUCKETS),       # 4 dim
        one_hot(segment["topic_cluster"], range(8)),         # 8 dim
        [segment.get("queries_per_week", 0) / 50],           # 1 dim normalized
        [int(segment.get("is_cold", False))],                # 1 dim
    ])                                                        # 총 26 dim


# C16의 LinUCB와 결합
from app.routing.bandit_router import LinUCB

router = LinUCB(n_arms=4, n_features=26)

def route_query(user_id: str, query: str) -> str:
    segment = load_segment(user_id)
    ctx = context_vector(segment)
    arm = router.select(ctx)
    return ARMS[arm]                                          # reranker_id

LinUCB가 자동으로 어떤 차원이 어느 arm에 영향력이 큰지 학습. “Sales 부서 + heavy user → reranker A 선호” 같은 패턴이 자동 발견된다.

11 자주 발생하는 함정

11.1 Small-segment overfitting

20명짜리 세그먼트에서 reranker A가 thumbs_up_rate 80% — 통계적 노이즈. 22편의 power analysis가 여기서도 적용된다.

def is_segment_significant(n_users: int, mde: float = 0.05) -> bool:
    from app.experiments.sample_size import required_sample_per_arm
    required = required_sample_per_arm(p_baseline=0.55, mde=mde)
    return n_users >= required


# 운영
for seg_id, users in segments.items():
    if not is_segment_significant(len(users)):
        # 작은 세그먼트는 상위 세그먼트로 fallback
        ...

11.2 Label leakage

세그먼트 라벨을 만들 때 target 메트릭(thumbs_up)을 feature로 쓰면 라우팅 평가가 자기 참조가 된다.

# WRONG
features = [..., thumbs_up_rate]    # target leakage
clusters = KMeans(...).fit(features)
# 이 segment로 다시 thumbs_up rate를 평가 → 부풀려진 효과

해법: target 메트릭은 segment feature에서 제외하거나 사용 시점을 사전·사후로 명시 분리.

11.3 Shifting cohort

매 갱신 시 사용자가 다른 세그먼트로 이동 → A/B 분석에서 “treatment 그룹의 평균이 실은 다른 사람들”이 됨.

해법: A/B 실험 동안 세그먼트를 freeze — 실험 시작 시점의 라벨을 사용자 ID에 할당하고 실험 종료까지 유지.

11.4 Naming drift

토픽 클러스터의 LLM 라벨이 갱신마다 약간 달라짐 (“billing question” → “payment inquiry”). 운영팀·대시보드 혼란.

해법: - 라벨은 사람이 한번 결정한 후 고정 - 클러스터 재훈련해도 라벨 사전 매핑 유지 - Hungarian matching으로 새 클러스터 → 기존 라벨

11.5 차원 폭발

세그먼트를 너무 많이 (“부서×역할×토픽×lifecycle = 4×8×8×4 = 1024 셀”). 셀당 사용자 수 → 통계 무의미.

해법: Bandit context 벡터에는 모든 차원을 넣되 세그먼트 ID는 고수준 8~12개만. 미세 분기는 Bandit이 학습.

12 정리

영역 핵심
차원 4가지 명시적·행동·토픽·시간 — 직교 아님, feature 벡터로 통합
명시적 DB 메타 — 안정·즉시 사용
행동 RFM bucket — 단순 bucket이 클러스터링보다 안정
토픽 임베딩 + K-means + LLM 라벨링
Lifecycle onboarding·active·at_risk·churned (RFM 일종)
클러스터링 K-means + silhouette → 8~12개 적정
안정성 ARI > 0.7 점검, anchored clustering·Hungarian matching
운영 갱신 주기 차원별 차등, 콜드 사용자 fallback
Bandit 통합 context 벡터로 변환, LinUCB가 차원 가중 학습
함정 Small-segment·Label leakage·Shifting cohort·Naming drift·차원 폭발

13 응용 분야

시나리오 차원 조합
Contextual Bandit context 명시적 + 행동 + lifecycle (3차원 결합)
A/B 세그먼트별 분석 명시적 + 행동 (Simpson’s paradox 방어)
개인화 프롬프트 분기 명시적 + 토픽 (부서별 응답 스타일 + 토픽별 지식)
Onboarding 흐름 lifecycle (onboarding·active 분기)
Churn 예측 행동 + lifecycle (at_risk 자동 추출)
신규 모델 도입 시 작은 그룹 시험 명시적 (R&D 부서로 우선 expose)

14 관련 주제

선행 학습 (선수)

18-LangGraph 시리즈 cross-reference

  • #10 프롬프트 분류와 라우팅 — 토픽 분류 결과를 세그먼트로 사용
  • #18 스킬 라우팅 확장성 — 세그먼트별 스킬 풀 분리

후속 (Phase C-4)

Subscribe

Enjoy this blog? Get notified of new posts by email: