<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Jaemin</title>
        <link>https://velog.io/</link>
        <description>ITM Major, Seoultech</description>
        <lastBuildDate>Sun, 04 Oct 2026 11:26:07 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Jaemin</title>
            <url>https://velog.velcdn.com/images/sjmn-zip/profile/01ee5e9d-7360-4d41-b66e-09e458c7c95b/image.jfif</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Jaemin. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sjmn-zip" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[GDGoC AI 스터디 #2] K-means 세션을 듣고 다시 정리한 것들]]></title>
            <link>https://velog.io/@sjmn-zip/GDGoC-AI-%EC%8A%A4%ED%84%B0%EB%94%94-2-K-means-%EC%84%B8%EC%85%98%EC%9D%84-%EB%93%A3%EA%B3%A0-%EB%8B%A4%EC%8B%9C-%EC%A0%95%EB%A6%AC%ED%95%9C-%EA%B2%83%EB%93%A4</link>
            <guid>https://velog.io/@sjmn-zip/GDGoC-AI-%EC%8A%A4%ED%84%B0%EB%94%94-2-K-means-%EC%84%B8%EC%85%98%EC%9D%84-%EB%93%A3%EA%B3%A0-%EB%8B%A4%EC%8B%9C-%EC%A0%95%EB%A6%AC%ED%95%9C-%EA%B2%83%EB%93%A4</guid>
            <pubDate>Sun, 04 Oct 2026 11:26:07 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>GDGoC AI 스터디 2주차는 다른 코어가 <strong>K-means</strong>로 세션을 진행했다.
같은 코어 입장에서 세션을 들으며 정리한 개념, 실습 코드를 직접 돌려 보며 헷갈렸던 점, 그리고 &quot;이렇게 가르치니 잘 들어오네&quot; 싶었던 부분을 기록해 둔다.</p>
</blockquote>
<h2 id="0-스터디-소개">0. 스터디 소개</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>대상</td>
<td>AI 입문자 (Python 기본 문법만 가정)</td>
</tr>
<tr>
<td>세션 시간</td>
<td>순수 강의 20분 기준</td>
</tr>
<tr>
<td>실습 환경</td>
<td>Google Colab</td>
</tr>
<tr>
<td>진행</td>
<td>코어 2명이 주차별로 나눠서 진행</td>
</tr>
</tbody></table>
<p>1주차는 내가 뉴런과 경사하강법을 맡았고, 2주차는 다른 코어분이 비지도학습을 맡았다.</p>
<h2 id="1-세션의-출발점-정답이-없어도-학습할-수-있을까">1. 세션의 출발점: 정답이 없어도 학습할 수 있을까?</h2>
<p>세션은 1주차 복습으로 시작했다. 1주차에는 정답 $y$를 알려주고 예측이 정답에 가까워지도록 가중치를 조정했다. 그리고 바로 질문을 던졌다.</p>
<blockquote>
<p>정답이 없어도 데이터를 학습할 수 있을까?</p>
</blockquote>
<table>
<thead>
<tr>
<th></th>
<th>지도학습 (W1)</th>
<th>비지도학습 (W2)</th>
</tr>
</thead>
<tbody><tr>
<td>주는 것</td>
<td>입력 + 정답 $(x_i, y_i)$</td>
<td>입력만 $x_i$</td>
</tr>
<tr>
<td>목표</td>
<td>정답을 맞히도록 학습</td>
<td>비슷한 점·구조를 찾음</td>
</tr>
<tr>
<td>예시</td>
<td>사진을 보여주고 &quot;고양이&quot;라는 정답을 알려줌</td>
<td>여러 사진을 보고 비슷한 것끼리 묶음</td>
</tr>
</tbody></table>
<p>1주차 내용을 기준점으로 두고 &quot;이번엔 뭐가 빠졌나&quot;로 들어가니까, 비지도학습이 완전히 새로운 개념이 아니라 <strong>지도학습에서 정답만 뺀 것</strong>으로 자연스럽게 연결됐다.</p>
<h2 id="2-k-means-알고리즘">2. K-means 알고리즘</h2>
<h3 id="두-가지-동작의-반복">두 가지 동작의 반복</h3>
<p>K-means는 두 동작만 번갈아 반복한다.</p>
<p><strong>① 배정: 각 점을 가장 가까운 중심에</strong></p>
<p>$$
c_i = \arg\min_k \lVert x_i - \mu_k \rVert^2
$$</p>
<ul>
<li>$x_i$: 데이터 하나</li>
<li>$\mu_k$: k번째 그룹의 중심</li>
<li>$c_i$: $x_i$가 배정된 그룹 번호</li>
</ul>
<p><strong>② 이동: 그룹의 평균으로 중심을 다시 정함</strong></p>
<p>$$
\mu_k = \frac{1}{\lvert C_k \rvert}\sum_{x_i \in C_k} x_i
$$</p>
<ul>
<li>$C_k$: k번째 그룹에 속한 점들의 모음</li>
<li>$\lvert C_k \rvert$: 그 점들의 개수</li>
</ul>
<h3 id="4단계로-풀면">4단계로 풀면</h3>
<ol>
<li>중심점 K개를 정한다</li>
<li>각 점을 가장 가까운 중심에 배정한다</li>
<li>각 그룹의 평균 위치로 중심을 옮긴다</li>
<li>소속이나 중심이 더 이상 바뀌지 않을 때까지 반복한다</li>
</ol>
<p>슬라이드에서는 이 과정을 <strong>3컷 그림</strong>(초기 배치 → 배정 → 평균으로 이동)으로 보여줬다. 수식만 봤으면 $\arg\min$에서 멈칫했을 텐데, 그림을 먼저 보니 &quot;가까운 데 붙이고, 가운데로 옮기고&quot;라는 말로 바로 정리가 됐다.</p>
<h2 id="3-그룹-개수-k는-어떻게-정하나-엘보우-방법">3. 그룹 개수 K는 어떻게 정하나: 엘보우 방법</h2>
<p>K-means는 <strong>K를 사람이 미리 정해야</strong> 한다.</p>
<ul>
<li>K가 너무 작으면 → 서로 다른 데이터가 한 그룹에 섞임</li>
<li>K가 너무 크면 → 비슷한 데이터도 여러 그룹으로 쪼개짐</li>
</ul>
<p>그래서 여러 K로 돌려 보고 <strong>WCSS</strong>를 비교한다.</p>
<p>$$
WCSS = \sum_k \sum_{x_i \in C_k} \lVert x_i - \mu_k \rVert^2
$$</p>
<p>각 점과 자기 그룹 중심 사이 거리 제곱의 총합이다. 작을수록 그룹이 촘촘하다.</p>
<p>K를 늘리면 WCSS는 계속 줄어든다. 극단적으로 점 개수만큼 K를 잡으면 0이 된다. 그래서 &quot;가장 작은 WCSS&quot;가 아니라 <strong>줄어드는 폭이 꺾이는 지점(팔꿈치)</strong> 을 K 후보로 삼는다.</p>
<h2 id="4-표준화-특징마다-범위가-다르면">4. 표준화: 특징마다 범위가 다르면</h2>
<p>K-means는 <strong>거리</strong>로 그룹을 정한다. 그래서 숫자 범위가 큰 특징이 거리를 좌우해버린다.</p>
<p>붓꽃 데이터만 봐도 꽃잎 길이는 대략 1<del>7, 꽃받침 길이는 대략 4</del>8로 범위가 다르다. 그래서 먼저 척도를 맞춘다.</p>
<p>$$
z = \frac{x - \mu}{\sigma}
$$</p>
<p>($\mu$: 평균, $\sigma$: 표준편차)</p>
<p>응용통계 수업에서 배운 z-점수 그대로다. 통계 시간에 배운 게 이렇게 전처리로 쓰이는 걸 보니 반가웠다.</p>
<h2 id="5-실습-정답을-가리고-붓꽃-묶기">5. 실습: 정답을 가리고 붓꽃 묶기</h2>
<p>세션 실습의 컨셉이 좋았다.</p>
<blockquote>
<p>붓꽃 종류(정답)를 <strong>가리고</strong>, 꽃잎 길이와 너비만으로 그룹을 만들어 보자.
마지막에 모델이 만든 그룹이 실제 꽃 종류와 얼마나 비슷한지 확인한다.</p>
</blockquote>
<p>세션이 끝나고 Colab에서 직접 돌려 봤다.</p>
<h3 id="①-데이터-불러오기--표준화">① 데이터 불러오기 &amp; 표준화</h3>
<pre><code class="language-python">from sklearn.datasets import load_iris
from sklearn.preprocessing import StandardScaler
import matplotlib.pyplot as plt

iris = load_iris()

# 꽃잎 길이와 꽃잎 너비만 선택
X = iris.data[:, 2:4]

# 특징값의 크기를 비슷한 기준으로 맞춤
X_scaled = StandardScaler().fit_transform(X)

# 정답 정보 없이 데이터 모양 확인
plt.scatter(X_scaled[:, 0], X_scaled[:, 1], color=&quot;teal&quot;)
plt.xlabel(&quot;Petal length (standardized)&quot;)
plt.ylabel(&quot;Petal width (standardized)&quot;)
plt.title(&quot;Iris data without labels&quot;)
plt.show()</code></pre>
<p><code>iris.target</code>(정답)은 쓰지 않는다. 점이 전부 같은 색으로 찍힌다.</p>
<h3 id="②-엘보우-방법으로-k-후보-찾기">② 엘보우 방법으로 K 후보 찾기</h3>
<pre><code class="language-python">from sklearn.cluster import KMeans

wcss = []

for k in range(1, 9):
    model = KMeans(n_clusters=k, random_state=42, n_init=10)
    model.fit(X_scaled)
    wcss.append(model.inertia_)   # inertia_ = 그 K에서의 WCSS

plt.plot(range(1, 9), wcss, marker=&quot;o&quot;)
plt.xlabel(&quot;Number of clusters (K)&quot;)
plt.ylabel(&quot;WCSS&quot;)
plt.title(&quot;Elbow Method&quot;)
plt.xticks(range(1, 9))
plt.show()</code></pre>
<h3 id="③-k3으로-군집화">③ K=3으로 군집화</h3>
<pre><code class="language-python">model = KMeans(n_clusters=3, random_state=42, n_init=10)
clusters = model.fit_predict(X_scaled)

plt.scatter(X_scaled[:, 0], X_scaled[:, 1], c=clusters, cmap=&quot;viridis&quot;)

centers = model.cluster_centers_
plt.scatter(centers[:, 0], centers[:, 1], marker=&quot;X&quot;, s=250, color=&quot;red&quot;)

plt.xlabel(&quot;Petal length (standardized)&quot;)
plt.ylabel(&quot;Petal width (standardized)&quot;)
plt.title(&quot;K-means clustering result&quot;)
plt.show()   # 빨간색 X가 각 그룹의 중심점</code></pre>
<h2 id="6-🐛-직접-돌려-보며-막힌-것들">6. 🐛 직접 돌려 보며 막힌 것들</h2>
<h3 id="슬라이드-코드를-그대로-복사했더니">슬라이드 코드를 그대로 복사했더니</h3>
<p>슬라이드에 있던 코드를 옮기는데 <code>for</code> 문 안쪽 <strong>들여쓰기가 풀려 있었다.</strong> 파이썬은 들여쓰기가 문법이라 그대로 붙이면 바로 <code>IndentationError</code>가 난다. 위 코드는 들여쓰기를 복원한 버전이다.</p>
<blockquote>
<p>슬라이드 → 코드 복붙은 들여쓰기부터 확인하자. 나중에 내가 세션 자료 만들 때도 조심할 부분.</p>
</blockquote>
<h3 id="inertia_가-뭐지"><code>inertia_</code>가 뭐지?</h3>
<p>슬라이드에선 WCSS라고 배웠는데 코드엔 <code>inertia_</code>가 나온다. scikit-learn에서 WCSS를 부르는 이름이 <code>inertia_</code>였다. 같은 것인데 이름이 달라서 처음엔 다른 값인 줄 알았다.</p>
<h3 id="헷갈렸던-개념-정리">헷갈렸던 개념 정리</h3>
<table>
<thead>
<tr>
<th>헷갈린 지점</th>
<th>정리</th>
</tr>
</thead>
<tbody><tr>
<td>결과의 0, 1, 2가 꽃 종류(setosa 등)인가?</td>
<td>아니다. <strong>그룹 번호일 뿐</strong>이다. 어떤 번호가 어떤 종인지 모델은 모른다. <code>clusters</code>를 출력하면 <code>[1, 1, 1, ..., 0, 2, ...]</code>가 나와서 &quot;1번이 setosa인가?&quot; 싶어지는데, 그냥 이름표다</td>
</tr>
<tr>
<td>K는 어떻게 정하나?</td>
<td>사람이 정한다. 실습은 엘보우를 보고 &quot;K=3이 적절해 보인다고 가정&quot;했다. 붓꽃은 실제로 3종이라 자연스럽지만, 진짜 비지도 상황에선 정답 개수를 모르는 게 보통이다</td>
</tr>
<tr>
<td>왜 표준화를 먼저 하나?</td>
<td>K-means는 거리로 묶기 때문에, 범위가 큰 특징이 거리를 좌우한다</td>
</tr>
<tr>
<td>정답을 안 쓴다면서 왜 마지막에 비교하나?</td>
<td>학습에는 안 쓰고 &quot;만든 그룹이 실제와 얼마나 비슷한가&quot;를 확인하는 용도다. 실무에선 비교할 정답조차 없는 경우가 많다</td>
</tr>
<tr>
<td>K-means도 경사하강법으로 학습하나?</td>
<td>아니다. 배정 ↔ 이동을 번갈아 반복할 뿐이다 (아래에서 정리)</td>
</tr>
</tbody></table>
<h2 id="7-세션-밖에서-혼자-연결해-본-것">7. 세션 밖에서 혼자 연결해 본 것</h2>
<h3 id="w1과-w2는-생각보다-닮았다">W1과 W2는 생각보다 닮았다</h3>
<p>마지막 질문을 곱씹다가, 내가 맡았던 1주차와 이번 2주차가 같은 뼈대라는 걸 알게 됐다.</p>
<table>
<thead>
<tr>
<th></th>
<th>W1 (선형회귀·신경망)</th>
<th>W2 (K-means)</th>
</tr>
</thead>
<tbody><tr>
<td>줄이는 숫자</td>
<td>손실 (MSE)</td>
<td>WCSS</td>
</tr>
<tr>
<td>줄이는 방법</td>
<td>경사하강법으로 $w$ 조정</td>
<td>배정 ↔ 중심 이동 반복</td>
</tr>
<tr>
<td>정답 사용</td>
<td>O</td>
<td>X</td>
</tr>
</tbody></table>
<p>둘 다 <strong>&quot;어떤 숫자를 정해 두고, 그걸 줄이는 방향으로 반복한다&quot;</strong> 는 뼈대는 같다. 다만 K-means는 미분해서 $w$를 고치는 게 아니라,앞으로 어떤 알고리즘이 나와도 &quot;이건 뭘 줄이려고 하지?&quot;라는 질문 하나로 구조를 잡을 수 있을 것 같다.</p>
<h3 id="내-프로젝트daon에서-보면">내 프로젝트(Daon)에서 보면</h3>
<p><a href="https://velog.io/">Daon 개발기</a>에서 만든 감정 분류기는 6개 감정 라벨(정답)을 주고 학습시킨 <strong>지도학습</strong>이다.</p>
<p>만약 라벨 없이 5만 개 문장을 &quot;비슷한 것끼리&quot; 묶었다면 비지도학습이 된다. 그때 K를 6으로 잡으면 감정 6개와 비슷하게 묶일까? 아마 아닐 것이다. 모델은 감정이 아니라 <strong>문장 길이나 자주 나오는 단어</strong>로 묶을 수도 있다. 비지도학습은 무엇을 기준으로 묶을지 우리가 정해주지 않는다는 게 장점이자 한계다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Daon 개발기 #2] 위기 탐지 게이트]]></title>
            <link>https://velog.io/@sjmn-zip/Daon-%EA%B0%9C%EB%B0%9C%EA%B8%B0-2-%EC%9C%84%EA%B8%B0-%ED%83%90%EC%A7%80-%EA%B2%8C%EC%9D%B4%ED%8A%B8</link>
            <guid>https://velog.io/@sjmn-zip/Daon-%EA%B0%9C%EB%B0%9C%EA%B8%B0-2-%EC%9C%84%EA%B8%B0-%ED%83%90%EC%A7%80-%EA%B2%8C%EC%9D%B4%ED%8A%B8</guid>
            <pubDate>Sun, 27 Sep 2026 01:41:08 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>1편에서 만든 감정 분류기는 정확도 57%다.
이 모델에 사람의 안전을 맡길 수는 없어서, 분류기보다 <strong>먼저, 별도로</strong> 동작하는 위기 탐지 게이트를 만들었다.
이번 편은 그 설계 원칙과, 테스트 중 실제로 터진 미탐 버그에 대한 기록이다.</p>
</blockquote>
<h2 id="1-왜-위기-탐지를-따로-만들었나">1. 왜 위기 탐지를 따로 만들었나</h2>
<h3 id="감정-분류기로는-부족한-이유">감정 분류기로는 부족한 이유</h3>
<p>감정 분류기는 문장을 6개 감정 중 하나로 고르는 모델이다. &quot;위험한가&quot;를 판단하도록 학습된 모델이 아니다.</p>
<p>예를 들어 &quot;다 끝내고 싶어&quot; 같은 문장은 분류기 입장에서 그냥 <strong>슬픔</strong>이다. 틀린 분류는 아니지만, 챗봇이 이걸 일반적인 슬픔처럼 받아서 &quot;오늘 많이 힘드셨나 봐요~&quot;로 넘어가면 안 된다.</p>
<p>게다가 정확도가 57%다. 10번 중 4번은 틀린다. 감정 분류가 틀리면 공감 톤이 조금 어긋나는 정도지만, 위기 판단이 틀리면 얘기가 다르다.</p>
<h3 id="llm에-맡기지-않은-이유">LLM에 맡기지 않은 이유</h3>
<p>&quot;그냥 Gemini한테 위험한지 물어보면 되지 않나?&quot; 싶었지만 세 가지 이유로 제외했다.</p>
<table>
<thead>
<tr>
<th>이유</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>비결정성</td>
<td>같은 문장을 넣어도 판단이 매번 같다는 보장이 없다</td>
</tr>
<tr>
<td>검증 불가</td>
<td>왜 그렇게 판단했는지 내가 확인하고 고칠 수 없다</td>
</tr>
<tr>
<td>외부 의존</td>
<td>API 서버가 죽거나 한도를 넘으면 게이트도 같이 멈춘다</td>
</tr>
</tbody></table>
<p>특히 세 번째가 컸다. 실제로 3편에서 다룰 Gemini 연동 중에 503(서버 과부하), 429(요청 한도 초과) 에러를 계속 만났다. <strong>안전장치가 외부 서버 상태에 따라 켜졌다 꺼졌다 하면 안전장치가 아니다.</strong></p>
<p>그래서 위기 게이트는 <strong>내 코드 안에서, 네트워크 없이, 항상 같은 결과를 내는 규칙 기반</strong>으로 만들었다.</p>
<h3 id="전체-흐름에서의-위치">전체 흐름에서의 위치</h3>
<pre><code>사용자 입력
   ↓
[위기 탐지 게이트]  ← 이번 편
   ├─ 위기 O → 공감 + 전문기관 안내 (대화는 끊지 않음)
   ↓ 위기 X
[KoBERT 감정 분류]  ← 1편
   ↓
[LLM(Gemini)]       ← 3편</code></pre><p>게이트가 <strong>맨 앞</strong>에 있다는 게 핵심이다. 감정 분류나 LLM 호출보다 먼저 실행된다.</p>
<h2 id="2-설계-원칙-1-미탐--오탐">2. 설계 원칙 1: 미탐 &gt; 오탐</h2>
<h3 id="두-종류의-실수">두 종류의 실수</h3>
<p>분류 문제에서 틀리는 방식은 두 가지다.</p>
<table>
<thead>
<tr>
<th>오류</th>
<th>의미</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>오탐 (False Positive)</td>
<td>위기가 아닌데 위기로 판단</td>
<td>불필요한 안내. 불편하지만 <strong>안전</strong> ✅</td>
</tr>
<tr>
<td>미탐 (False Negative)</td>
<td>위기인데 놓침</td>
<td><strong>치명적</strong> 🚨</td>
</tr>
</tbody></table>
<p>둘 다 줄이면 좋겠지만, 현실적으로 하나를 줄이면 다른 하나가 늘어난다. 기준을 빡빡하게 잡으면 미탐이 늘고, 느슨하게 잡으면 오탐이 는다.</p>
<h3 id="어느-쪽을-택했나">어느 쪽을 택했나</h3>
<p>다온은 <strong>오탐을 감수하고 미탐을 줄이는 쪽</strong>을 택했다.</p>
<p>화재경보기랑 비슷하다. 요리하다 연기 조금 났을 때 울리는 경보기는 짜증 나지만, 진짜 불났을 때 안 울리는 경보기는 존재 이유가 없다.</p>
<p>그래서 &quot;배고파 죽겠다&quot;, &quot;과제 때문에 죽을 것 같아&quot; 같은 관용 표현이 걸리는 건 <strong>의도적으로 감수</strong>했다. 이런 경우 챗봇이 &quot;혹시 많이 힘드신가요?&quot;라고 한 번 더 묻는 정도의 불편함은 괜찮다고 판단했다.</p>
<blockquote>
<p>게이트의 목적은 &quot;정확히 맞히기&quot;가 아니라 <strong>&quot;놓치지 않기&quot;</strong> 다.</p>
</blockquote>
<h2 id="3-설계-원칙-2-문장-길이는-필터가-아니라-보조-신호">3. 설계 원칙 2: 문장 길이는 필터가 아니라 보조 신호</h2>
<h3 id="처음-세운-가설">처음 세운 가설</h3>
<p>개발하면서 이런 생각이 들었다.</p>
<blockquote>
<p>급성 위기일수록 말이 짧고 단정적이지 않을까?</p>
</blockquote>
<p>길게 상황을 설명하는 사람은 아직 누군가와 이야기할 여유가 있는 상태고, 짧게 결론만 말하는 쪽이 더 급할 수 있다는 가설이었다.</p>
<h3 id="필터로-쓰면-안-되는-이유">필터로 쓰면 안 되는 이유</h3>
<p>문제는 이걸 <strong>필터</strong>로 쓰는 순간이다. &quot;긴 문장은 안전하니 통과&quot;로 만들면, 길게 쓴 진짜 위기를 놓친다. 원칙 1(미탐 &gt; 오탐)에 정면으로 위배된다.</p>
<h3 id="역할-분리">역할 분리</h3>
<p>그래서 판단을 두 단계로 나눴다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>질문</th>
<th>길이의 역할</th>
</tr>
</thead>
<tbody><tr>
<td>1. 감지</td>
<td>위기 신호가 있는가?</td>
<td><strong>사용 안 함.</strong> 신호가 있으면 길이와 무관하게 무조건 잡는다</td>
</tr>
<tr>
<td>2. 강도</td>
<td>얼마나 급한가?</td>
<td><strong>보조 신호로 사용.</strong> 짧고 단정적이면 강도를 올린다</td>
</tr>
</tbody></table>
<p>강도 판단 기준은 이렇다.</p>
<ul>
<li><strong>high</strong>: 구체적인 시점·방법 언급이 있거나, 짧고 단정적인 표현</li>
<li><strong>medium</strong>: 위기 신호는 있지만 길게 상황을 토로하는 경우</li>
</ul>
<p>길이는 &quot;잡을지 말지&quot;가 아니라 <strong>&quot;어떻게 대응할지&quot;</strong> 에만 영향을 준다.</p>
<h2 id="4-구현">4. 구현</h2>
<h3 id="crisisgate-클래스">CrisisGate 클래스</h3>
<pre><code class="language-python">class CrisisGate:
    SHORT_TEXT_LEN = 15  # 짧은 문장 기준 (예시 값)

    def __init__(self):
        self.crisis_keywords = [...]     # 위기 신호 표현
        self.imminent_keywords = [...]   # 임박 신호 (시점·방법 등)

    def check(self, text: str) -&gt; dict:
        text = text.strip()

        # 1단계: 감지 (길이 무관)
        has_crisis = any(k in text for k in self.crisis_keywords)
        if not has_crisis:
            return {&quot;crisis&quot;: False, &quot;level&quot;: None}

        # 2단계: 강도
        has_imminent = any(k in text for k in self.imminent_keywords)
        is_short = len(text) &lt;= self.SHORT_TEXT_LEN

        level = &quot;high&quot; if (has_imminent or is_short) else &quot;medium&quot;
        return {&quot;crisis&quot;: True, &quot;level&quot;: level}

    def get_response(self, level: str) -&gt; str:
        ...</code></pre>
<p>키워드 목록은 공개 글이라 생략했다. 탐지 표현을 그대로 공개하면 우회 방법을 알려주는 셈이 되기 때문이다.</p>
<h3 id="사용-예시">사용 예시</h3>
<pre><code class="language-python">gate = CrisisGate()

result = gate.check(user_input)
if result[&quot;crisis&quot;]:
    reply = gate.get_response(result[&quot;level&quot;])
else:
    emotion = classifier.predict(user_input)   # 1편의 KoBERT
    reply = llm.generate(user_input, emotion)  # 3편의 Gemini</code></pre>
<p>게이트가 위기를 감지하면 감정 분류와 LLM 호출을 <strong>아예 건너뛴다.</strong> 이 경로에서는 외부 API가 죽어 있어도 응답이 나간다.</p>
<h2 id="5-응답-설계-어떻게-말할-것인가">5. 응답 설계: 어떻게 말할 것인가</h2>
<p>위기를 잡는 것만큼 중요한 게 <strong>잡은 다음에 뭐라고 말하느냐</strong>였다.</p>
<h3 id="정한-원칙">정한 원칙</h3>
<table>
<thead>
<tr>
<th>원칙</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>대화를 끊지 않는다</td>
<td>&quot;전문기관에 연락하세요&quot;만 던지고 끝내면 거절당한 느낌을 준다</td>
</tr>
<tr>
<td>&quot;왜&quot; 대신 &quot;어떤 일이&quot;</td>
<td>&quot;<strong>왜</strong> 그런 생각을 했어요?&quot;는 추궁처럼 들린다. &quot;<strong>어떤 일이</strong> 있었어요?&quot;는 이야기를 꺼낼 틈을 준다</td>
</tr>
<tr>
<td>다리 역할만 한다</td>
<td>챗봇이 상담을 혼자 떠안지 않는다. 전문 도움으로 <strong>이어주는</strong> 게 역할이다</td>
</tr>
</tbody></table>
<h3 id="강도별-대응">강도별 대응</h3>
<p><strong>high</strong>: 공감 → 즉시 전문기관 안내 → 대화 이어가기</p>
<pre><code>지금 많이 힘드신 것 같아요. 이야기해 주셔서 고마워요.
지금 바로 이야기 나눌 수 있는 분들이 있어요.
자살예방상담전화 109는 24시간 연결돼요.
저도 여기서 계속 이야기 들을게요.</code></pre><p><strong>medium</strong>: 공감 → 열린 질문 → 경청 → 대화 흐름 속에서 자연스럽게 권유</p>
<pre><code>마음이 많이 무거우셨겠어요.
어떤 일이 있었는지 조금 더 이야기해 주실 수 있을까요?</code></pre><p>medium에서 바로 전화번호부터 들이밀지 않은 건, 아직 이야기하고 싶은 사람에게 번호부터 주면 &quot;넌 내가 감당 못 해&quot;로 들릴 수 있어서다.</p>
<h2 id="6-🐛-실제로-터진-미탐">6. 🐛 실제로 터진 미탐</h2>
<h3 id="상황">상황</h3>
<p>테스트 문장을 넣어 보다가, 누가 봐도 고위험인 문장이 <code>crisis: False</code>로 <strong>그냥 통과</strong>했다.</p>
<pre><code>{&quot;crisis&quot;: False, &quot;level&quot;: None}</code></pre><p>감정 분류기로 넘어가서 일반 대화처럼 처리될 뻔한 상황이다. 원칙 1에서 &quot;제일 치명적&quot;이라고 적어 둔 바로 그 미탐이었다.
<img src="https://velog.velcdn.com/images/sjmn-zip/post/0242302e-9f94-480c-9ef1-aae8d2d30be2/image.png" alt=""></p>
<h3 id="원인">원인</h3>
<p>코드를 따라가 보니 원인은 단순했다.</p>
<ol>
<li>문장에는 시점·방법 같은 <strong>임박 신호</strong>가 분명히 있었다</li>
<li>그런데 위기 표현이 키워드 목록에 없는 <strong>활용형</strong>이었다</li>
<li>그래서 <strong>1단계(감지)에서 안 걸렸고</strong></li>
<li>1단계를 통과 못 하니 <strong>2단계(강도)까지 가지도 못했다</strong></li>
</ol>
<p>2단계에 임박 신호 체크가 있어도 소용없었다. 1단계가 문지기라서, 거기서 막히면 뒤는 실행조차 안 된다. 구조상 <strong>1단계의 구멍이 곧 전체의 구멍</strong>이었던 것이다.</p>
<h3 id="조치">조치</h3>
<p>일단 해당 활용형을 키워드에 추가해서 막았다. 다시 넣어 보니 <code>high</code>로 정상 감지됐다.</p>
<h3 id="근본적인-문제">근본적인 문제</h3>
<p>그런데 이 방식은 <strong>끝이 없다.</strong> 한국어는 어미 활용이 다양해서 같은 의미를 수십 가지 형태로 말할 수 있다. 하나를 막으면 다른 활용형이 또 빠져나간다.</p>
<p>&quot;키워드 방식은 문맥을 모른다&quot;는 건 설계할 때 이미 알고 있던 한계였다. 그런데 막상 내 코드에서 <strong>실제로 재현되니</strong> 무게가 달랐다.</p>
<h2 id="7-한계와-개선-방향">7. 한계와 개선 방향</h2>
<h3 id="현재-게이트의-한계">현재 게이트의 한계</h3>
<table>
<thead>
<tr>
<th>한계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>활용형 누락</td>
<td>이번 버그. 목록에 없는 형태는 못 잡는다</td>
</tr>
<tr>
<td>문맥 무시</td>
<td>&quot;죽겠다&quot;가 관용 표현인지 진심인지 구분 못 한다 (오탐)</td>
</tr>
<tr>
<td>우회 가능</td>
<td>띄어쓰기, 오타, 돌려 말하기에 약하다</td>
</tr>
<tr>
<td>1단계 의존</td>
<td>감지 단계가 뚫리면 뒤는 아무 소용이 없다</td>
</tr>
</tbody></table>
<h3 id="생각하는-개선-방향">생각하는 개선 방향</h3>
<ul>
<li><strong>형태소 분석</strong>: 어간 기준으로 비교하면 활용형 누락을 줄일 수 있다</li>
<li><strong>1단계에 임박 신호도 포함</strong>: 위기 표현이 없어도 임박 신호가 있으면 일단 잡는 OR 구조</li>
<li><strong>AI 보강</strong>: 키워드로 1차, 문맥을 이해하는 모델로 2차를 거는 이중 안전망( 2026년 9월 15일, 미국 Typesafe AI에서 만든 Jev라는 기술을 사용해봐도 괜찮을 듯)</li>
<li><strong>검증된 표현 목록</strong>: 실서비스라면 내가 만든 목록이 아니라 전문기관이 검증한 기준을 써야 한다</li>
</ul>
<blockquote>
<p>지금의 게이트는 <strong>구조를 검증하기 위한 프로토타입</strong>이다. 실서비스 수준의 안전장치가 아니다.</p>
</blockquote>
<h2 id="8-이번-편에서-배운-것">8. 이번 편에서 배운 것</h2>
<ol>
<li><strong>오류에도 무게가 다르다.</strong> 정확도 하나로 모델을 평가하면 안 된다. 어떤 실수가 더 치명적인지 먼저 정하고, 거기에 맞춰 설계해야 한다.</li>
<li><strong>신호마다 역할이 있다.</strong> 문장 길이처럼 애매한 신호도 필터로 쓰면 독이지만, 보조 신호로 쓰면 유용하다.</li>
<li><strong>안전장치는 외부에 의존하면 안 된다.</strong> 항상 같은 결과를, 항상 작동하는 곳에서 내야 한다.</li>
<li><strong>알던 한계도 직접 겪어야 체감된다.</strong> 미탐 버그 하나가 개선 방향을 훨씬 구체적으로 만들어 줬다.</li>
<li><strong>내 시스템이 어디서 놓치는지 아는 것이 실력이다.</strong> &quot;키워드로 잡는다&quot;보다 &quot;어디서 놓치는지 알고 설계했다&quot;가 이번 편의 핵심이다.</li>
</ol>
<h2 id="다음-편-예고">다음 편 예고</h2>
<p>드디어 LLM을 붙인다.
Claude API에서 Gemini로 바꾸게 된 이유, <code>.env</code>로 API 키 숨기기, 그리고 503 → 429 → 첫 응답까지의 에러 여정을 다룬다.</p>
<hr>
<blockquote>
<p>본 프로젝트는 학습·포트폴리오용 프로토타입이며 전문 상담을 대체하지 않습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Daon 개발기 #1]]]></title>
            <link>https://velog.io/@sjmn-zip/Daon-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-toik2skp</link>
            <guid>https://velog.io/@sjmn-zip/Daon-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-toik2skp</guid>
            <pubDate>Sun, 27 Sep 2026 01:39:07 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>한국어 정서지지 챗봇 <strong>다온(Daon)</strong> 을 만들면서 겪은 과정을 기록하는 시리즈다.
이번 편에서는 데이터 전처리부터 KoBERT 감정 분류기 학습까지 다룬다.
학습·포트폴리오용 프로토타입이며, 전문 상담을 대체하지 않는다.</p>
</blockquote>
<h2 id="1-다온은-어떤-프로젝트인가">1. 다온은 어떤 프로젝트인가</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>유형</td>
<td>대화형 정서지지 챗봇</td>
</tr>
<tr>
<td>엔진</td>
<td>하이브리드 (직접 학습한 감정 분류기 + LLM API)</td>
</tr>
<tr>
<td>핵심 가치</td>
<td><strong>Safety-First</strong>: 위기 탐지 게이트</td>
</tr>
<tr>
<td>스택</td>
<td>Python, pandas, PyTorch, HuggingFace Transformers, Colab(GPU), Gemini API</td>
</tr>
</tbody></table>
<p>LLM에 사용자 말을 그대로 던지는 챗봇이 아니라, 세 단계를 거치도록 설계했다.</p>
<p>사용자 입력
↓
[위기 탐지 게이트] ── 위기 O → 공감 + 전문기관 안내 (대화는 끊지 않음)
↓ 위기 X
[KoBERT 감정 분류] → 6개 감정 중 하나
↓
[LLM(Gemini)] → 감정 정보를 주입한 프롬프트로 공감 응답 생성</p>
<p>핵심은 <strong>&quot;판단은 내가 만든 부품이, 말은 LLM이&quot;</strong> 하는 구조다.
이번 편은 이 중 가운데, <strong>감정 분류기</strong>를 만드는 과정이다.</p>
<h2 id="2-데이터-준비-5만-건의-중첩-json을-2열-표로">2. 데이터 준비: 5만 건의 중첩 JSON을 2열 표로</h2>
<h3 id="데이터-확보">데이터 확보</h3>
<p>AI Hub의 <strong>감성대화 말뭉치</strong>를 사용했다. 발화마다 감정이 라벨링되어 있고, 60개 세부 감정이 6개 대분류로 묶여 있다.</p>
<p>처음엔 엑셀 파일일 거라 생각했는데 받아 보니 <strong>JSON</strong>이었다. 첫 교훈: 가정은 반드시 확인하자.</p>
<pre><code class="language-python">import pandas as pd

df = pd.read_json(&quot;data/감성대화말뭉치(최종데이터)_Training.json&quot;)
print(df.shape)    # (51628, 2)
print(df.columns)  # [&#39;profile&#39;, &#39;talk&#39;]</code></pre>
<p>필요한 값은 깊숙이 중첩되어 있었다.</p>
<ul>
<li>감정 코드: <code>profile → emotion → type</code> (예: <code>E18</code>)</li>
<li>사람 발화: <code>talk → content → HS01</code></li>
</ul>
<h3 id="설계-결정-60개가-아닌-6개-감정">설계 결정: 60개가 아닌 6개 감정</h3>
<p>감정 코드의 앞자리로 대분류가 정해진다.</p>
<table>
<thead>
<tr>
<th>코드</th>
<th>E1x</th>
<th>E2x</th>
<th>E3x</th>
<th>E4x</th>
<th>E5x</th>
<th>E6x</th>
</tr>
</thead>
<tbody><tr>
<td>감정</td>
<td>분노</td>
<td>슬픔</td>
<td>불안</td>
<td>상처</td>
<td>당황</td>
<td>기쁨</td>
</tr>
</tbody></table>
<p>세부 60개 대신 <strong>대분류 6개만</strong> 쓰기로 했다. 학습이 쉽고, 챗봇이 사용자 상태를 파악하는 데는 6개로 충분하다고 판단했다.</p>
<h3 id="전처리-코드">전처리 코드</h3>
<pre><code class="language-python">def code_to_emotion(emotion_code: str) -&gt; str:
    code_num = emotion_code[1]          # &quot;E18&quot; → &quot;1&quot; (문자!)
    mapping = {&quot;1&quot;: &quot;분노&quot;, &quot;2&quot;: &quot;슬픔&quot;, &quot;3&quot;: &quot;불안&quot;,
               &quot;4&quot;: &quot;상처&quot;, &quot;5&quot;: &quot;당황&quot;, &quot;6&quot;: &quot;기쁨&quot;}
    return mapping.get(code_num, &quot;알 수 없음&quot;)

sentences, emotions = [], []
for i in range(len(df)):
    sentences.append(df.loc[i, &quot;talk&quot;][&quot;content&quot;][&quot;HS01&quot;])
    emotions.append(code_to_emotion(df.loc[i, &quot;profile&quot;][&quot;emotion&quot;][&quot;type&quot;]))

result = pd.DataFrame({&quot;sentence&quot;: sentences, &quot;emotion&quot;: emotions})</code></pre>
<h3 id="🐛-첫-번째-버그-문자-1과-숫자-1">🐛 첫 번째 버그: 문자 &quot;1&quot;과 숫자 1</h3>
<p>처음엔 <code>code_num == 1</code>로 비교했다가 <strong>모든 데이터가 else로 빠졌다.</strong>
<code>&quot;E18&quot;[1]</code>로 꺼낸 값은 문자열 <code>&quot;1&quot;</code>이지 숫자가 아니다. <code>type()</code>으로 찍어 보고 나서야 알았다.</p>
<blockquote>
<p>코드는 맞는 것 같은데 결과가 이상하면, 타입부터 의심하자.</p>
</blockquote>
<h3 id="결과-검증">결과 검증</h3>
<pre><code class="language-python">print(result[&quot;emotion&quot;].value_counts())</code></pre>
<p>합계 51,628건, &quot;알 수 없음&quot; 0건. 다만 <strong>기쁨이 다른 감정보다 약 3천 건 적은 불균형</strong>이 보였다. 이게 나중에 성능 한계의 원인 중 하나가 된다.</p>
<h2 id="3-kobert-파인튜닝">3. KoBERT 파인튜닝</h2>
<h3 id="개념-한-줄-정리">개념 한 줄 정리</h3>
<ul>
<li><strong>KoBERT</strong>: 한국어를 이미 잘 아는 사전학습 모델 (국어 잘하는 전학생)</li>
<li><strong>파인튜닝</strong>: 그 전학생에게 &quot;감정 구분법&quot;만 추가로 가르치는 것</li>
</ul>
<p>KoBERT에는 한국어를 이해하는 &quot;몸통&quot;만 있고 감정을 고르는 &quot;머리&quot;는 없다.
<code>num_labels=6</code>으로 머리를 새로 달고, 그 머리를 내 데이터로 훈련시키는 것이 파인튜닝이다.</p>
<h3 id="파이프라인">파이프라인</h3>
<pre><code class="language-python">from sklearn.model_selection import train_test_split
from transformers import AutoTokenizer, AutoModelForSequenceClassification

# 1. 라벨 인코딩 (감정 → 숫자)
label_map = {&quot;분노&quot;: 0, &quot;슬픔&quot;: 1, &quot;불안&quot;: 2, &quot;상처&quot;: 3, &quot;당황&quot;: 4, &quot;기쁨&quot;: 5}
result[&quot;label&quot;] = result[&quot;emotion&quot;].map(label_map)

# 2. 학습/검증 분리 (8:2, 감정 비율 유지)
X_train, X_test, y_train, y_test = train_test_split(
    result[&quot;sentence&quot;].tolist(), result[&quot;label&quot;].tolist(),
    test_size=0.2, random_state=42, stratify=result[&quot;label&quot;])

# 3. 토큰화 (문장 → 숫자)
tokenizer = AutoTokenizer.from_pretrained(&quot;monologg/kobert&quot;, trust_remote_code=True)
train_enc = tokenizer(X_train, truncation=True, padding=True,
                      max_length=64, return_tensors=&quot;pt&quot;)

# 4. 모델 불러오기 (6갈래 분류 머리 부착)
model = AutoModelForSequenceClassification.from_pretrained(
    &quot;monologg/kobert&quot;, num_labels=6, trust_remote_code=True)</code></pre>
<ul>
<li>학습용 41,302건 / 검증용 10,326건</li>
<li><code>stratify</code>로 두 세트 모두 6개 감정 비율을 똑같이 맞췄다</li>
<li>Colab 무료 GPU(T4)에서 학습</li>
</ul>
<h3 id="🔥-과적합을-직접-목격하다">🔥 과적합을 직접 목격하다</h3>
<p>3에폭으로 학습한 결과다.</p>
<table>
<thead>
<tr>
<th>Epoch</th>
<th>Training Loss</th>
<th>Validation Loss</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1.022</td>
<td><strong>1.207</strong> ← 가장 균형 좋음</td>
</tr>
<tr>
<td>2</td>
<td>0.785</td>
<td>1.319 ⚠️</td>
</tr>
<tr>
<td>3</td>
<td>0.560</td>
<td>1.451 ⚠️</td>
</tr>
</tbody></table>
<p>Training Loss는 계속 내려가는데 Validation Loss는 오히려 올라간다.
모델이 문제집을 <strong>외우기</strong> 시작해서, 처음 보는 문장은 더 못 맞히게 된 상태, 즉 <strong>과적합</strong>이다.</p>
<h3 id="개선-진단-→-조치-→-검증">개선: 진단 → 조치 → 검증</h3>
<p>에폭을 2로 줄여 다시 학습했다. 이때 함정이 하나 있었다.</p>
<blockquote>
<p>⚠️ 모델을 <code>from_pretrained</code>로 <strong>새로 불러오지 않고</strong> 다시 <code>train()</code>하면,
이미 3에폭 학습된 모델에 2에폭이 <strong>누적</strong>되어 과적합이 더 심해진다.</p>
</blockquote>
<table>
<thead>
<tr>
<th>구분</th>
<th>3에폭</th>
<th>2에폭</th>
</tr>
</thead>
<tbody><tr>
<td>Validation Loss</td>
<td>1.451</td>
<td><strong>1.135</strong></td>
</tr>
<tr>
<td>정확도</td>
<td>56.54%</td>
<td><strong>57.40%</strong></td>
</tr>
</tbody></table>
<p>랜덤으로 찍으면 16.7%(1/6)이니 약 3.4배 수준이다.</p>
<p>개선 폭이 크지 않다는 건 <strong>과적합만이 원인은 아니었다</strong>는 뜻이다. 남은 원인으로 보는 것들은 다음과 같다.</p>
<ul>
<li><strong>슬픔 ↔ 상처</strong>처럼 경계가 모호한 감정 쌍</li>
<li>기쁨 클래스의 <strong>데이터 불균형</strong></li>
<li>학습률·배치 크기 등 <strong>하이퍼파라미터 미튜닝</strong></li>
</ul>
<p>지금은 &quot;전체 파이프라인 완성&quot;이 우선이라 57%로 확정하고, 성능 최적화는 뒤 단계로 미뤘다.</p>
<h2 id="4-이번-편에서-배운-것">4. 이번 편에서 배운 것</h2>
<ol>
<li><strong>원본 데이터는 항상 지저분하다.</strong> 모델링보다 전처리에 더 많은 시간을 썼고, 그게 정상이다.</li>
<li><strong>숫자 하나 올리는 것보다 원인을 아는 게 중요하다.</strong> 0.86%p 개선보다, 과적합을 진단하고 고치고 검증한 사이클 자체가 더 큰 수확이었다.</li>
<li><strong>실험은 조건을 통제해야 한다.</strong> 모델을 새로 불러오지 않으면 비교 자체가 무의미해진다.</li>
</ol>
<h2 id="다음-편-예고">다음 편 예고</h2>
<p>정확도 57%짜리 모델에 사람의 안전을 맡길 수는 없다.
다음 편에서는 감정 분류보다 <strong>먼저, 별도로</strong> 동작하는 <strong>위기 탐지 게이트</strong>를 어떻게 설계했는지, 그리고 테스트 중 실제로 터진 <strong>미탐 버그</strong>를 다룬다.</p>
]]></description>
        </item>
    </channel>
</rss>