---
tags: [Emotion Analysis, Robeing, klue-bert, ONNX, Temperature Scaling, FastAPI, PostgreSQL]
date: 2025-09-15
modified: 2025-09-15
---

# 로빙(Roboing) 감정 그래프 시스템: 기술 구현 가이드

## 1. 시스템 개요 및 목표

### 1.1. 목표

이 문서는 로빙이 사용자의 한국어 텍스트 입력을 기반으로 감정을 분석하고, 이를 시계열 그래프로 시각화하여 제공하는 시스템의 전체 기술 사양과 구현 계획을 정의합니다. 시스템은 실시간 추론 성능, 모델 신뢰성, 데이터 프라이버시를 핵심 요건으로 삼습니다.

### 1.2. 핵심 기술 사양

-   **감정 분류**: 7개 클래스 단일 라벨 분류
    -   `fear`, `surprise`, `anger`, `sadness`, `neutral`, `happiness`, `disgust`
-   **기반 모델**: `klue/bert-base`를 fine-tuning한 한국어 모델
-   **확률 보정**: Temperature Scaling을 적용하여 모델의 신뢰도(Confidence) 보정
-   **추론 최적화**: PyTorch 모델을 ONNX로 변환하여 ONNX Runtime에서 실행
-   **데이터 저장**: PostgreSQL을 사용하여 시계열 감정 데이터 저장
-   **API**: FastAPI를 사용하여 실시간 및 배치 추론 API 제공

---

## 2. 모델 및 추론 파이프라인

### 2.1. 파이프라인 단계

1.  **입력**: 한국어 텍스트 (단건 또는 배치)
2.  **전처리**: `klue/bert-base` 토크나이저로 토큰화 (최대 길이 128)
3.  **추론**: ONNX 모델이 7개 감정에 대한 원시 로짓(logits)을 출력
4.  **보정**: 학습된 Temperature(T) 값으로 로짓을 스케일링 (`logits / T`)
5.  **확률화**: 보정된 로짓에 Softmax 함수를 적용하여 최종 확률(probabilities) 계산
6.  **후처리**: 엔트로피(불확실성 지표) 계산, 가장 높은 확률 값과 라벨 추출
7.  **결과**: `unknown` 임계값(예: 0.45) 미만일 경우, 라벨을 `unknown`으로 처리하여 낮은 신뢰도의 예측을 필터링

### 2.2. 확률 보정: Temperature Scaling

신경망 모델이 과신(overconfident)하는 경향을 완화하고, 예측 확률을 실제 정답률에 가깝게 보정하는 기법입니다.

#### 2.2.1. 개념

-   보정된 확률 `p_calibrated = softmax(logits / T)`
-   `T`는 단일 스칼라 파라미터로, 검증 데이터셋(validation set)의 NLL(Negative Log-Likelihood)을 최소화하도록 학습됩니다.
-   `T > 1` 이면 확률 분포가 부드러워져 과신이 완화되고, `T < 1` 이면 더 첨예해집니다.

#### 2.2.2. Temperature(T) 학습 코드 예시

```python
# torch >= 2.0, L-BFGS 옵티마이저 사용
import torch
import torch.nn as nn
from torch.optim import LBFGS

class TemperatureScaler(nn.Module):
    def __init__(self, init_T=1.5):
        super().__init__()
        # T를 직접 학습하는 대신 log(T)를 학습하여 T > 0을 보장
        self.log_T = nn.Parameter(torch.log(torch.tensor(init_T)))

    def forward(self, logits):
        T = torch.exp(self.log_T)
        return logits / T

def fit_temperature(valid_logits, valid_labels, init_T=1.5, max_iter=100):
    """검증 데이터셋으로 최적의 T 값을 찾습니다."""
    device = valid_logits.device
    scaler = TemperatureScaler(init_T).to(device)
    nll = nn.CrossEntropyLoss()

    optimizer = LBFGS([scaler.log_T], lr=0.1, max_iter=max_iter, line_search_fn="strong_wolfe")

    def closure():
        optimizer.zero_grad()
        scaled_logits = scaler(valid_logits)
        loss = nll(scaled_logits, valid_labels)
        loss.backward()
        return loss

    optimizer.step(closure)
    optimal_T = torch.exp(scaler.log_T).item()
    return optimal_T
```

### 2.3. 성능 최적화: ONNX 변환 및 추론

PyTorch 모델을 표준화된 ONNX 포맷으로 변환하여 CPU 및 GPU에서 높은 성능의 추론을 수행합니다.

#### 2.3.1. ONNX 변환 코드 예시

```python
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from transformers.onnx import export
from pathlib import Path
import torch

model_id = "klue/bert-base"
output_path = Path("onnx/ko_bert_emotion.onnx")
output_path.parent.mkdir(exist_ok=True)

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForSequenceClassification.from_pretrained(
    model_id,
    num_labels=7,
    problem_type="single_label_classification"
)

# ONNX export를 위한 더미 입력 생성
dummy_input = tokenizer("예시 문장", return_tensors="pt", padding="max_length", truncation=True, max_length=128)

# ONNX 모델로 변환
export(
    preprocessor=tokenizer,
    model=model,
    config=model.config,
    opset=17, # 최신 ONNX opset 버전
    output=output_path,
    tokenizer=tokenizer,
    pipeline_name="text-classification",
    feature="text-classification",
    dynamic_axes={"input_ids": {0: "batch"}, "attention_mask": {0: "batch"}} # 동적 배치 크기 지원
)
```

#### 2.3.2. ONNX Runtime 추론 엔진 코드 예시

```python
import onnxruntime as ort
import numpy as np
from transformers import AutoTokenizer
import math

EMOTIONS = ["fear", "surprise", "anger", "sadness", "neutral", "happiness", "disgust"]
TOKENIZER = AutoTokenizer.from_pretrained("klue/bert-base")

def softmax(x):
    x = x - np.max(x, axis=-1, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=-1, keepdims=True)

def entropy(p):
    p = np.clip(p, 1e-12, 1.0)
    return -np.sum(p * np.log(p), axis=-1)

class EmotionONNXEngine:
    def __init__(self, onnx_path, T, providers=None):
        sess_opts = ort.SessionOptions()
        sess_opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
        self.sess = ort.InferenceSession(onnx_path, sess_options=sess_opts, providers=providers or ort.get_available_providers())
        self.T = float(T)

    def infer(self, texts, max_length=128, threshold=0.45):
        batch = TOKENIZER(texts, return_tensors="np", padding=True, truncation=True, max_length=max_length)
        logits = self.sess.run(None, {"input_ids": batch["input_ids"], "attention_mask": batch["attention_mask"]})[0]
        
        probs = softmax(logits / self.T)
        ent = entropy(probs)
        
        top_indices = probs.argmax(axis=-1)
        top_ps = probs[np.arange(len(texts)), top_indices]
        
        labels = [EMOTIONS[i] if top_ps[j] >= threshold else "unknown" for j, i in enumerate(top_indices)]
        
        results = []
        for j in range(len(texts)):
            results.append({
                "label": labels[j],
                "probs": {EMOTIONS[k]: float(probs[j, k]) for k in range(7)},
                "entropy": float(ent[j]),
                "top_p": float(top_ps[j])
            })
        return results
```

---

## 3. 시스템 아키텍처

### 3.1. API 명세 (FastAPI 기반)

```python
from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime, timezone

app = FastAPI()
# 위에서 정의한 EmotionONNXEngine 초기화
# 예: engine = EmotionONNXEngine("onnx/ko_bert_emotion.onnx", T=1.73)

class InferRequest(BaseModel):
    text: str
    user_id: str
    source: str = "web"

@app.post("/v1/emotion/infer")
def infer_single(req: InferRequest):
    result = engine.infer([req.text])[0]
    result.update({
        "temperature": engine.T,
        "model_version": "ko-bert-emotion-0.8.2", # 버전 정보 포함
        "ts": datetime.now(timezone.utc).isoformat()
    })
    return result

# GET /v1/emotion/timeseries?user_id=...&from=...&to=...&bin=1h
# (DB에서 집계된 데이터를 반환하는 로직 구현)
```

### 3.2. 데이터베이스 스키마 (PostgreSQL)

```sql
-- 감정 분석 결과를 저장하는 시계열 테이블
CREATE TABLE emotion_readings (
  id BIGSERIAL PRIMARY KEY,
  user_id TEXT NOT NULL,
  source TEXT NOT NULL,                  -- 입력 소스 (e.g., slack, web, mail)
  ts TIMESTAMPTZ NOT NULL,             -- 분석 시점
  text_hash CHAR(64) NOT NULL,           -- 원문 텍스트의 SHA256 해시 (프라이버시 보호)
  model_version TEXT NOT NULL,           -- 사용된 모델 버전
  temperature REAL NOT NULL,             -- 적용된 T 값
  logits REAL[] NOT NULL,                -- 원시 로짓 (7차원 배열)
  probs REAL[] NOT NULL,                 -- 보정된 확률 (7차원 배열)
  top_label TEXT NOT NULL,               -- 가장 높은 확률의 감정 라벨
  top_p REAL NOT NULL,                   -- 가장 높은 확률 값
  entropy REAL NOT NULL,                 -- 불확실성 지표
  meta JSONB NOT NULL DEFAULT '{}'::jsonb -- 추가 메타데이터
);

-- 시계열 쿼리 성능을 위한 인덱스 생성
CREATE INDEX idx_emotion_readings_user_ts ON emotion_readings (user_id, ts DESC);

-- 메타데이터 검색을 위한 GIN 인덱스
CREATE INDEX idx_emotion_readings_meta ON emotion_readings USING GIN (meta);
```

---

## 4. 시각화 및 프론트엔드 연동

### 4.1. 그래프 종류

1.  **지배 감정 타임라인**: 시간 축에 따라 가장 확률이 높은 감정을 색상 블록으로 표시합니다. EMA(지수이동평균)를 적용하여 그래프를 부드럽게 만듭니다.
2.  **확률 시계열 그래프**: 7개 감정 각각의 확률 변화를 여러 개의 선으로 표시합니다. 사용자가 특정 감정선을 켜고 끌 수 있는 토글 기능을 제공합니다.
3.  **감정 분포 차트**: 일/주/월 단위로 각 감정이 차지하는 비중을 스택 영역(Stacked Area) 차트로 요약하여 보여줍니다.

### 4.2. Slack 연동 방안

-   **요약 카드**: 사용자가 요청 시, 최근 24시간의 주요 감정(상위 2개), 평균 불확실성(엔트로피), 감정 변화가 컸던 시간 등을 텍스트로 요약하여 제공합니다.
-   **그래프 이미지**: 서버에서 Matplotlib, Plotly 등으로 생성한 정적 그래프(PNG)를 이미지로 업로드하거나, 웹 대시보드 링크를 버튼 형태로 제공합니다.

---

## 5. 운영 및 거버넌스

-   **모델 모니터링**: 주기적으로 혼동 행렬(Confusion Matrix), ECE(Expected Calibration Error), NLL을 계산하여 모델 성능 저하 여부를 추적합니다.
-   **데이터 편향 모니터링**: 특정 사용자나 특정 소스에서 감정 분포가 비정상적으로 편향될 경우, 이를 감지하고 알림을 보냅니다.
-   **프라이버시**: 원문 텍스트는 암호화하여 별도 저장하거나, 해시 값만 `emotion_readings` 테이블에 저장하여 개인정보 노출을 최소화합니다.
-   **데이터 삭제 권리**: 사용자가 자신의 데이터 삭제를 요청할 경우, 관련 `user_id`의 모든 기록을 삭제할 수 있는 절차와 API를 마련해야 합니다.

---

## 6. 1주 스프린트 구현 계획

1.  **1일차**: 모델 가중치 확정, 검증 세트 준비, 최적의 `T` 값 학습 및 확정.
2.  **2일차**: ONNX 변환 및 `EmotionONNXEngine` 클래스 구현, 단건 추론 API (`/v1/emotion/infer`) 개발.
3.  **3일차**: PostgreSQL 테이블 스키마(`emotion_readings`) 정의 및 마이그레이션 스크립트 작성, 추론 결과를 DB에 저장하는 로직 구현.
4.  **4일차**: 시계열 집계 API (`/v1/emotion/timeseries`) 개발 및 데이터 집계 로직(Pandas/SQL) 구현.
5.  **5일차**: Matplotlib 등을 이용한 기본 그래프(지배 감정 타임라인) 렌더러 개발.
6.  **6일차**: Slack 연동 로직 개발 (요약 카드 텍스트 생성 및 그래프 이미지 업로드).
7.  **7일차**: 부하 테스트, API 문서화, 운영 모니터링 대시보드(ECE, NLL) 구성.
