1강 — 확률과 신뢰도 해석
🎯 학습 목표
- AI가 함께 내놓는 확률값(confidence)이 무슨 뜻인지 해석할 수 있다
- 신뢰도 임계값(threshold)을 정해 결과를 "채택 / 보류"로 필터링할 수 있다
- 임계값을 올리고 내릴 때의 트레이드오프를 이해하고 상황에 맞게 정할 수 있다
- 애매한(불확실한) 결과를 전략적으로 처리하는 4가지 방법을 안다
📖 해설
1. 분석은 끝났는데, 이 결과 다 믿어도 될까요?
지난 프로젝트(24차시)에서 우리는 대량 분석 파이프라인을 완성했습니다. 배치 처리와 에러 핸들링으로 안전하게 돌렸고, 체크포인트로 중간 저장까지 해서 실패해도 이어서 실행할 수 있게 만들었죠. 그렇게 리뷰 1,000개를 분석한 결과가 손에 들어왔습니다. 그중 650개가 "긍정"으로 분류됐다고 해 봅시다.
여기서 질문 하나 드리겠습니다. 이 결과, 다 믿어도 될까요? 실무에서 팀장님이 "분석 결과 어때?" 하고 묻습니다. "긍정 650개입니다" 하고 그냥 보고해도 될까요? 만약 나중에 확인했더니 그중 틀린 게 꽤 많았다면요? 보고한 사람의 신뢰가 흔들립니다.
리뷰 몇 개를 직접 보면 감이 옵니다.
| 리뷰 | AI 분류 | 정말 확실할까? |
|---|---|---|
| "게임 재밌어요! 강추합니다!" | 긍정 | 거의 확실해 보임 |
| "뭐… 할만해요" | 긍정 | 글쎄, 애매한데? |
| "기대했는데 생각보다…" | 긍정 | 이게 정말 긍정 맞나? |
세 개 다 "긍정"이라고 나왔지만, 우리가 느끼는 확신의 정도는 완전히 다릅니다. 오늘의 핵심이 바로 여기 있습니다. AI도 모든 답변에 똑같이 확신하는 게 아닙니다. 어떤 건 확실하고, 어떤 건 "아마도"입니다. 이 확신의 정도를 알 수 있다면, 믿을 결과와 의심할 결과를 가려낼 수 있겠죠.
2. AI의 확신 정도 = 확률(confidence)
AI의 확신 정도를 숫자로 표현한 것을 확률(confidence)이라고 부릅니다. 어렵게 생각할 것 없습니다. 그냥 AI가 얼마나 자신 있어 하는지를 0%에서 100% 사이의 숫자로 나타낸 것입니다.
- 95% → "이건 거의 확실해요!"
- 70% → "아마 이게 맞을 거예요"
- 52% → "글쎄요… 반반인데요?" (동전 던지기 수준)
참고로 현실에서 confidence가 정확히 100%인 경우는 거의 없습니다. 아주 확실해도 0.99 정도죠. 핵심은 이겁니다. 같은 "긍정"이라도 95% 긍정과 52% 긍정은 완전히 다릅니다. 95%는 정말 확신하는 거고, 52%는 사실 부정인데 긍정으로 흘러나왔을 수도 있는 위태로운 결과입니다.
일기 예보로 비유해 봅시다. 내일 비 올 확률이 90%면 우산을 꼭 챙기죠. 안 챙기면 후회합니다. 50%면 챙길까 말까 고민됩니다. 10%면 안 챙겨도 될 것 같고요. AI 분석도 똑같습니다. "긍정, 확률 90%"라고 하면 믿어도 되지만, "긍정, 확률 52%"라고 하면 한 번 의심해 봐야 합니다. 확률에 따라 다르게 대응해야 한다는 거죠.
그럼 낮은 확률 결과를 그냥 쓰면 어떻게 될까요? 52% 긍정을 그냥 "긍정"으로 처리하면 세 가지 문제가 생깁니다.
- 보고서 신뢰도 하락 — 실제로는 부정인데 긍정으로 잘못 분류됩니다.
- 의사결정 오류 — 유저들이 불만인데 "긍정적이네요!"라고 보고하게 됩니다.
- 비용 낭비 — 잘못된 인사이트로 잘못된 전략을 세웁니다.
3. 확률값을 출력하게 만들기
그런데 AI는 가만히 두면 확률값을 알려주지 않습니다. 우리가 프롬프트에서 "확신 정도도 함께 출력해줘"라고 요청해야 합니다. 지금까지 쓰던 감성 분석 프롬프트에 confidence 필드 하나를 추가하는 겁니다.
한 가지 관례가 있습니다. 확률은 보통 0.0 ~ 1.0 사이의 소수로 출력하게 합니다. 우리가 일상에서 "90%"라고 말하는 걸, 머신러닝·AI 분석 영역에서는 0.9로 다룹니다. 사람에게 보고할 때만 × 100을 해서 퍼센트로 바꿔 보여주면 됩니다.
# ① 라이브러리 설치 & API 준비 (이 강의 첫 셀에서 한 번만)
!pip install openai -q
import json
import re
import numpy as np
import matplotlib.pyplot as plt
from collections import Counter
from google.colab import userdata # Colab Secrets에 저장한 키를 불러옴(코드에 키를 직접 쓰지 않기 위함)
from openai import OpenAI
# 그래프에서 음수 부호가 깨지지 않도록 하는 설정(한글 폰트가 약한 matplotlib 보완)
plt.rcParams['font.family'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
# ② 모델을 맨 앞에서 한 번만 정해두면 아래 모든 함수가 이 모델을 씁니다.
# OpenRouter에는 모델이 아주 많으니, 이 줄만 바꿔 다른 모델로 실험해 보세요.
MODEL = "mistralai/devstral-2512:free"
api_key = userdata.get('OPENROUTER_API_KEY')
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=api_key)
# ③ LLM이 JSON을 코드블록(```)으로 감싸 보낼 때가 있어, 순수 JSON만 뽑아내는 헬퍼
def clean_json_response(text):
"""응답 문자열에서 ```json ... ``` 같은 군더더기를 걷어내고 JSON 본문만 남긴다."""
text = text.strip()
if '```json' in text:
m = re.search(r'```json\s*([\s\S]*?)\s*```', text)
if m:
return m.group(1).strip()
if '```' in text:
m = re.search(r'```\s*([\s\S]*?)\s*```', text)
if m:
return m.group(1).strip()
m = re.search(r'(\{[\s\S]*\}|\[[\s\S]*\])', text) # 중괄호/대괄호 덩어리를 직접 탐색
return m.group(1).strip() if m else text
# ④ 호출이 반복되니 함수로 묶어 재사용성을 높입니다.
def call_llm(system_prompt, user_message):
"""LLM에 system+user 메시지를 보내고 텍스트 응답을 받는다."""
res = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
)
return res.choices[0].message.content
def call_llm_json(system_prompt, user_message):
"""LLM 응답을 JSON(dict)으로 파싱해서 돌려준다."""
return json.loads(clean_json_response(call_llm(system_prompt, user_message)))
print("준비 완료!")
# 확률값(confidence)을 함께 출력하도록 요청하는 프롬프트
# 핵심은 '판단 결과'뿐 아니라 '그 판단을 얼마나 확신하는지'를 숫자로 받는 것
SYSTEM_PROMPT_WITH_CONFIDENCE = """너는 게임 리뷰 감성 분석 전문가야.
리뷰를 분석해서 다음 JSON 형식으로 응답해줘:
{
"sentiment": "POSITIVE" 또는 "NEGATIVE" 또는 "NEUTRAL",
"confidence": 0.0~1.0 사이의 확률값 (네가 얼마나 확신하는지),
"reason": "판단 근거 한 줄"
}
confidence 기준:
- 0.9 이상: 매우 확실함
- 0.7~0.9: 꽤 확실함
- 0.5~0.7: 애매함
- 0.5 미만: 거의 찍는 수준
솔직하게 확신 정도를 표현해줘. 애매하면 낮은 confidence를 줘도 돼.
"""
# 명확한 것부터 애매한 것까지 일부러 섞은 테스트 리뷰
test_reviews = [
"게임 정말 재밌어요! 강추합니다!", # 명확한 긍정
"최악의 게임... 돈 아깝다", # 명확한 부정
"뭐... 할만해요", # 애매함
"기대했는데 생각보다...", # 미완성 문장, 부정 뉘앙스
"그냥 그래요. 나쁘지도 좋지도 않고", # 중립
]
print("=== 확률값 포함 분석 테스트 ===\n")
for review in test_reviews:
result = call_llm_json(SYSTEM_PROMPT_WITH_CONFIDENCE, f"리뷰: {review}")
conf_pct = result['confidence'] * 100 # 사람이 읽기 쉽게 퍼센트로 변환
print(f"리뷰: {review}")
print(f" → {result['sentiment']} ({conf_pct:.0f}% 확신) / 이유: {result['reason']}\n")
명확한 긍정·부정 리뷰는 90% 안팎으로 높게, "할만해요"나 "그냥 그래요" 같은 애매한 리뷰는 그보다 낮게 나오는 경향을 볼 수 있습니다. (실제 수치는 모델·실행마다 조금씩 다릅니다.) 중요한 건 판단 결과 옆에 확신의 정도가 숫자로 붙었다는 점입니다.
4. 신뢰도 임계값(threshold) 정하기
확률값이 생겼으니, 이제 "이 정도 확률 이상이면 믿겠다"라는 기준선을 정합니다. 이 기준선을 신뢰도 임계값(confidence threshold)이라고 합니다. 예를 들어 임계값을 70%로 정하면, 확률 70% 이상은 채택(confident)하고 70% 미만은 보류(uncertain)합니다. 이렇게 하면 확실한 결과만 보고서에 들어가고, 애매한 건 따로 검토할 수 있어 전체 품질이 올라갑니다.
그럼 임계값을 높게 잡을까요, 낮게 잡을까요? 여기에 트레이드오프가 있습니다. 한쪽을 얻으면 다른 쪽을 잃는 관계죠.
- 높게(예: 90%) → 확실한 것만 통과하니 정확도는 올라가지만, 채택되는 결과가 적어 많은 데이터가 버려집니다.
- 낮게(예: 50%) → 데이터 손실은 적지만, 오류 가능성이 커져 보고서 신뢰도가 떨어집니다.
시험 합격 커트라인과 똑같습니다. 커트라인을 90점으로 높게 잡으면 합격자는 적지만 실력은 확실합니다(대신 인원이 부족할 수 있죠). 60점으로 낮게 잡으면 합격자는 많지만 실력 편차가 큽니다. 정답은 없습니다. 목적에 맞게 정하는 문제입니다.
실무에서는 대략 이런 기준을 씁니다.
| 상황 | 추천 임계값 | 설명 |
|---|---|---|
| 경영진 보고서 | 80% 이상 | 틀리면 안 되는 상황 |
| 일반 분석 | 70% (기본값) | 균형 잡힌 기본값, 가장 많이 씀 |
| 탐색 / 트렌드 파악 | 60% | 대략적 경향만 보면 될 때 |
| 연구 / 실험 | 상황에 따라 | 목적에 맞게 조정 |
코드로는 결과 리스트를 돌면서 임계값을 넘으면 confident, 못 넘으면 uncertain으로 나누기만 하면 됩니다.
def filter_by_confidence(results, threshold=0.7):
"""분석 결과를 신뢰도 기준으로 '채택(confident)'과 '보류(uncertain)'로 분류."""
confident, uncertain = [], []
for r in results:
if r.get('confidence', 0) >= threshold: # 임계값 이상이면 채택
confident.append(r)
else: # 미달이면 보류
uncertain.append(r)
ratio = len(confident) / len(results) * 100 if results else 0
return {
'confident': confident,
'uncertain': uncertain,
'stats': {'total': len(results), 'confident': len(confident),
'uncertain': len(uncertain), 'confident_ratio': ratio},
}
# 실제 대량 분석 결과 대신, 확인용 샘플 데이터로 동작을 봅니다
sample_results = [
{"review_id": 1, "sentiment": "POSITIVE", "confidence": 0.95},
{"review_id": 2, "sentiment": "NEGATIVE", "confidence": 0.88},
{"review_id": 3, "sentiment": "POSITIVE", "confidence": 0.72},
{"review_id": 4, "sentiment": "NEUTRAL", "confidence": 0.65},
{"review_id": 5, "sentiment": "POSITIVE", "confidence": 0.52},
{"review_id": 6, "sentiment": "NEGATIVE", "confidence": 0.91},
{"review_id": 7, "sentiment": "POSITIVE", "confidence": 0.78},
{"review_id": 8, "sentiment": "NEUTRAL", "confidence": 0.45},
{"review_id": 9, "sentiment": "NEGATIVE", "confidence": 0.83},
{"review_id": 10, "sentiment": "POSITIVE", "confidence": 0.59},
]
filtered = filter_by_confidence(sample_results, threshold=0.7)
s = filtered['stats']
print(f"전체 {s['total']}개 → 채택 {s['confident']}개({s['confident_ratio']:.0f}%) / 보류 {s['uncertain']}개\n")
print("보류된 결과(추가 검토 필요):")
for r in filtered['uncertain']:
print(f" ID {r['review_id']}: {r['sentiment']} ({r['confidence']*100:.0f}%)")
규모를 키워 생각하면 감이 옵니다. 리뷰 1,000개를 70%로 거르면 confident 820개·uncertain 180개처럼 나뉘죠. 이 180개가 따로 관리할 대상입니다.
5. 임계값을 바꿔보며 트레이드오프 체감하기
말로만 트레이드오프를 듣는 것보다, 임계값을 여러 개로 바꿔 채택률이 어떻게 변하는지 직접 보면 감이 확실히 옵니다. 임계값을 올릴수록 채택되는 결과가 줄어드는 걸 눈으로 확인할 수 있습니다.
print(f"{'임계값':^8} | {'채택':^6} | {'보류':^6} | {'채택률':^8}")
print("-" * 36)
for threshold in [0.5, 0.6, 0.7, 0.8, 0.9]:
s = filter_by_confidence(sample_results, threshold=threshold)['stats']
print(f"{threshold*100:^7.0f}% | {s['confident']:^6} | {s['uncertain']:^6} | {s['confident_ratio']:^7.0f}%")
임계값 50%에서는 거의 다 통과하지만, 90%로 올리면 정말 확실한 몇 개만 남습니다. "정확도를 얻으면 데이터 양을 잃는다"는 트레이드오프가 표 하나로 보이는 거죠.
6. 확률 분포로 프롬프트 건강검진
한 걸음 더 나아가, 모든 결과의 확률값을 모아서 전체 경향을 보면 우리가 만든 프롬프트가 잘 작동하는지 진단할 수 있습니다. 이 전체 경향을 확률 분포라고 합니다. 히스토그램(값의 분포를 막대로 그린 그래프)으로 그려 보면 패턴이 보입니다.
| 분포 패턴 | 의미 |
|---|---|
| 대부분 80% 이상에 몰림 | AI가 확신하며 답함 → 프롬프트가 잘 설계됨 |
| 50~60%에 많이 분포 | AI가 자주 헷갈림 → 프롬프트 개선 필요 |
| 양극단(높음 + 낮음)으로 나뉨 | 쉬운 케이스와 어려운 케이스가 섞여 있음 |
# 실제 분석에서 자주 보이는 '양봉(두 봉우리) 분포'를 시뮬레이션해 봅니다
np.random.seed(42)
confident_samples = np.random.normal(0.85, 0.08, 70) # 확신 높은 그룹 70개
uncertain_samples = np.random.normal(0.55, 0.10, 30) # 애매한 그룹 30개
all_conf = np.clip(np.concatenate([confident_samples, uncertain_samples]), 0, 1) # 0~1로 제한
plt.figure(figsize=(10, 6))
plt.hist(all_conf * 100, bins=20, edgecolor='black', alpha=0.7, color='steelblue')
plt.axvline(x=70, color='red', linestyle='--', linewidth=2, label='Threshold (70%)') # 임계값 선
plt.xlabel('Confidence (%)'); plt.ylabel('Count')
plt.title('Confidence Distribution (Simulated 100 Reviews)')
plt.legend(); plt.grid(axis='y', alpha=0.3)
plt.tight_layout(); plt.show()
print(f"평균 confidence: {np.mean(all_conf)*100:.1f}% / 표준편차: {np.std(all_conf)*100:.1f}%")
70% 선을 기준으로 오른쪽(채택)과 왼쪽(보류)에 결과가 어떻게 쌓이는지 한눈에 보입니다. 만약 막대들이 50~60% 근처에만 잔뜩 몰려 있다면, "프롬프트를 손봐야겠다"는 신호로 읽으면 됩니다.
7. 애매한 결과는 어떻게 처리할까요?
임계값에 걸려 보류된(uncertain) 결과들, 그냥 버리긴 아깝습니다. 처리 전략은 네 가지가 있고, 상황에 따라 고릅니다.
| 전략 | 설명 | 언제 쓰나 |
|---|---|---|
| 수동 검토 | 사람이 직접 읽고 분류 | 결과 수가 적거나, 정확도가 매우 중요할 때 |
| 재분석 | 프롬프트를 바꿔 다시 분석 | 특정 유형에서 실패가 반복될 때 |
| 별도 카테고리 | "불확실"로 따로 분류해 보고 | 불확실성 자체가 인사이트일 때 |
| 제외 | 분석에서 빼고 진행 | 전체 샘플이 충분히 클 때(몇 개 빠져도 영향 적음) |
def process_uncertain_results(uncertain_results, strategy='separate'):
"""보류된 결과를 전략에 따라 처리. separate=별도 분류 / flag=검토 플래그 / exclude=제외"""
if strategy == 'exclude':
return {"action": "excluded", "count": len(uncertain_results)}
if strategy == 'separate':
for r in uncertain_results:
r['original_sentiment'] = r.get('sentiment', 'UNKNOWN')
r['sentiment'] = 'UNCERTAIN' # 별도 카테고리로 표시
return uncertain_results
if strategy == 'flag':
for r in uncertain_results:
r['needs_review'] = True # '사람이 봐야 함' 표시만 달기
return uncertain_results
return uncertain_results
uncertain_samples = [
{"review_id": 4, "sentiment": "NEUTRAL", "confidence": 0.65},
{"review_id": 5, "sentiment": "POSITIVE", "confidence": 0.52},
{"review_id": 8, "sentiment": "NEUTRAL", "confidence": 0.45},
]
print("[제외 전략]", process_uncertain_results([dict(r) for r in uncertain_samples], 'exclude'))
flagged = process_uncertain_results([dict(r) for r in uncertain_samples], 'flag')
print("[플래그 전략] 각 결과에 needs_review=True 추가됨:",
[r['review_id'] for r in flagged])
정답이 정해져 있는 게 아닙니다. 도메인(어떤 게임·어떤 분석인지)과 여러분의 판단에 따라 전략을 고르면 됩니다. 때로는 "불확실 20%"라는 사실 자체가 "이 구간은 사람이 봐야 한다"는 중요한 인사이트가 되기도 합니다.
🔑 핵심 용어
| 용어 | 쉬운 설명 |
|---|---|
| 확률 / 신뢰도(confidence) | AI가 자기 판단을 얼마나 확신하는지. 보통 0.0~1.0 소수로 표현 |
| 임계값(threshold) | "이 확률 이상이면 믿겠다"는 기준선. 기본 0.7(70%) |
| 채택 / 보류(confident / uncertain) | 임계값을 넘은 결과 / 못 넘어 따로 검토할 결과 |
| 트레이드오프 | 임계값↑이면 정확도↑·채택률↓, 임계값↓이면 그 반대. 한쪽을 얻으면 한쪽을 잃음 |
| 확률 분포 | 모든 결과의 확률값을 모은 전체 경향. 프롬프트 품질 진단에 사용 |
| 히스토그램 | 값이 어디에 얼마나 몰려 있는지 막대로 그린 그래프 |
🎮 게임 기획자 시점
확률·임계값은 사실 기획자가 매일 하는 의사결정의 정밀도 조절과 같습니다. "긍정 65%"라는 한 줄을 보고받을 때, 그 65%가 확신에 찬 65%인지 동전 던지기에 가까운 65%인지에 따라 후속 액션이 달라져야 하죠. 임계값을 정하는 일은 "어느 정도 확실해야 이 데이터로 패치·BM·밸런싱 의사결정을 내릴 것인가"를 못 박는 일입니다. 경영진 보고엔 80%, 내부 탐색엔 60% — 이건 곧 상황별 리스크 허용치를 정하는 운영 감각입니다. 또 "불확실 20%"를 따로 떼어 사람이 보게 하는 건, 자동화가 닿지 못하는 회색지대를 운영 인력이 챙기도록 설계하는 것과 같습니다.
⚠️ 흔한 오해 / 짚고 갈 점
- "confidence가 높으면 정답이다"가 아닙니다. confidence는 AI가 스스로 느끼는 확신일 뿐, 실제로 맞췄다는 보장이 아닙니다. (실제 정확도는 다음 강에서 검증합니다.)
- 임계값은 높을수록 좋은 게 아닙니다. 너무 높이면 대부분의 데이터가 버려져 분석할 게 남지 않습니다. 보통 70%가 균형점입니다.
- 확률은 보통 퍼센트가 아니라 0~1 소수로 다룹니다.
0.9가 90%입니다. 사람에게 보여줄 때만 ×100 해서 변환하세요. - 분포가 50~60%에 몰리는 건 데이터 탓이 아니라 프롬프트 탓일 때가 많습니다. 프롬프트에 기준을 더 명확히 주면 확신이 또렷해집니다.
✅ 30초 정리
- AI의 "긍정"에는 확신의 정도(confidence)가 숨어 있습니다. 95% 긍정과 52% 긍정은 다릅니다.
- 임계값으로 채택/보류를 나눕니다(기본 70%). 올리면 정확도↑·채택률↓, 내리면 반대 — 트레이드오프입니다.
- 확률 분포로 프롬프트를 진단하고, 애매한 결과는 수동검토·재분석·별도분류·제외 중에서 골라 처리합니다.
▶️ 다음 강 연결
confidence는 어디까지나 AI가 스스로 매긴 점수입니다. "90% 확신"이 정말 90% 맞는다는 뜻은 아니죠. 다음 2강 결과 검증과 품질 관리에서는 신뢰도와 정확도는 다르다는 점에서 출발해, 샘플 검증·일관성 체크·분포 확인으로 실제 정확도를 숫자로 측정하고 품질을 관리하는 법을 배웁니다.