<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>inhwaaa_v.log</title>
        <link>https://velog.io/</link>
        <description>얼렁뚱땅 바보 학부생...</description>
        <lastBuildDate>Wed, 17 Jun 2026 21:56:02 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>inhwaaa_v.log</title>
            <url>https://velog.velcdn.com/images/inhwaaa_v/profile/a36d6a6e-5811-44db-8709-0a1487503af4/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. inhwaaa_v.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/inhwaaa_v" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[LLM과 VLM]]></title>
            <link>https://velog.io/@inhwaaa_v/LLM%EA%B3%BC-VLM-x4colyhf</link>
            <guid>https://velog.io/@inhwaaa_v/LLM%EA%B3%BC-VLM-x4colyhf</guid>
            <pubDate>Wed, 17 Jun 2026 21:56:02 GMT</pubDate>
            <description><![CDATA[<h1 id="llm과-vlm">LLM과 VLM</h1>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/f5505ee8-fcfc-4e9d-a8e8-bc9cf72514ee/image.png" alt=""></p>
<h2 id="llm">LLM</h2>
<p> LLM은 Large Language Model의 약자로, 대량의 텍스트를 학습한 언어 모델이다. LLM은 완성된 문장을 한 번에 만들어내는 것이 아니라, 앞에 나온 token들을 보고 다음 token의 확률분포를 예측하는 방식으로 문장을 생성한다.</p>
<p> 즉, LLM의 기본 동작은 다음과 같다.</p>
<pre><code>P(next token | previous tokens)</code></pre><p> 예를 들어 입력 문장이 “이 이미지에는 고양이가” 라고 할 때, 모델은 다음에 올 수 있는 token 후보들에 대한 확률을 계산한다.</p>
<table>
<thead>
<tr>
<th>다음 token 후보</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>있다</td>
<td>“고양이가 있다”</td>
</tr>
<tr>
<td>보인다</td>
<td>“고양이가 보인다”</td>
</tr>
<tr>
<td>앉아</td>
<td>“고양이가 앉아…”</td>
</tr>
<tr>
<td>있다가</td>
<td>다른 문장 전개 가능</td>
</tr>
</tbody></table>
<p> 모델은 이 후보 token들에 확률을 부여하고, 선택된 token을 문장 뒤에 붙인다. 그리고 다시 그다음 token의 확률분포를 예측한다. 이 과정을 반복하면서 문장이 생성된다. 이처럼 LLM은 완성된 문장을 한 번에 꺼내는 모델이 아니라, 앞에 나온 token들을 보고 다음 token의 확률분포를 예측한다. 즉, LLM은 <strong>next token prediction</strong>을 반복해서 자연어 문장을 생성하는 모델이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/162565fb-6571-462b-8146-b2393dc42320/image.png" alt=""></p>
<p> 이때, LLM이 다음 token의 확률분포를 만들었다고 해서 항상 가장 확률이 높은 token만 고르는 것은 아니다. 어떤 방식으로 token을 선택하느냐에 따라 생성 결과가 달라진다. 예를 들어 temperature가 낮으면 확률이 높은 token에 더 몰리기 때문에 안정적이고 보수적인 문장이 나온다. 반대로 temperature가 높으면 낮은 확률의 token도 선택될 가능성이 커져서 더 다양하지만 불안정한 결과가 나올 수 있다. 즉, LLM의 문장 생성은 단순히 확률이 높은 답을 꺼내는 것이 아니라 다음 token 확률 분포 예측과 token 선택 규칙이 맞물려 이뤄진다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>의미</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>Greedy</td>
<td>가장 확률이 높은 token만 선택</td>
<td>안정적이지만 반복적일 수 있음</td>
</tr>
<tr>
<td>Sampling</td>
<td>확률분포에서 무작위로 선택</td>
<td>다양하지만 불안정할 수 있음</td>
</tr>
<tr>
<td>Temperature</td>
<td>확률분포를 날카롭게 또는 부드럽게 조절</td>
<td>낮으면 보수적, 높으면 다양함</td>
</tr>
<tr>
<td>Top-k</td>
<td>확률 상위 k개 후보에서만 선택</td>
<td>너무 낮은 확률 후보 제거</td>
</tr>
<tr>
<td>Top-p</td>
<td>누적확률 p 안의 후보에서 선택</td>
<td>문맥마다 후보 수가 달라짐</td>
</tr>
</tbody></table>
<h2 id="vlm">VLM</h2>
<p>  VLM은 Vision-Language Model의 약자로, 이미지 정보와 언어 정보를 함께 처리하는 모델이다. 이는 이미지를 feature로 바꾸고, 언어모델이 그 feature와 질문을 함께 보고 텍스트 답변을 생성한다. 쉽게 말해서, 이미지 정보를 LLM에 연결해서 자연어 답변을 생성하는 모델을 의미한다.</p>
<p> 이때, LLM은 원래 텍스트 token을 입력으로 받아 다음 token을 예측하는 모델이다. 그런데 이미지는 텍스트 token이 아니기 때문에 그대로 LLM에 넣을 수 없다. 그래서 VLM은 이미지를 먼저 feature로 바꾼 뒤, 그 feature를 LLM이 처리할 수 있는 형태로 연결한다. 그러면 언어모델은 그 feature와 질문을 함께 보고 텍스트 답변을 생성한다. 즉, VLM은 이미지 자체를 바로 언어모델에 넣는 것이 아니라, 이미지를 언어모델이 이해 가능한 정보로 변환한 뒤 질문과 함께 처리하는 구조이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/c0bec382-1a20-466d-9189-45ee918456be/image.png" alt=""></p>
<p> VLM은 보통 다음의 세 부분으로 구성된다. VLM은 이미지를 그대로 언어모델에 넣지 않고, Vision Encoder가 이미지를 feature로 바꾸고, Projector가 이를 LLM 입력 형태로 맞춘 뒤, Language Model이 이미지 feature와 질문 token을 함께 보고 답변 token을 생성하는 구조를 지니고 있다. </p>
<p> VLM의 답변 생성 방식은 LLM처럼 <strong>next token prediction</strong> 방식으로 답변을 생성한다. 다만 일반 LLM과 다른 점은, 입력에 텍스트만 있는 것이 아니라 <strong>이미지 feature</strong>도 함께 들어간다는 점에 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/310f8bf0-d51b-4739-8f28-16d6b447f308/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>Vision Encoder</td>
<td>이미지를 feature로 변환</td>
</tr>
<tr>
<td>Projector</td>
<td>이미지 feature를 LLM이 처리할 수 있는 형태로 맞춤</td>
</tr>
<tr>
<td>Language Model</td>
<td>이미지 feature와 질문 token을 함께 보고 답변 token 생성</td>
</tr>
</tbody></table>
<h1 id="clip과의-차이-정리">CLIP과의 차이 정리</h1>
<table>
<thead>
<tr>
<th>구분</th>
<th>CLIP</th>
<th>VLM</th>
</tr>
</thead>
<tbody><tr>
<td>입력</td>
<td>이미지 + 텍스트 후보</td>
<td>이미지 + 질문/명령</td>
</tr>
<tr>
<td>처리</td>
<td>이미지 embedding과 텍스트 embedding 비교</td>
<td>이미지 feature를 LLM에 연결</td>
</tr>
<tr>
<td>출력</td>
<td>유사도 점수</td>
<td>자연어 답변 (문장)</td>
</tr>
<tr>
<td>대표 작업</td>
<td>zero-shot 분류, 이미지 검색</td>
<td>image captioning, VQA</td>
</tr>
<tr>
<td>핵심</td>
<td>같은 embedding space에서 비교</td>
<td>이미지 정보를 이용해 텍스트 생성</td>
</tr>
</tbody></table>
<p> CLIP은 VLM을 이해하기 위한 좋은 출발점이다. 하지만 CLIP은 직접 대답을 생성하지 않는다. VLM은 이미지 feature를 언어모델에 연결하여 답변을 생성한다는 점에서 한 단계 더 생성형 AI에 가깝다.</p>
<p> 하지만, VLM은 이미지를 보고 답변을 생성하지만, 항상 정확하게 본다고 보장할 수는 없다. 특히 아래 상황에서는 결과를 조심스럽게 해석해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/9f648b24-4814-496d-ab78-95f152b1609c/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CLIP과 멀티모달 AI]]></title>
            <link>https://velog.io/@inhwaaa_v/CLIP%EA%B3%BC-%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%8B%AC-AI</link>
            <guid>https://velog.io/@inhwaaa_v/CLIP%EA%B3%BC-%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%8B%AC-AI</guid>
            <pubDate>Wed, 17 Jun 2026 21:53:57 GMT</pubDate>
            <description><![CDATA[<h1 id="clip과-멀티모달-ai">CLIP과 멀티모달 AI</h1>
<h2 id="clip">CLIP</h2>
<p> 멀티모달 AI는 <strong>이미지, 텍스트, 음성처럼 서로 다른 형태의 데이터를 함께 다루는 AI</strong>이다. 예를 들어, 이미지와 텍스트를 함께 다루는 경우, 사진과 문장을 비교하고 모델은 이미지와 각 문장이 얼마나 잘 맞는지 계산할 수 있다.</p>
<p> CLIP은 이미지와 텍스트를 같은 벡터 공간에서 비교하는 모델이다. 즉, 이미지는 Image Encoder에 넣고, 텍스트는 Text Encoder에 넣어서 각각 embedding vector로 만든다. 그 다음 두 벡터가 얼마나 가까운지 비교하여 이미지와 문장의 의미가 잘 맞으면 두 벡터가 가까워지고, 의미가 다르면 멀어진다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/6b5955e0-ccfa-41a2-8ae1-cd25b89c5f01/image.png" alt=""></p>
<p> 이때 중요한 것은 Image Encoder와 Text Encoder의 출력은 같은 차원이어야 한다. CLIP은 Image Encoder가 만든 이미지 embedding과 Text Encoder가 만든 텍스트 embedding을 같은 벡터 공간에서 비교한다. 이때 두 embedding의 차원이 같아야 cosine similarity나 dot product를 계산할 수 있다. 예를 들어 이미지 feature 행렬이 <code>[이미지 개수, d]</code>이고 텍스트 feature 행렬이 <code>[텍스트 개수, d]</code>라면, 텍스트 feature를 전치하여 <code>[d, 텍스트 개수]</code>로 만든 뒤 행렬곱을 수행할 수 있다. 그러면 <code>[이미지 개수, 텍스트 개수]</code> 크기의 similarity matrix가 만들어진다. 이 행렬의 각 값은 특정 이미지와 특정 텍스트가 얼마나 잘 맞는지를 나타낸다. 따라서 Image Encoder와 Text Encoder의 출력 차원 <code>d</code>가 같아야 행렬곱을 통해 이미지-텍스트 유사도 matrix를 계산할 수 있다.</p>
<h2 id="zero-shot-classification">Zero-shot Classification</h2>
<p> CLIP의 가장 직관적인 활용은 zero-shot 이미지 분류로, 이는 새로운 이미지 분류기를 따로 학습하지 않는다는 의미를 지닌다. 즉, 분류 클래스가 바뀌면 모델 구조를 바꾸는 것이 아니라 후보 문장만 바꾸기에 모델 구조를 바꾸거나 새 classifier를 학습하지 않아도, 후보 문장만 바꿔서 새로운 분류 문제를 만들 수 있다.</p>
<p> 여기서 CLIP과 일반 이미지 분류의 차이점을 알 수 있다. 일반 이미지 분류기는 학습할 때 미리 정해진 클래스 중 하나를 선택한다. 따라서 새로운 클래스를 분류하려면 보통 출력층을 바꾸거나 새로운 데이터로 다시 학습해야 한다. 반면 CLIP은 이미지를 고정된 클래스 번호로 바로 분류하는 것이 아니라, 이미지와 후보 문장을 각각 embedding으로 변환한 뒤 유사도를 비교한다. 예를 들어 <code>a photo of a cat</code>, <code>a photo of coffee</code>, <code>a photo of a rocket</code> 같은 문장을 후보로 만들고, 이미지 embedding과 가장 가까운 문장을 선택한다. 즉 CLIP은 새로운 분류기를 따로 학습하지 않고 후보 문장만 바꾸어 이미지를 분류하는 zero-shot classification을 수행한다. </p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/f1a799cd-4dc7-4796-a33b-3da69e21b7d0/image.png" alt=""></p>
<p> 이때, 클래스 이름을 문장으로 바꾸는 이유는 CLIP이 단순한 클래스 이름만 학습한 것이 아니라 이미지와 자연어 문장의 관계를 학습한 모델이기 때문이다. 그래서 <code>cat</code>, <code>coffee</code>, <code>rocket</code>처럼 단어만 넣는 것보다 <code>a photo of a cat</code>, <code>a photo of coffee</code>, <code>a photo of a rocket</code>처럼 자연스러운 문장 형태로 바꾸면 이미지-텍스트 학습 방식과 더 잘 맞는다. 클래스 이름을 문장 prompt로 바꾸면 모델이 이미지를 클래스 번호가 아니라 어떤 문장과 가장 의미적으로 가까운지를 기준으로 비교할 수 있다. 이 때문에 zero-shot classification에서는 클래스 이름을 후보 문장으로 만들어 사용한다.</p>
<p> 또, CLIP은 텍스트를 Text Encoder에 그대로 넣어 embedding으로 변환하는 방식이기에 같은 의미를 가진 문장이라도 표현 방식이 달라지면 텍스트 embedding도 달라진다. 예를 들어 <code>a cat</code>, <code>a photo of a cat</code>, <code>a close-up photo of a cat</code>, <code>a cute orange cat</code>은 모두 고양이를 의미하지만, 각 문장에 포함된 단어와 구체성이 다르기 때문에 이미지 embedding과의 유사도 점수가 달라질 수 있다. 즉 CLIP은 핵심 단어 하나만 비교하는 것이 아니라 문장 전체의 의미를 벡터로 바꿔 비교하므로 prompt 표현이 결과에 영향을 줄 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/14e97440-75cb-4089-b7d7-a32026028d3f/image.png" alt=""></p>
<blockquote>
<p> 실제 CLIP이 동작하는 경우, 텍스트를 그대로 Encoder에 넣으면 모델은 이미지와 각 문장의 embedding을 만든 뒤 유사도를 비교한다. 이때, probability는 후보 문장들 사이에서 softmax를 취한 값이며, 후보 문장을 어떻게 쓰는지에 따라 결과가 달라질 수 있다. 다시 말해, 같은 의미라도 표현 방식이 달라지면 embedding도 달라진다. 이러한 이유에서 실무에서는 보통 여러 prompt를 비교하고, 안정적인 표현을 찾는다</p>
<p>prompt가 달라짐
→ text embedding이 달라짐
→ image embedding과의 유사도도 달라짐
→ 결과가 바뀔 수 있음</p>
</blockquote>
<h2 id="contrastive-learning">Contrastive Learning</h2>
<p> CLIP에서의 학습의 핵심은 핵심은 contrastive learning이다. 이는 한 batch 안에 이미지와 텍스트 쌍이 여러 개 있을 때, 올바른 쌍의 유사도는 높이고 틀린 쌍의 유사도는 낮춘다. 쉽게 말해, 올바른 이미지-텍스트 쌍은 가깝게 만들고, 틀린 쌍은 멀게 만드는 방식이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/42060d68-0e70-4a2e-b046-ce6c712123dc/image.png" alt=""></p>
<pre><code>올바른 image-text pair: similarity를 높인다.
틀린 image-text pair: similarity를 낮춘다.</code></pre><h2 id="clip의-장단점">CLIP의 장단점</h2>
<h3 id="장점">장점</h3>
<ul>
<li>새 클래스를 추가할 때 모델을 다시 학습하지 않아도 된다.</li>
<li>이미지 분류뿐 아니라 이미지 검색, 텍스트 기반 검색, 데이터 필터링에도 사용할 수 있다.</li>
<li>자연어 prompt를 통해 사람이 원하는 개념을 비교적 쉽게 지정할 수 있다</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li>prompt 표현에 민감할 수 있다.</li>
<li>학습 데이터의 편향이 결과에 반영될 수 있다.</li>
<li>세밀한 위치 추론, 개수 세기, 복잡한 관계 판단은 약할 수 있다.</li>
<li>높은 점수가 항상 정답이나 안전한 판단을 의미하지 않는다.</li>
</ul>
<p>따라서 CLIP 결과는 &quot;정답&quot;이라기보다 이미지와 문장의 의미적 유사도 신호로 보는 것이 좋다.</p>
<blockquote>
<p>CLIP의 출력 결과는 출력 점수는 이미지와 문장 사이의 의미적 유사도 신호이지 항상 정답을 의미하는 것은 아니다. 각 후보 문장마다 유사도 점수를 만들고 이 점수를 softmax에 넣어 후보 문장들 사이의 상대적 확률처럼 점수를 볼 수 있긴 하지만, 이는 후보 문장들 중 상대적으로 어떤 문장이 가장 이미지와 가까운가를 보는 값이지 절대적인 신뢰도가 아니다. 이 값은 prompt 표현에 따라 결과가 달라질 수 있고, 학습 데이터의 편향이 결과에 반영될 수 있다. 또한 세밀한 위치 추론, 개수 세기, 복잡한 관계 판단에는 약할 수 있으며, 높은 similarity score가 항상 안전하고 정확한 판단을 보장하지 않는다. 따라서 실제 서비스에서는 CLIP 결과를 그대로 사용하기보다 추가적인 검증, 사람의 확인, 도메인 데이터 기반 평가, 안전성 점검을 거쳐야 한다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Attention과 Transformer]]></title>
            <link>https://velog.io/@inhwaaa_v/Attention%EA%B3%BC-Transformer</link>
            <guid>https://velog.io/@inhwaaa_v/Attention%EA%B3%BC-Transformer</guid>
            <pubDate>Wed, 17 Jun 2026 21:52:36 GMT</pubDate>
            <description><![CDATA[<h1 id="attention과-transformer-내부-동작">Attention과 Transformer 내부 동작</h1>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/acb238b2-7347-475a-951a-bb29130a7cef/image.png" alt=""></p>
<h2 id="attention-등장-배경-→-왜-attention이-필요했는가">Attention 등장 배경 (→ 왜 Attention이 필요했는가?)</h2>
<p> 기존 RNN 기반 Sequence-to-Sequence 모델은 입력 문장을 순서대로 읽고, 마지막 hidden state 또는 하나의 context vector <code>c</code>에 입력 전체의 정보를 압축한 뒤 decoder로 전달하는 구조이다. 예를 들어 영어 문장 <code>we see the sky</code>를 이탈리아어 <code>vediamo il cielo</code>로 번역할 때, encoder는 입력 단어들을 순서대로 처리하여 hidden state를 만들고, decoder는 그 정보를 바탕으로 출력 단어를 하나씩 생성한다.</p>
<p> 그러나 입력 문장의 길이가 길어질수록 모든 정보를 고정 크기의 vector 하나에 담기 어려워지는 <strong>bottleneck 문제</strong>가 발생한다. 즉, 긴 sequence의 중요한 정보가 마지막 hidden state나 context vector에 충분히 보존되지 못할 수 있다.</p>
<p> 이를 해결하기 위해 등장한 것이 <strong>Attention</strong>이다. Attention은 decoder가 매 출력 시점마다 입력 sequence 전체를 다시 참고하면서, 현재 생성하려는 단어와 관련 있는 입력 부분에 더 큰 가중치를 부여하는 방식이다. 예를 들어 <code>vediamo</code>를 생성할 때는 <code>we see</code> 부분을 더 많이 참고하고, <code>il</code>을 생성할 때는 <code>the</code>, <code>cielo</code>를 생성할 때는 <code>sky</code>를 더 많이 참고한다. 따라서 Attention을 사용하면 하나의 고정된 context vector에만 의존하지 않고, 출력 시점마다 필요한 입력 정보를 선택적으로 활용할 수 있다.</p>
<h2 id="초기-attention-방식">초기 Attention 방식</h2>
<h3 id="bahdanau-attention">Bahdanau Attention</h3>
<p> RNN Encoder-Decoder에 붙은 대표적인 초기 Attention 방식으로, Decoder의 현재 상황과 Encoder의 각 hidden state를 비교해서 어느 입력 단어를 볼지 점수로 정한다.</p>
<p> 아래의 수식에서 <code>f_att</code>는 작은 신경망 또는 linear layer를 의미한다. 아래의 수식을 통해 alignment score를 계산하고, softmax로 attention weight를 만든 뒤 context vector를 만든다. 여기서 <code>e_{t,i}</code>는 decoder의 현재 시점 <code>t</code>가 입력 위치 <code>i</code>를 얼마나 참고해야 하는지 나타내는 점수이고, <code>a_{t,i}</code>는 softmax를 거친 attention weight이다. <code>c_t</code>는 attention weight를 이용해 encoder hidden state들을 가중합한 context vector이다. 이렇게 alignment score를 계산하고, 이를 softmax로 attention weight로 바꾼 뒤, hidden state들의 weighted sum으로 context vector를 만든다.</p>
<blockquote>
<p>score 계산 → softmax로 비율화 → weighted sum으로 정보 결합</p>
</blockquote>
<p>$$
e_{t,i} = f_{att}(s_{t-1}, h_i)
$$</p>
<p>$$
a_{t,i} = softmax(e_{t,i})
$$</p>
<p>$$
c_t = Σ<em>i a</em>{t,i} h_i
$$</p>
<p> 즉, 아래와 같은 흐름으로 정리할 수 있다.</p>
<ol>
<li>decoder 상태 $s_{t-1}$와 encoder hidden state h_i를 비교한다.</li>
<li>alignment score $e_{t,i}$를 만든다.</li>
<li>softmax로 attention weight $a_{t,i}$를 만든다.</li>
<li>encoder hidden state들을 attention weight로 가중합한다.</li>
<li>context vector $c_t$를 만든다.</li>
<li>decoder가 $c_t$를 이용해 다음 단어를 생성한다.</li>
</ol>
<pre><code>Attention Layer를 일반화하면?

일반 Attention Layer는 다음과 같다.

입력:
- Query vector q
- Data vectors X

계산:
1. q와 각 X_i의 유사도 e_i 계산
2. e_i를 softmax해서 attention weight a_i 계산
3. X_i들을 a_i로 가중합해서 output y 생성

즉, Query가 data vector들 중에서 필요한 정보를 찾고,
그 비율대로 정보를 섞어 output vector를 만드는 연산을 하는 것이 Attention Layer이다. </code></pre><h3 id="luong-attention">Luong Attention</h3>
<p> Decoder가 현재 출력에 필요한 encoder hidden state를 고르는 것이라는 목적은 같으나, 점수 계산 방식에서 Bahdanau는 decoder 상태와 encoder hidden state를 작은 신경망에 넣어서 점수를 계산하는데 반해, Luong Attention은 좀 더 단순하게 dot product 계열로 계산한다. (두 벡터를 바로 내적해서 비슷하면 점수 높게 주는 것)</p>
<p>$$
score(s_t, h_i) = s_t^T h_i
$$</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Bahdanau Attention</th>
<th>Luong Attention</th>
</tr>
</thead>
<tbody><tr>
<td>별명</td>
<td>Additive Attention</td>
<td>Multiplicative / Dot Attention</td>
</tr>
<tr>
<td>점수 계산</td>
<td>작은 신경망으로 계산</td>
<td>내적 또는 행렬 곱으로 계산</td>
</tr>
<tr>
<td>느낌</td>
<td>“둘을 합쳐서 신경망이 판단”</td>
<td>“두 벡터가 얼마나 비슷한지 바로 계산”</td>
</tr>
<tr>
<td>장점</td>
<td>표현력이 좋음</td>
<td>계산이 단순하고 빠름</td>
</tr>
<tr>
<td>공통점</td>
<td>둘 다 attention score → softmax → weighted sum</td>
<td></td>
</tr>
</tbody></table>
<blockquote>
<p>두 방식 모두 현재 decoder가 입력 중 어디를 봐야 하는지 점수화하려는 목적을 지니고 있다.</p>
</blockquote>
<h2 id="self-attention과-qkv">Self-Attention과 Q/K/V</h2>
<h3 id="qkv-attention-계산-scaled-dot-product-attention">Q/K/V Attention 계산 (Scaled Dot-Product Attention)</h3>
<p> 입력 token embedding을 <code>X</code>라고 할 때, Transformer에서는 <code>X</code>에 서로 다른 가중치 행렬을 곱해서 Q, K, V를 만들고, Query와 Key를 비교해 점수를 구한다.</p>
<pre><code>                                                     $Q = XW_Q$ / $K = XW_K$ / $V = XW_V$</code></pre><p>$$
score = QK^T
$$</p>
<p> 이 점수에 softmax를 적용해서 attention weight를 만들고, 그 weight와 Value를 섞는 것이 Transformer에서의 Attention의 핵심이다. </p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/856c98fe-6444-425c-b40b-b4e7b4117ef7/image.png" alt=""></p>
<p>이를 정리하면 다음과 같다. </p>
<ol>
<li>입력 X에서 Q, K, V를 만든다.</li>
<li>$QK^T$를 계산해서 query와 key의 유사도를 구한다.</li>
<li>$\sqrt{d_k}$로 나누어 scale을 조정한다.</li>
<li>softmax를 적용해서 attention weight를 만든다.</li>
<li>attention weight와 V를 곱해서 output을 만든다.</li>
</ol>
<table>
<thead>
<tr>
<th>기호</th>
<th>의</th>
</tr>
</thead>
<tbody><tr>
<td><code>Q</code> Query</td>
<td>지금 내가 찾고 싶은 정보</td>
</tr>
<tr>
<td><code>K</code> Key</td>
<td>각 token이 가진 검색용 특징</td>
</tr>
<tr>
<td><code>V</code> Value</td>
<td>실제로 가져올 정보</td>
</tr>
</tbody></table>
<h3 id="√d_k-scaling">√d_k scaling</h3>
<p> <code>QK^T</code>는 Query와 Key의 dot product이다. 그런데 <code>d_k</code>, 즉 Key/Query 벡터의 차원이 커질수록 dot product 값이 커질 수 있다. 이 값이 너무 커지면 softmax가 한쪽으로 심하게 쏠린다.</p>
<p>예를 들어, softmax([1, 2, 3])은 비교적 여러 값에 분산될 수 있지만, softmax([10, 20, 30])은 큰 값 하나에 거의 몰리는 경향성을 지닌다. 이렇게 되면 attention이 한두 token에만 지나치게 집중되고, gradient가 작아져 학습이 불안정해질 수 있다. 그래서 <code>√d_k</code>로 나누어 score의 크기를 줄인다.</p>
<blockquote>
<p>Scaled Dot-Product Attention에서 <code>√d_k</code>로 나누는 이유는 Query와 Key의 dot product 값이 차원이 커질수록 커져 softmax가 한쪽으로 과도하게 쏠리는 것을 방지하기 위해서이다. 이를 통해 attention weight가 지나치게 뾰족해지는 것을 완화하고, gradient가 작아져 학습이 불안정해지는 문제를 줄일 수 있다.
<img src="https://velog.velcdn.com/images/inhwaaa_v/post/6c5f4d3e-477e-4423-a728-06e15b987812/image.png" alt=""></p>
</blockquote>
<h3 id="self-attention">Self-Attention</h3>
<p> Self-Attention은 <strong>같은 sequence 안의 token들끼리 서로를 참고하는 Attention</strong>이다. 일반 RNN Attention에서는 decoder가 encoder의 hidden state를 참고했던 것과는 달리, Self-Attention에서는 같은 문장 안의 token들이 서로를 참고한다. 예를 들어, I love deep  learning이라는 문장이 있을 때 각 token이 문장 안의 다른 token들을 보면서 자신의 표현을 새롭게 만든다. 아래의 예시를 보면 <code>learning</code>은 <code>deep</code>을 많이 참고할 수 있고, <code>love</code>는 <code>I</code>를 많이 참고할 수 있다. 이렇게 token들이 서로의 관계를 계산해 더 문맥적인 표현으로 바뀌는 것이 Self-Attention이다.</p>
<pre><code>I       → I, love, deep, learning 중 어디를 볼까?
love    → I, love, deep, learning 중 어디를 볼까?
deep    → I, love, deep, learning 중 어디를 볼까?
learning→ I, love, deep, learning 중 어디를 볼까?</code></pre><p> Self-Attention에서는 입력 <code>X</code> 하나에서 <code>Q</code>, <code>K</code>, <code>V</code>가 모두 나온다. 그래서 <strong>모든 token이 Query가 될 수도 있고, Key/Value가 될 수도 있다.</strong>  즉, Self-Attention은 같은 입력 sequence 내부에서 token들끼리 <code>Q</code>, <code>K</code>, <code>V</code> 관계를 통해 서로 얼마나 참고할지 계산하는 방식이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/8ac7eb0c-e4fe-4898-9df0-96bf45d373d6/image.png" alt="">
S</p>
<table>
<thead>
<tr>
<th>요소</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>Query <code>Q</code></td>
<td>현재 token이 찾는 정보</td>
</tr>
<tr>
<td>Key <code>K</code></td>
<td>다른 token들이 가진 검색 기준</td>
</tr>
<tr>
<td>Value <code>V</code></td>
<td>실제로 가져와서 섞을 정보</td>
</tr>
</tbody></table>
<p><strong>추가 중요 포인트 - Score와 Attention Weight 비교</strong></p>
<ul>
<li>Score는 softmax 전의 유사도이고, attention weight는 softmax 후의 참고 비율이다.</li>
<li>Attention weight의 각 행은 확률 분포처럼 합이 1이 된다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/12e887e5-ffe1-48f1-a738-ee28998ffe59/image.png" alt=""></p>
<h3 id="rnn과-self-attention의-차이">RNN과 Self-Attention의 차이</h3>
<p> RNN은 입력을 순서대로 처리한다. 따라서 <code>x3</code>를 처리하려면 <code>x2</code>가 끝나야 하고, <code>x2</code>를 처리하려면 <code>x1</code>이 끝나야 한다. 이 때문에 병렬화가 어렵고, 긴 문장에서 멀리 떨어진 정보가 잘 전달되기 어렵다.</p>
<p> 반면 Transformer는 Self-Attention을 사용해 모든 token 관계를 한 번에 계산한다. 이 관계 계산을 행렬곱으로 한 번에 처리할 수 있기 때문에 RNN보다 병렬화에 유리하다.</p>
<pre><code>x1 ↔ x2
x1 ↔ x3
x1 ↔ x4
x2 ↔ x3
x2 ↔ x4
x3 ↔ x4</code></pre><p> 이러한 Self-Attention은 입력 벡터들의 순서를 바꾸면 출력도 같은 방식으로 순서만 바뀌는 <strong>permutation equivariant</strong> 성질을 지닌다. 따라서 Self-Attention 자체는 벡터들의 집합에 대해 동작한다. 이러한 특성 때문에 Self-Attention은 token 간 관계는 잘 계산하지만, 그 자체만으로는 순서 정보를 알지 못한다. 이것이 Transformer에 Positional Encoding이 필요한 이유이다. 즉 ,Self-Attention은 token 간 관계를 계산하고, Positional Encoding이 token의 위치 정보를 보완한다.</p>
<h2 id="positional-encoding">Positional Encoding</h2>
<p> Self-Attention은 같은 sequence 안의 token들이 서로를 얼마나 참고할지 계산하는 구조이다. 즉, token 간 관계는 잘 계산한다. 하지만 Self-Attention 자체는 입력을 <strong>순서가 있는 문장</strong>이라기보다 <strong>벡터들의 집합</strong>처럼 다룬다. 그래서 입력 벡터들의 순서를 바꾸면 출력도 같은 방식으로 순서만 바뀌는 <strong>permutation equivariant</strong> 성질을 가진다. 이 말은 곧, Self-Attention만으로는 token의 순서 정보를 명시적으로 알 수 없다는 뜻이다.</p>
<p> 예를 들어 다음 두 문장은 단어는 비슷하지만 순서가 다르다.</p>
<pre><code>I love you
You love I</code></pre><p> 사람은 순서가 다르기 때문에 의미가 다르다는 것을 알지만, Self-Attention만 사용하면 어떤 token이 몇 번째 위치에 있는지에 대한 정보가 부족하다. 따라서 Transformer는 token embedding에 <strong>Positional Encoding</strong>을 더해준다.</p>
<pre><code>Transformer 입력 = token embedding + positional encoding</code></pre><p> 이렇게 하면 모델은 token의 의미뿐 아니라 <strong>그 token이 문장 안에서 어디에 있는지</strong>도 함께 알 수 있다. 논문에서도 Transformer는 recurrence와 convolution을 사용하지 않기 때문에 sequence 순서를 활용하려면 token의 상대적 또는 절대적 위치 정보를 주입해야 하며, 이를 위해 encoder와 decoder 입력 embedding에 positional encoding을 더한다고 설명한다.</p>
<p> 또한, Transformer 논문에서 사용한 대표적인 방식은 <strong>sinusoidal positional encoding</strong>이다. 이는 sin과 cos 함수를 이용해 위치별로 서로 다른 패턴의 벡터를 만드는 방식이다. 즉, 짝수 차원에는 <code>sin</code> 값을 넣고, 홀수 차원에는 <code>cos</code> 값을 넣어 각 위치마다 고유한 위치 벡터가 만들어진다.</p>
<p> 이러한 sin/cos positional encoding은 위치마다 서로 다른 주기 패턴을 가진다. 가까운 위치는 positional encoding 패턴이 비교적 비슷하고, 먼 위치는 다른 패턴을 보인다.</p>
<p> 그래서 모델은 token의 절대 위치뿐 아니라 상대적인 거리 정보도 어느 정도 학습할 수 있다. Transformer 논문에서도 sinusoidal positional encoding을 사용한 이유 중 하나로, 특정 offset <code>k</code>에 대해 <code>PE(pos+k)</code>가 <code>PE(pos)</code>의 선형 함수로 표현될 수 있어 상대 위치를 학습하기 좋을 것이라고 설명한다.</p>
<blockquote>
<p>쉽게 말해, pos 1과 pos 2는 비교적 가까운 위치라 비슷한 패턴 / pos 1과 pos 100은 먼 위치라 다른 패턴을 보여 위치 차이를 모델이 구분할 수 있게 된다.</p>
</blockquote>
<p> 다만, Positional Encoding 자체가 단어 의미를 담는 것은 아니다. 예를 들어, <code>love</code>라는 단어의 의미는 token embedding에 들어 있다. Positional Encoding은 단지 그 token이 몇 번째 위치에 있는지를 알려준다. 두 벡터를 더해서 Transformer에 넣으면, 모델은 이 단어가 무엇인지와 이 단어가 어디에 있는지를 함께 사용할 수 있다.</p>
<pre><code>** positional encoding 자체가 의미를 담는 것은 아니고, 의미 표현은 token embedding과 여러 attention layer를 거치며 형성된다.
token embedding = 단어의 의미 정보
positional encoding = token의 위치 정보
</code></pre><p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/b84c6b20-1245-4659-b471-2187fe532baf/image.png" alt=""></p>
<h2 id="encoder-attention과-decoder-causal-mask">Encoder Attention과 Decoder Causal Mask</h2>
<p> BERT나 ViT처럼 입력 전체를 이해하는 encoder는 보통 모든 token이 서로를 볼 수 있다. 반면 GPT 계열 decoder는 다음 token을 예측해야 하므로 미래 token을 보면 안 된다. 이를 위해 causal mask를 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/9a18240f-b1bd-403a-94d3-f941e6ae3252/image.png" alt=""></p>
<h3 id="encoder-attention">Encoder Attention</h3>
<p> Encoder Attention은 입력 전체를 이해하기 위한 attention이다. Encoder에서는 각 token이 자기 자신을 포함해 모든 token을 볼 수 있다. 왜냐하면 encoder의 목적은 입력 문장 전체를 이해하는 것이기 때문이다.</p>
<ul>
<li><p>Encoder에서는 각 입력 위치가 자기 자신을 포함한 모든 입력 위치를 참고할 수 있다. 행렬 관점에서는 모든 query-key 쌍에 대해 score가 계산된다.</p>
<p>  <img src="https://velog.velcdn.com/images/inhwaaa_v/post/d8ba73b6-f419-498a-8648-71f8cbf56705/image.png" alt=""></p>
</li>
</ul>
<ul>
<li>Encoder-style attention에서는 별도 causal mask 없이 모든 token을 볼 수 있다.</li>
<li>BERT나 ViT 같은 encoder-style 모델은 전체 입력을 보고 분류, 이해, 표현 학습을 수행한다.</li>
</ul>
<blockquote>
<p><strong>Encoder Attention</strong>
= 모든 token이 모든 token을 볼 수 있음
= 입력 전체 이해에 사용
= BERT, ViT 같은 encoder-style 모델에서 사용</p>
</blockquote>
<h3 id="decoder-causal-mask">Decoder Causal Mask</h3>
<p> 이와 달리, GPT 같은 decoder-only 모델은 현재까지 나온 token을 보고 <strong>다음 token을 예측</strong>하는 모델이다. 예를 들어, I love deep라는 문장이 있을 때 deep까지만 보고 다음 단어 learning을 예측해야 한다.</p>
<p> 학습할 때 현재 위치가 미래 token을 볼 수 있으면 문제가 생긴다. 예를 들어 <code>deep</code> 위치에서 이미 뒤에 있는 <code>learning</code>을 볼 수 있다면, 모델은 정답을 미리 보고 맞히는 꼴이 된다. 이러면 실제 생성 상황과 맞지 않는다. 실제로 글을 생성할 때는 미래 단어를 모르기 때문이다. 그래서 decoder에서는 미래 token을 가려야 한다.</p>
<p> 이를 위해 Causal Mask를 활용한다. Causal Mask는 decoder가 미래 token을 보지 못하도록 막는 mask이다. 현재 위치 <code>i</code>는 자기 자신과 이전 위치 <code>j ≤ i</code>까지만 볼 수 있다.</p>
<p> 이러한 Causal Mask는 softmax 전에 적용된다. attention score가 있다고 할 때, 미래 token에 해당하는 score를 아주 작은 값, 보통 <code>-inf</code>로 바꾼다.  그 다음 softmax를 적용하면 <code>-inf</code>였던 위치의 attention weight는 0이 된다. 즉, 미래 token은 attention weight가 0이 되므로 참고하지 못한다.</p>
<p>$$
scores = QK^T / √d_k
$$</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Encoder Attention</th>
<th>Decoder Causal Attention</th>
</tr>
</thead>
<tbody><tr>
<td>볼 수 있는 범위</td>
<td>모든 token</td>
<td>과거 token + 현재 token</td>
</tr>
<tr>
<td>미래 token 접근</td>
<td>가능</td>
<td>불가능</td>
</tr>
<tr>
<td>mask 사용</td>
<td>보통 causal mask 없음</td>
<td>causal mask 사용</td>
</tr>
<tr>
<td>대표 모델</td>
<td>BERT, ViT</td>
<td>GPT 계열</td>
</tr>
<tr>
<td>목적</td>
<td>입력 전체 이해</td>
<td>다음 token 예측, 텍스트 생성</td>
</tr>
</tbody></table>
<blockquote>
<p>Decoder Causal Attention
= 현재 token이 미래 token을 보지 못하게 함
= causal mask를 사용
= GPT 계열의 다음 token 예측에 필요</p>
<p> Encoder Attention에서는 입력 전체를 이해해야 하므로 각 token이 자기 자신을 포함한 모든 token을 참고할 수 있다. 반면 GPT 계열 decoder는 현재까지의 token을 바탕으로 다음 token을 예측하는 auto-regressive 모델이므로, 현재 위치에서 미래 token을 보면 안 된다. 이를 방지하기 위해 causal mask를 사용하며, 현재 위치 <code>i</code>는 <code>j ≤ i</code>인 위치만 참고할 수 있다. Causal mask는 softmax 전에 미래 위치의 score를 매우 작은 값으로 바꾸어, softmax 이후 해당 위치의 attention weight가 0이 되도록 한다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/03d86216-a6cc-4cd8-ac8d-e6c9812a62af/image.png" alt=""></p>
<p> GPT 계열 decoder-only 모델은 다음 token을 예측해야 하므로 현재 위치에서 미래 token을 보지 못하도록 mask를 적용한다. 오른쪽의 GPT 구조에서 Mask → Softmax → Matmul 흐름이 causal attention 계산과 연결된다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/1250ec87-2e46-421e-b296-d70625167df1/image.png" alt=""></p>
<h2 id="multi-head-self-attention과-shape-변화">Multi-Head Self-Attention과 Shape 변화</h2>
<p> Self-Attention은 token들이 서로를 얼마나 참고할지 계산하는 구조이다. 그런데 <strong>하나의 attention head만 사용하면 token 간 관계를 한 가지 방식으로만 볼 수 있</strong>다. 문장 안에는 여러 종류의 관계가 있다. 그러나, 하나의 attention head만 있으면 이런 다양한 관계를 한 번에 충분히 포착하기 어렵다. 그래서 Transformer는 여러 개의 attention head를 병렬로 사용한다. </p>
<p> Multi-Head Attention은 입력을 여러 개의 서로 다른 projection 공간으로 보내고, 각 공간에서 attention을 따로 계산한다. 즉, 하나의 입력 <code>X</code>에서 여러 head마다 서로 다른 <code>Q</code>, <code>K</code>, <code>V</code>를 만든다.</p>
<p>!image.png</p>
<pre><code>Head 1: Q1, K1, V1로 attention 계산
Head 2: Q2, K2, V2로 attention 계산
Head 3: Q3, K3, V3로 attention 계산
...
Head H: QH, KH, VH로 attention 계산</code></pre><p> 각 head는 같은 문장을 보지만, 서로 다른 가중치 행렬을 사용하기 때문에 <strong>서로 다른 관점</strong>에서 token 관계를 학습할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/fa3430d8-56f6-4a21-88cb-e4dda62ac76d/image.png" alt=""></p>
<p> Multi-Head Attention의 전체 흐름은 다음과 같다.</p>
<ol>
<li><p>입력 X를 받는다.</p>
</li>
<li><p>각 head마다 Q, K, V를 만든다.</p>
</li>
<li><p>각 head에서 Scaled Dot-Product Attention을 계산한다.</p>
</li>
<li><p>각 head의 attention output을 concat한다.</p>
</li>
<li><p>concat된 결과에 output projection을 적용한다.</p>
</li>
<li><p>다음 layer로 전달한다.</p>
<p>즉, 여러 attention head가 서로 다른 projection 공간에서 병렬로 관계를 계산하고, 각 head의 출력은 concat 후 output projection을 거쳐 다음 layer로 전달되는 흐름을 지닌다. 이러한 구조는 서로 다른 representation subspace와 위치의 정보를 동시에 볼 수 있게 해준다.</p>
<p>여기서 각 head는 기본적으로 Scaled Dot-Product Attention을 수행한다. 하지만 head마다 사용하는 projection matrix가 다르다. 그래서 같은 입력 <code>X</code>를 보더라도 head마다 다른 표현 공간에서 관계를 계산한다.</p>
</li>
</ol>
<pre><code>Head 1:
Q1 = XW_Q1
K1 = XW_K1
V1 = XW_V1

Head 2:
Q2 = XW_Q2
K2 = XW_K2
V2 = XW_V2</code></pre><h3 id="shape-변화">Shape 변화</h3>
<pre><code>B = batch size
L = sequence length
D = d_model
H = number of heads
d_h = D / H</code></pre><table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
<th>Shape</th>
</tr>
</thead>
<tbody><tr>
<td>입력</td>
<td>token embedding</td>
<td><code>[B, L, D]</code></td>
</tr>
<tr>
<td>Q/K/V projection</td>
<td>Linear layer 통과</td>
<td><code>[B, L, D]</code></td>
</tr>
<tr>
<td>head 분리</td>
<td><code>D</code>를 <code>H × d_h</code>로 나눔</td>
<td><code>[B, L, H, d_h]</code></td>
</tr>
<tr>
<td>transpose</td>
<td>head 차원을 앞으로 이동</td>
<td><code>[B, H, L, d_h]</code></td>
</tr>
<tr>
<td>score 계산</td>
<td><code>QKᵀ</code></td>
<td><code>[B, H, L, L]</code></td>
</tr>
<tr>
<td>softmax</td>
<td>attention weight</td>
<td><code>[B, H, L, L]</code></td>
</tr>
<tr>
<td>V와 곱함</td>
<td>각 head의 output</td>
<td><code>[B, H, L, d_h]</code></td>
</tr>
<tr>
<td>concat</td>
<td>head들을 다시 합침</td>
<td><code>[B, L, D]</code></td>
</tr>
<tr>
<td>output projection</td>
<td>최종 선형변환</td>
<td><code>[B, L, D]</code></td>
</tr>
</tbody></table>
<h3 id="concat-후-output-projection을-하는-이유">concat 후 output projection을 하는 이유</h3>
<p> 각 head는 서로 다른 관점에서 attention 결과를 만든다. 하지만 head별 결과가 따로따로 있으면 다음 layer에서 사용하기 어렵다. 그래서 각 head의 output을 하나로 이어 붙인다. concat하면 다시 [B, L, D]의 형태가 된다. 그 다음 output projection을 적용해서 여러 head에서 나온 정보를 다시 섞고 정리한다. 즉, output projection은 여러 head의 결과를 종합해서 다음 Transformer block에 넘기기 위한 단계이다.</p>
<pre><code>Concat(head1, head2, ..., headH) → Output Projection</code></pre><table>
<thead>
<tr>
<th>구분</th>
<th>Single-Head Self-Attention</th>
<th>Multi-Head Self-Attention</th>
</tr>
</thead>
<tbody><tr>
<td>head 개수</td>
<td>1개</td>
<td>여러 개</td>
</tr>
<tr>
<td>관계 포착</td>
<td>한 가지 관점</td>
<td>여러 관점</td>
</tr>
<tr>
<td>Q/K/V projection</td>
<td>한 세트</td>
<td>head마다 다른 projection</td>
</tr>
<tr>
<td>출력 처리</td>
<td>바로 output</td>
<td>head output concat 후 projection</td>
</tr>
<tr>
<td>장점</td>
<td>구조가 단순함</td>
<td>다양한 관계를 동시에 학습 가능</td>
</tr>
</tbody></table>
<h2 id="transformer-구조">Transformer 구조</h2>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/7a3d2a84-2f45-4f83-b151-399976ec01c8/image.png" alt=""></p>
<p> Transformer는 기존 RNN 기반 Encoder-Decoder 구조처럼 <strong>Encoder와 Decoder</strong>를 가진다. 하지만 중요한 차이는 <strong>RNN이나 CNN을 사용하지 않고 Attention을 중심으로 구성된다</strong>는 점이다. 즉, RNN처럼 입력을 순서대로 처리하지 않고, <strong>Self-Attention을 통해 token들 사이의 관계를 한 번에 계산</strong>한다.</p>
<h3 id="encoder">Encoder</h3>
<p> Encoder는 입력 문장을 이해하는 역할을 한다. Encoder layer 하나는 보통 다음과 같이 구성된다. 논문에서는 encoder가 <code>N=6</code>개의 동일한 layer로 구성되고, 각 layer는 Multi-Head Self-Attention과 position-wise Feed-Forward Network 두 sub-layer를 가진다고 설명한다. 각 sub-layer 주변에는 residual connection이 있고, 그 뒤에 layer normalization을 적용한다.</p>
<p> 이때, Self-Attention만 token들 사이의 상호작용을 담당하고, LayerNorm과 MLP는 각 token vector에 독립적으로 적용된다. 즉, token끼리 정보를 섞는 부분은 attention이고, MLP는 이미 섞인 각 token 표현을 더 복잡하게 변환하는 역할이다.</p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>Multi-Head Self-Attention</td>
<td>입력 token들이 서로를 참고해 문맥 정보 생성</td>
</tr>
<tr>
<td>Feed Forward Network</td>
<td>각 token 표현을 비선형적으로 변환</td>
</tr>
<tr>
<td>Add &amp; Norm</td>
<td>Residual Connection + Layer Normalization</td>
</tr>
</tbody></table>
<pre><code>Input
 → Multi-Head Self-Attention
 → Add &amp; Norm
 → Feed Forward Network
 → Add &amp; Norm</code></pre><h3 id="decoder">Decoder</h3>
<p> Decoder는 출력 문장을 생성하는 역할을 한다. Decoder layer는 Encoder보다 attention block이 하나 더 많다.</p>
<pre><code>Output Embedding
 → Masked Multi-Head Self-Attention
 → Add &amp; Norm
 → Encoder-Decoder Attention
 → Add &amp; Norm
 → Feed Forward Network
 → Add &amp; Norm
 → Output</code></pre><p> Decoder는 크게 세 부분으로 구성된다.</p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>Masked Multi-Head Self-Attention</td>
<td>지금까지 생성된 token들만 보고 다음 token 예측</td>
</tr>
<tr>
<td>Encoder-Decoder Attention</td>
<td>Encoder의 출력 정보를 참고</td>
</tr>
<tr>
<td>Feed Forward Network</td>
<td>각 token 표현을 비선형적으로 변환</td>
</tr>
</tbody></table>
<p> Decoder의 첫 attention은 <strong>masked self-attention</strong>이다. 다음 단어를 예측할 때 미래 단어를 보면 안 되기 때문에, 현재 위치 이후 token은 가린다. 즉, decoder self-attention은 auto-regressive 성질을 유지하기 위해 미래 위치를 볼 수 없도록 mask를 적용한다.</p>
<p> Decoder에는 Encoder에는 없는 attention이 하나 더 있다. <strong>Encoder-Decoder Attention(=Cross Attention)</strong>이다. 이는 Decoder가 출력 단어를 만들 때, Encoder가 이해한 입력 문장 정보를 참고하는 부분이다. 예를 들어 번역에서 Decoder가 <code>cielo</code>를 만들 때 Encoder 출력 중 <code>sky</code>와 관련된 정보를 참고할 수 있다. 즉, decoder가 자기 상태를 Query로 삼아 encoder output에서 필요한 정보를 찾아오는 attention이다. 이렇게 decoder가 출력을 만들 때 encoder가 봐야할 부분을 찾아올 수 있다.</p>
<pre><code>입력: we see the sky
출력: vediamo il cielo

Decoder의 현재 상태 → Query
Encoder 출력 → Key, Value</code></pre><p> 정리하면, Self-Attention은 같은 sequence 내부에서 token들이 서로 보는 것이고, Encoder-Decoder Attention은 Decoder가 Encoder의 출력 정보를 보는 것이다.</p>
<h2 id="왜-transformer-구조가-좋은가">왜 Transformer 구조가 좋은가?</h2>
<p> Transformer는 RNN처럼 입력을 순차적으로 처리하지 않고, Self-Attention을 이용해 sequence 내 모든 token 간 관계를 한 번에 계산한다. 따라서 training example 내부에서 병렬화가 가능하며, 멀리 떨어진 token도 attention을 통해 직접 연결할 수 있어 장기 의존성 학습에 유리하다.</p>
<p> RNN은 이전 hidden state가 계산되어야 다음 hidden state를 계산할 수 있으므로 sequential operation이 <code>O(n)</code>이고 병렬화가 어렵다. 반면 Self-Attention은 sequential operation과 maximum path length가 <code>O(1)</code>이므로 병렬화와 장거리 정보 전달에 유리하다.</p>
<p> 다만 모든 token 쌍을 비교하므로 per-layer complexity가 <code>O(n²d)</code>로, 매우 긴 sequence에서는 계산량과 메모리 사용량이 커지는 한계가 있다.</p>
<table>
<thead>
<tr>
<th>장점</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>병렬 처리 가능</strong></td>
<td>RNN처럼 앞 token 계산이 끝나야 다음 token을 계산하는 구조가 아님</td>
</tr>
<tr>
<td><strong>장거리 의존성 학습에 유리</strong></td>
<td>멀리 떨어진 token도 attention으로 바로 연결 가능</td>
</tr>
<tr>
<td><strong>확장성이 좋음</strong></td>
<td>언어, 이미지, 음성 등 다양한 데이터에 적용 가능</td>
</tr>
</tbody></table>
<h2 id="self-attention-rnn-convolution-비교">Self-Attention, RNN, Convolution 비교</h2>
<p> Transformer 논문은 Self-Attention, RNN, Convolution을 계산 복잡도, 병렬화 가능성, 장기 의존성 경로 길이 관점에서 비교한다. 표에서는 Self-Attention이 모든 위치를 한 번에 연결하므로 sequential operation과 maximum path length가 <code>O(1)</code>이고, RNN은 순서대로 계산해야 해서 <code>O(n)</code>이라고 정리한다. </p>
<p> 정리하면, Self-Attention은 긴 거리의 token을 직접 연결할 수 있어 장기 의존성 학습에 유리하고 병렬화도 잘 된다. 다만 sequence 길이 <code>n</code>에 대해 attention score matrix가 <code>[L, L]</code>로 생기기 때문에 아주 긴 sequence에서는 계산량과 메모리 사용량이 커진다.</p>
<table>
<thead>
<tr>
<th>관점</th>
<th>Self-Attention</th>
<th>RNN</th>
<th>Convolution</th>
</tr>
</thead>
<tbody><tr>
<td>장기 의존성</td>
<td>멀리 떨어진 token도 한 번에 연결</td>
<td>여러 time step을 거쳐야 함</td>
<td>kernel 크기/층 수에 따라 점진적으로 연결</td>
</tr>
<tr>
<td>병렬화</td>
<td>좋음. 모든 token 동시 계산 가능</td>
<td>나쁨. 이전 hidden state가 필요</td>
<td>좋음. 위치별 병렬 계산 가능</td>
</tr>
<tr>
<td>sequential operation</td>
<td><code>O(1)</code></td>
<td><code>O(n)</code></td>
<td><code>O(1)</code></td>
</tr>
<tr>
<td>maximum path length</td>
<td><code>O(1)</code></td>
<td><code>O(n)</code></td>
<td><code>O(log_k n)</code></td>
</tr>
<tr>
<td>per-layer complexity</td>
<td><code>O(n²d)</code></td>
<td><code>O(nd²)</code></td>
<td><code>O(knd²)</code></td>
</tr>
<tr>
<td>단점</td>
<td>sequence가 길면 <code>n²</code> 때문에 비용 큼</td>
<td>긴 문맥/병렬화에 약함</td>
<td>먼 위치 연결에 여러 layer 필요</td>
</tr>
</tbody></table>
<h1 id="transformer-기반-모델">Transformer 기반 모델</h1>
<h3 id="encoder-only--decoder-only--encoder-decoder-모델-정리">Encoder-only / Decoder-only / Encoder-Decoder 모델 정리</h3>
<table>
<thead>
<tr>
<th>구조</th>
<th>핵심 특징</th>
<th>대표 모델</th>
<th>대표 task</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Encoder-only</strong></td>
<td>입력 전체를 양방향으로 보고 이해함</td>
<td>BERT, ViT</td>
<td>문장 분류, 감정 분석, 이미지 분류</td>
</tr>
<tr>
<td><strong>Decoder-only</strong></td>
<td>과거 token만 보고 다음 token을 생성함</td>
<td>GPT-2</td>
<td>텍스트 생성</td>
</tr>
<tr>
<td><strong>Encoder-Decoder</strong></td>
<td>Encoder가 입력을 이해하고, Decoder가 출력 생성/예측을 수행함</td>
<td>DETR, 번역 모델</td>
<td>객체 검출, 번역, seq2seq</td>
</tr>
</tbody></table>
<h2 id="bert">BERT</h2>
<p> Encoder-only 모델은 입력 전체를 한 번에 보고 이해하는 구조이다. Encoder에서는 각 token이 앞뒤 token을 모두 볼 수 있다. 그래서 문장 분류, 감정 분석, 이미지 분류처럼 입력 전체를 보고 판단하는 task에 많이 쓰인다.</p>
<p> 그 중 하나인 BERT는 <strong>텍스트용 Transformer Encoder</strong>이다. BERT는 문장을 입력받아 전체 문맥을 양방향으로 이해한다. 즉, 어떤 token을 해석할 때 앞 token과 뒤 token을 모두 참고할 수 있다. BERT 입력은 보통 세 가지 embedding을 더해서 만든다.</p>
<p> BERT는 token embedding, segment embedding, position embedding을 더해 encoder에 넣고, 사전학습에서는 일부 token을 <code>[MASK]</code>로 바꾼 뒤 주변 문맥으로 원래 token을 맞히는 masked language modeling을 사용한다.</p>
<p> 또 BERT는 문장을 단어 그대로 처리하지 않고 <strong>subword token</strong>으로 나눈다. 예를 들어 하나의 단어가 <code>##</code>가 붙은 여러 조각 token으로 나뉠 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/39b39abc-9518-4e57-a7a1-403835dad6a4/image.png" alt=""></p>
<h2 id="vit">ViT</h2>
<p> ViT는 <strong>이미지용 Transformer Encoder</strong>이다. 기존 CNN처럼 이미지를 convolution으로 처리하는 것이 아니라, 이미지를 작은 patch로 나누고 각 patch를 token처럼 Transformer에 넣는다. 예를 들어 <code>google/vit-base-patch16-224</code>는 224×224 이미지를 16×16 patch로 나눈다.</p>
<pre><code>224 / 16 = 14
14 × 14 = 196개 patch</code></pre><p> 위의 예시에서 <code>[CLS]</code> token을 추가하면 sequence length는 197이 된다. ViT는 이미지를 일정한 크기의 patch로 나누고, 각 patch를 token처럼 embedding하며, <code>[CLS]</code> token의 최종 representation을 MLP head에 넣어 이미지 분류 결과를 낸다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/a5a7c3c0-1520-4ddb-bce7-51d81945ccc2/image.png" alt="">
<img src="https://velog.velcdn.com/images/inhwaaa_v/post/49b0f38d-afa7-4696-a31b-c1c8e9906ace/image.png" alt=""></p>
<h2 id="gpt">GPT</h2>
<p> Decoder-only 모델은 현재까지 나온 token들을 보고 <strong>다음 token을 예측</strong>하는 구조이다. 이때 미래 token을 보면 안 되므로 <strong>causal mask</strong> 또는 <strong>masked self-attention</strong>을 사용한다. 그래서 텍스트 생성 task에 적합하다.</p>
<p> GPT-2는 <strong>Decoder-only Transformer</strong>로, 주어진 prompt 뒤에 이어질 token을 하나씩 생성한다. 예를 들어, Once upon a time,이라는 prompt가 주어지면, GPT-2는 다음에 올 단어를 예측하고, 다시 그 단어를 이용해 그다음 단어를 예측하는 방식으로 문장을 생성한다.</p>
<h2 id="detr">DETR</h2>
<p> Encoder-Decoder 구조는 입력을 이해하는 Encoder와 출력을 만드는 Decoder를 함께 사용한다. 기계번역처럼 입력 sequence와 출력 sequence가 다를 때 많이 사용된다. 객체 검출에서도 DETR처럼 Encoder-Decoder 구조를 사용할 수 있다.</p>
<p> DETR은 <strong>Detection Transformer</strong>로, 객체 검출에 Transformer Encoder-Decoder 구조를 적용한 모델이다. 이미지를 입력받고, 이미지 안의 객체가 무엇인지와 어디에 있는지를 예측한다. 즉, 출력은 단순 class 하나가 아니라 객체 class + bounding box 위치 + confidence score이다.</p>
<h2 id="정리">정리</h2>
<p> BERT, GPT-2, ViT, DETR은 모두 Transformer 기반 모델이지만 구조와 task가 다르다. BERT는 Encoder-only 구조로 텍스트 입력을 양방향으로 이해하며, token embedding, position embedding, segment embedding을 더해 encoder에 입력한다. 주로 문장 분류나 감정 분석 같은 자연어 이해 task에 사용된다. GPT-2는 Decoder-only 구조로, masked self-attention을 사용해 미래 token을 보지 않고 다음 token을 예측하므로 텍스트 생성에 적합하다. ViT는 이미지를 patch로 나누어 각 patch를 token처럼 처리하는 Encoder-only 모델이며, <code>[CLS]</code> token의 최종 표현을 이용해 이미지 분류를 수행한다. DETR은 Encoder-Decoder 구조를 가진 객체 검출 모델로, 이미지 내 객체의 class와 bounding box를 예측한다.</p>
<table>
<thead>
<tr>
<th>모델</th>
<th>구조</th>
<th>입력 형태</th>
<th>출력</th>
<th>대표 task</th>
<th>핵심 특징</th>
</tr>
</thead>
<tbody><tr>
<td><strong>BERT</strong></td>
<td>Encoder-only</td>
<td>텍스트 token</td>
<td>문장/Token 표현, 분류 결과</td>
<td>문장 분류, 감정 분석</td>
<td>양방향 문맥 이해</td>
</tr>
<tr>
<td><strong>GPT-2</strong></td>
<td>Decoder-only</td>
<td>텍스트 prompt</td>
<td>다음 token sequence</td>
<td>텍스트 생성</td>
<td>causal mask로 미래 token 차단</td>
</tr>
<tr>
<td><strong>ViT</strong></td>
<td>Encoder-only</td>
<td>이미지 patch token</td>
<td>이미지 class</td>
<td>이미지 분류</td>
<td>이미지를 patch sequence로 처리</td>
</tr>
<tr>
<td><strong>DETR</strong></td>
<td>Encoder-Decoder</td>
<td>이미지</td>
<td>class + bounding box</td>
<td>객체 검출</td>
<td>이미지 내 객체 위치와 종류 예측</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[RNN, LSTM, and GRU]]></title>
            <link>https://velog.io/@inhwaaa_v/RNN-LSTM-and-GRU</link>
            <guid>https://velog.io/@inhwaaa_v/RNN-LSTM-and-GRU</guid>
            <pubDate>Wed, 17 Jun 2026 21:45:42 GMT</pubDate>
            <description><![CDATA[<h2 id="rnn-lstm-and-gru">RNN, LSTM, and GRU</h2>
<blockquote>
<p><strong>핵심 : 문장, 음성 신호, ECG 파형, 수요 곡선 같은 데이터는 단순히 독립적인 값들의 집합이 아니라 순서가 있는 객체이다. 따라서 순서를 고려할 수 있는 sequence model이 필요하다.</strong></p>
</blockquote>
<h3 id="개요">개요</h3>
<p>기존 feed-forward layer는 입력 <code>x</code> 하나를 받아서 선형 변환을 하고, activation function을 적용해서 출력한다. MLP는 이런 층을 여러 개 쌓은 구조다. 이 방식은 입력이 <strong>고정 길이 벡터</strong>일 때 잘 맞는다. 예를 들어 표 데이터, flatten된 이미지 feature 같은 것들은 MLP로 처리할 수 있다. 그런데 현실의 많은 데이터는 고정 길이 벡터가 아니라 <strong>sequence</strong>, 즉 순차 데이터다.</p>
<ul>
<li>문장 : 단어들의 sequence</li>
<li>음성 : 시간 순서대로 나열된 frame들의 sequence</li>
<li>센서 데이터 : 시간 순서대로 측정된 값들의 sequence</li>
<li>주가 : 시간에 따른 가격 sequence</li>
<li>ECG : 시간에 따른 생체 신호 sequence</li>
</ul>
<p>이런 데이터에서는 <strong>순서가 의미의 일부</strong>다.</p>
<p>예를 들어, <code>&quot;not bad&quot;</code>는 단어만 보면 <code>not</code>, <code>bad</code>지만, 순서를 고려하면 “나쁘지 않다”라는 의미가 된다. 센서 데이터도 갑자기 튄 값이 의미 있으려면 이전 정상 흐름 이후에 발생했다는 맥락이 중요하다. 그래서 이전 시점 정보를 요약해서 기억하는 <strong>state</strong>가 필요하다. RNN은 이 state를 도입한 모델이다.</p>
<blockquote>
<p>일반적인 feed-forward layer의 계산</p>
<ul>
<li>z = Wx + b</li>
<li>h = ϕ(z)</li>
</ul>
</blockquote>
<h3 id="sequence-data란">Sequence Data란?</h3>
<p> 순차 데이터는 순서가 있는 관측값들의 모음이다. 아래의 수식에서 <code>T</code> 는 Sequence의 길이, <code>x_t</code> 는 t번째 시점에서의 입력이다. 이때 중요한 점은 x_t가 그냥 독립적인 샘플이 아니라는 것이다. 순차 데이터에서는 현재 입력을 해석할 때, 이전 시점의 정보가 중요한 역할을 한다 예를 들어, x_3가 있다고 하면 x_3의 의미는 x_1과 x_2에 의해 변화할 수 있다.</p>
<p>$$
x₁:T = (x₁, x₂, ..., x_T)
$$</p>
<p> 예를 들어 문장 데이터에서 “나는 밥을” 다음에 오는 단어는 앞의 단어들에 의해 의미가 결정된다. 또한 주가 데이터에서도 오늘의 가격은 단순히 오늘 값 하나만으로 판단하기보다 이전 가격 흐름과 함께 분석해야 한다. 이처럼 순차 데이터는 <strong>시간적 의존성 또는 문맥적 의존성</strong>을 가지기 때문에, 이를 처리하기 위해 RNN, LSTM, GRU와 같은 순환 신경망 모델이 사용된다.</p>
<p> 이러한 순차 데이터가 일반적인 고정 길이 벡터 데이터와 다른 이유는 여기에 있다. 일반적인 고정 길이 벡터 데이터는 보통 각 샘플을 독립적으로 본다. 예를 들어 이미지 분류나 표 형식 데이터에서는 입력 하나를 넣고 출력 하나를 예측한다. (입력 x → 모델 → 출력 y)</p>
<p> 이와 달리, 순차 데이터는 x_1, x_2, x_3, …, x_t처럼 순서가 있는 데이터이고, 각 시점의 값이 독립적이지 않다. 즉, 현재 값 <code>x_t</code>의 의미가 이전 값 <code>x_{t-1}</code>, <code>x_{t-2}</code>와 연결되어 있다.</p>
<h3 id="sequence-learning의-입력-출력-유형">Sequence Learning의 입력-출력 유형</h3>
<p> RNN 계열은 모델이 입력을 단계별로 처리하고 이전 정보에 대한 표현을 유지해야 할 때 특히 유용하다. </p>
<ul>
<li>Many-to-One은 sequence를 입력받지만 출력은 하나이다. (ex) 감성 분류</li>
<li>Many-to-Many는 sequence를 입력받고 각 시점마다 출력한다. (ex) 품사 태깅</li>
<li>Sequence-to-Sequence는 sequence를 입력받아 새로운 sequence를 생성하며, 입출력 길이가 다를 수 있다. (ex)기계 번역</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/50ba0270-7f92-4d9e-a89a-054e8d4d0de8/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>유형</th>
<th>입력</th>
<th>출력</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><strong>One-to-One</strong></td>
<td>입력 하나</td>
<td>출력 하나</td>
<td>이미지 분류</td>
</tr>
<tr>
<td><strong>One-to-Many</strong></td>
<td>입력 하나</td>
<td>Sequence 출력</td>
<td>이미지 캡셔닝</td>
</tr>
<tr>
<td><strong>Many-to-One</strong></td>
<td>Sequence 입력</td>
<td>출력 하나</td>
<td>감성 분류</td>
</tr>
<tr>
<td><strong>Many-to-Many</strong></td>
<td>Sequence 입력</td>
<td>Sequence 출력</td>
<td>품사 태깅</td>
</tr>
<tr>
<td><strong>Sequence-to-Sequence</strong></td>
<td>Sequence 입력</td>
<td>Sequence 출력</td>
<td>기계 번역</td>
</tr>
<tr>
<td><strong>Streaming</strong></td>
<td>계속 들어오는 Sequence 입력</td>
<td>실시간 판단</td>
<td>실시간 음성 인식, 센서 모니터링</td>
</tr>
</tbody></table>
<h3 id="vanilla-rnn">Vanilla RNN</h3>
<p> Vanilla RNN은 가장 기본적인 RNN으로, 핵심은 <strong>hidden state</strong>이다. Hidden state는 이전 시점까지의 정보를 요약한 값이며, RNN은 매 시점마다 현재 입력 <code>x_t</code>와 이전 hidden state <code>h_{t-1}</code>를 함께 사용해서 현재 hidden state <code>h_t</code>를 만든다.</p>
<p> 공식은 다음과 같다.</p>
<p>$$
h_t = ϕ(W_{xh} x_t + W_{hh} h_{t-1} + b_h)
$$</p>
<p> 출력은 보통 다음처럼 계산하며, 분류 문제라면 softmax를 붙여 예측 확률을 만들고, 회귀의 경우 선형 출력 결과를 직접 사용할 수 있다.</p>
<p>$$
y_t = W_{hy} h_t + b_y
$$</p>
<p>$$
ŷ<em>t = softmax(W</em>{hy} h_t + b_y)
$$</p>
<blockquote>
<p><strong>Vanilla RNN</strong> : 현재 입력과 이전 hidden state를 이용해 현재 hidden state를 업데이트</p>
</blockquote>
<ul>
<li><p>현재 입력 + 이전 기억 → 현재 hidden state</p>
<blockquote>
</blockquote>
<p>RNN은 3개의 가중치가 존재하며, 단순히 현재 입력만 보는 것이 아니라 현재 입력과 이전 기억을 함께 보고 현재 상태를 만든다. 쉽게 말해, RNN에서는 매 시점마다 입력 x_t가 들어오고, 모델은 이전 hidden state h_{t-1}을 참고해 현재 hidden state h_t를 만든다.</p>
</li>
</ul>
<table>
<thead>
<tr>
<th>기호</th>
<th>의미</th>
<th>예시 shape</th>
</tr>
</thead>
<tbody><tr>
<td>x_t</td>
<td>시점 t에서의 입력값</td>
<td>(d_x,)</td>
</tr>
<tr>
<td>h_t</td>
<td>시점 t에서의 hidden state</td>
<td>(d_h,)</td>
</tr>
<tr>
<td>y_t</td>
<td>시점 t에서의 출력값</td>
<td></td>
</tr>
<tr>
<td>모델이 현재 hidden state를 바탕으로 내는 결과</td>
<td>(d_y,)</td>
<td></td>
</tr>
<tr>
<td>W_{xh}</td>
<td>현재 입력 <code>x_t</code>를 hidden state 공간으로 변환</td>
<td></td>
</tr>
<tr>
<td>현재 입력 → hidden state에 반영 (현재 입력을 처리하는 가중치)</td>
<td>(d_h, d_x)</td>
<td></td>
</tr>
<tr>
<td>W_{hh}</td>
<td>이전 hidden state <code>h_{t-1}</code>를 현재 hidden state 계산에 반영</td>
<td></td>
</tr>
<tr>
<td>이전 기억 → 현재 기억에 반영 (이전 기억을 가져오는 가중치)</td>
<td>(d_h, d_h)</td>
<td></td>
</tr>
<tr>
<td>W_{hy}</td>
<td>hidden state <code>h_t</code>를 output으로 변환</td>
<td></td>
</tr>
<tr>
<td>hidden state를 output으로 바꿔주는 가중치</td>
<td></td>
<td></td>
</tr>
<tr>
<td>RNN이 내부적으로 기억한 정보를 실제 예측 결과로 바꾸는 역할</td>
<td>(d_y, d_h)</td>
<td></td>
</tr>
<tr>
<td>b_h</td>
<td>hidden bias vector</td>
<td>(d_h,)</td>
</tr>
<tr>
<td>b_y</td>
<td>output bias vector</td>
<td>(d_y,)</td>
</tr>
</tbody></table>
<h3 id="time-unrolling">Time Unrolling</h3>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/92b829cf-37f6-4efb-976b-7b09a24c7712/image.png" alt=""></p>
<p> RNN은 그림으로 보면 자기 자신에게 연결된 순환 구조처럼 보인다. 하지만 학습할 때는 시간 방향으로 펼쳐서 생각한다. RNN은 원래 <code>현재 입력 x_t + 이전 기억 h_{t-1} → 현재 기억 h_t</code> 와 같이 생긴 하나의 반복 구조인데, 이 계산을 시간 순서대로 반복한다는 것이다. 즉, 시간마다 다른 모델을 쓰는 게 아니라, 같은 RNN 셀이 시간마다 반복해서 사용된다.</p>
<p> 예를 들어, 시퀀스의 길이가 3이면 아래와 같이 펼쳐질 수 있다. 즉, 첫 번째 입력 x_1과 초기 기억 h_0으로 h_1을 만들고, 두 번째 입력 x_2와 이전 기억 h_1으로 h_2를 만들고, 세 번째 입력 x_3와 이전 기억 h_2로 h_3을 만든다. 이 흐름을 시간 전개, 즉 Time unrolling이라고 한다. </p>
<p>$$
h₁ = ϕ(W_{xh} x₁ + W_{hh} h₀ + b_h)
$$</p>
<p>$$
h₂ = ϕ(W_{xh} x₂ + W_{hh} h₁ + b_h)
$$</p>
<p>$$
h₃ = ϕ(W_{xh} x₃ + W_{hh} h₂ + b_h)
$$</p>
<p> 위의 그림 오른쪽에 RNN 블록이 여러 개 있는 것처럼 보여도, <strong>서로 다른 모델이 여러 개 있는 게 아닌, 같은 RNN 셀을 시간 순서대로 반복해서 적용한 것</strong>이다. 즉, h_1, h_2, h_3를 계산할 때 각각 다른 가중치를 쓰는 게 아니라 항상 같은 가중치를 사용하므로 W_xh, W_hh, b_h는 모든 시점에서 동일하게 사용되며, 이를 <strong>parameter sharing over time</strong>이라고 한다.</p>
<p> <strong>Parameter sharing over time</strong>은 시간이 달라져도 모든 time step에서 동일한 파라미터를 반복해서 사용하는 것을 의미한다. 즉, 문장이 3단어든 10단어든 RNN은 매 시점마다 같은 계산 규칙을 적용한다.</p>
<p> RNN은 각 시점에서 현재 입력 x_t와 이전 hidden state h_{t-1}를 함께 사용하여 현재 hidden state h_t를 계산한다.</p>
<p>$$
h_t = \phi(W_{xh}x_t + W_{hh}h_{t-1} + b_h)
$$</p>
<p> 이때 W_{xh}, W_{hh}, b_h는 모든 time step에서 동일하게 사용된다. 따라서 time unrolling으로 펼쳐진 여러 RNN 블록은 서로 다른 모델이 아니라, 같은 RNN 셀이 시간축을 따라 반복 적용된 것이다.</p>
<p>  RNN에서 공유되는 것은 <strong>hidden state 값 자체가 아니라 계산에 사용되는 가중치와 규칙</strong>이다. W_{xh}는 현재 입력을 hidden state에 반영하는 가중치이고, W_{hh}는 이전 hidden state를 현재 hidden state에 반영하는 가중치이며, W_{hy}는 hidden state를 output으로 변환하는 가중치이다.</p>
<p> RNN의 parameter sharing은 모든 time step에서 같은 가중치를 사용하여 동일한 계산 규칙을 반복 적용하는 것이다. 계산 공식은 같지만, 공식에 들어가는 입력값 x_t와 이전 기억값 h_{t-1}은 매 시점마다 달라진다. RNN은 이렇게 parameter를 공유하기에 시계열 길이가 길어져도 매 시점 다른 가중치를 새로 만드는 게 아니라, 같은 가중치를 반복 사용한다. 그래서 파라미터 수가 시간에 비례해 늘어나지 않고, 파라미터를 크게 늘리지 않고도 순서가 있는 데이터를 학습할 수 있다는 장점이 있다.</p>
<h3 id="bptt-backpropagation-through-time">BPTT (Backpropagation Through Time)</h3>
<p> BPTT는 <strong>Backpropagation Through Time</strong>의 약자로, RNN을 시간 방향으로 펼친 다음, 그 펼쳐진 계산 그래프를 따라 역전파하는 것을 의미한다. RNN은 시간 순서로 펼치면 <code>h0 → h1 → h2 → h3 → ... → hT → Loss</code> 와 같은 구조를 지니고, 학습 시에는 Loss를 줄이기 위해 역전파를 수행해야 하는데, RNN은 시간 축으로 펼쳐져 있으므로 역전파도 시간 방향을 거슬러 가야 하므로 <code>Loss → hT → hT-1 → hT-2 → ... → h0</code> 와 같이 시간 축을 거슬러 전달하게 된다.</p>
<p> 일반 신경망은 층 방향으로 역전파하지만, RNN은 시간 방향으로도 연결되어 있기 때문에 과거 시점까지 gradient를 보내야 한다. 그래서 RNN의 gradient는 대략 이런 형태가 된다. 즉, 초기 hidden state h0가 최종 loss에 얼마나 영향을 주는지 계산하려면, h0 → h1 → h2 → ... → hT까지 이어지는 영향들을 전부 곱해야 하는 형태라는 것이다.</p>
<p>$$
\frac{∂L_T} {∂h_0} = \frac{∂L_T} {∂h_T} × ∏<em>{t=1}^T \frac{∂h_t}{∂h</em>{t-1}}
$$</p>
<p> 수식에서도 보듯, 핵심은 <code>∏</code>, 즉 반복 곱셈이다. gradient가 과거로 전달될 때 Jacobian들이 계속 곱해지고, 시간이 길수록 곱해지는 값들이 더 많아진다. BPTT에서는 반복적인 Jacobian 곱이 central issue이며, 반복 곱이 1보다 작으면 vanishing, 1보다 크면 exploding이 발생할 수 있다. 만약 계속 곱해지는 값들이 1보다 작으면 gradient가 0에 가까워져 앞쪽 시점의 정보가 뒤쪽 loss에 영향을 거의 못 주기에 기울기 소실 문제가 발생해 오래 전 정보는 뒤로 갈수록 영향력이 희미해져서 학습이 잘 안 된다. 예를 들어 문장이 길 때, 맨 앞 단어가 뒤쪽 의미에 중요해도 Vanilla RNN은 그 정보를 끝까지 잘 전달하지 못할 수 있다.</p>
<p> 반대로, 곱해지는 값들이 1보다 크면 gradient가 너무 커져 학습이 불안정해지고, 기울기 폭주 문제가 발생할 수 있다. 즉, gradient가 너무 커져서 weight가 과하게 업데이트되고 학습이 불안정해진다. 이는 <strong>반복 곱셈이 작은 차이를 엄청 크게 만들어 발생하는 문제</strong>이며, Vanilla RNN의 한계를 명확히 보여준다. Vanilla RNN은 먼 과거의 정보가 현재 출력에 영향을 주려면 많은 hidden state를 거쳐야 하고, 이 과정에서 gradient가 계속 곱해진다. 따라서 1보다 작으면 기울기 소실 문제가, 크면 폭주 문제가 발생해 장기 의존성을 잘 학습하지 못하는 long-term dependency 문제가 발생한다. 이를 해결하기 위해 나온 구조가 LSTM과 GRU이다. 이 두 구조는 gate를 사용해 어떤 정보를 유지하고 버릴지, 어떤 정보를 새로 반영할지를 반영하므로 Vanilla RNN보다 긴 문맥을 더 잘 다룰 수 있다는 특징이 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/99412e17-911c-4990-a887-dd996f288b6b/image.png" alt=""></p>
<h3 id="lstm-long-short-term-memory">LSTM (Long Short-Term Memory)</h3>
<p> Vanilla RNN은 모든 과거 정보를 하나의 hidden state <code>h_t</code>에 저장한다. 이는 짧은 sequence에서는 괜찮을 수 있지만, 긴 sequence에서는 hidden state 하나가 병목된다. 이때 병목된다는 말의 의미는 긴 과거 정보를 hidden state 하나에 다 우겨넣어야 해서 정보가 압축되거나 지워지는 것을 의미한다. 즉, h_t는 크기가 정해진 하나의 벡터인데, 과거 정보가 계속 늘어나 오래된 정보가 희미해지거나 새 정보에 덮여서 사라지는 information bottleneck 현상이 발생한다.</p>
<p> 그래서 LSTM은 이를 해결하기 위해 별도의 기억 경로인 <strong>cell state <code>c_t</code></strong> 를 도입한다. <code>h_t</code> 하나로 기억과 출력을 해결하려고 했던 Vanilla RNN과는 달리 <code>c_t</code>에는 오래 가져갈 기억을, <code>h_t</code>에는 현재 시점에서 밖으로 보여주는 상태를 저장해 나눈다.</p>
<table>
<thead>
<tr>
<th>기호</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>c_t</code></td>
<td>내부 장기 기억 경로</td>
</tr>
<tr>
<td><code>h_t</code></td>
<td>다음 layer나 output으로 노출되는 hidden representation</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/3201899f-0cde-4ae9-8380-c6a6a06ea0d5/image.png" alt=""></p>
<p> LSTM은 memory를 gate로 조절한다. 여기서 gate는 정보가 얼마나 통과할지 조절하는 장치이다. Gate에서는 sigmoid 함수를 사용하는데, sigmoid는 입력값을 0과 1 사이의 값으로 변환한다. 따라서 gate의 출력값은 정보를 얼마나 유지하거나 반영할지를 나타내는 비율처럼 해석할 수 있다.</p>
<p> 예를 들어 gate 값이 0에 가까우면 해당 정보를 거의 차단하고, 1에 가까우면 거의 그대로 통과시킨다. 0.5에 가까우면 정보를 절반 정도만 반영하는 것으로 볼 수 있다. 즉, LSTM의 gate는 정보를 완전히 켜고 끄는 hard switch가 아니라, 정보의 통과 정도를 연속적으로 조절하는 soft switch이다.</p>
<p> 또한 gate 값은 벡터 형태로 계산되므로, 전체 정보를 한 번에 버리거나 유지하는 것이 아니라 cell state의 각 차원별로 어떤 정보는 유지하고 어떤 정보는 줄이는 방식으로 조절한다. 이 덕분에 LSTM은 이전 기억 중 중요한 정보는 오래 유지하고, 불필요한 정보는 선택적으로 잊을 수 있다.</p>
<ul>
<li>cell state c_t = 기억이 저장되는 공간</li>
<li>gate = cell state에 들어가고 나가는 정보량을 조절하는 역할</li>
<li>Gate에서는 sigmoid를 사용해 정보의 통과 비율을 0<del>1 사이로 조절하고, candidate cell state에서는 tanh를 사용해 새로 저장할 후보 기억을 -1</del>1 범위의 값으로 만든다.</li>
</ul>
<table>
<thead>
<tr>
<th>게이트</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>Forget gate <code>f_t</code></td>
<td>이전 기억을 얼마나 잊을지/유지할지</td>
</tr>
<tr>
<td>Input gate <code>i_t</code></td>
<td>새 기억을 얼마나 쓸지</td>
</tr>
<tr>
<td>Output gate <code>o_t</code></td>
<td>내부 기억을 얼마나 밖으로 보여줄지</td>
</tr>
</tbody></table>
<p><strong>Forget Gate</strong></p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/a3b9b155-ee84-420a-831c-a831e1d70090/image.png" alt=""></p>
<ul>
<li>이전 기억 c_{t-1} 중에서 무엇을 얼마나 유지할지 결정하는 것</li>
<li>이전 기억 전체를 한 번에 버리거나 유지하는 게 아니라, 기억 벡터의 각 요소마다 따로 조절함.</li>
<li>Forget gate는 이전 hidden state h_{t-1}와 현재 입력 x_t를 이용해 f_t를 계산하며, f_t는 sigmoid를 거치기 때문에 0과 1 사이의 값을 가진다. 이 값은 이전 cell state c_{t-1}에 element-wise로 곱해져 각 기억 성분을 얼마나 유지할지 결정한다. 값이 0에 가까우면 해당 기억은 거의 버려지고, 1에 가까우면 거의 유지된다.</li>
</ul>
<p>$$
f_t = σ(W_f[h_{t-1}, x_t] + b_f)
$$</p>
<pre><code>f_t ≈ 0 → 이전 기억을 거의 버림
f_t ≈ 1 → 이전 기억을 거의 유지

h_{t-1}와 x_t가 들어옴
        ↓
sigmoid를 통과해서 f_t 계산
        ↓
f_t가 이전 cell state c_{t-1}에 곱해짐
        ↓
이전 기억 중 유지할 부분만 남음</code></pre><p><strong>Input Gate</strong></p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/7c564048-af27-480f-9fda-5604d9e674e8/image.png" alt=""></p>
<ul>
<li>새로운 정보를 얼마나 cell state에 넣을지 결정하는 것</li>
<li>현재 들어온 정보 중 무엇을 새 기억으로 저장할지 결정하는 것</li>
</ul>
<p>$$
i_t = σ(W_i[h_{t-1}, x_t] + b_i)
$$</p>
<pre><code>1. 무엇을 새로 저장할 후보로 만들까? → candidate cell state
2. 그중 얼마나 실제로 저장할까? → input gate

i_t ≈ 0 → 새 정보를 거의 저장하지 않음
i_t ≈ 1 → 새 정보를 거의 그대로 저장함

현재 입력 x_t + 이전 hidden state h_{t-1}
        ↓
1) input gate i_t 계산
2) candidate cell state c̃_t 계산
        ↓
i_t ⊙ c̃_t
        ↓
cell state에 새로 추가할 정보

sigmoid 가지는 input gate i_t를 계산하고,
tanh 가지는 candidate memory c̃_t를 계산한다.
이 둘을 원소별로 곱한 값이 cell state에 새로 기록되는 정보가 된다.

현재 입력과 이전 hidden state를 보고,
새로 저장할 후보 기억 c̃_t를 만들고,
input gate i_t가 그 후보 기억을 얼마나 실제 memory에 쓸지 결정한다.</code></pre><p><strong>Candidate Cell State</strong></p>
<ul>
<li>새로 저장할 후보 기억을 만드는 것</li>
<li>cell state에 들어간 최종 기억은 아니고, 이번 시점에서 새로 기억하면 좋을 만한 정보 후보를 만드는 것으로 여기서는 sigmoid가 아닌 tanh를 활용함.</li>
<li><code>tanh</code>는 값을 −1~ 1 사이로 만들기 때문에, 새로 저장할 정보의 내용 자체를 만들어내는 역할을 함. 다시 말해, 양수 값 → 이 방향의 정보를 추가하자 / 음수 값 → 이 방향의 정보를 줄이거나 반대로 반영하자 / 0에 가까운 값 → 별로 반영할 정보가 없다를 의미함.</li>
</ul>
<p>$$
c̃<em>t = tanh(W_c[h</em>{t-1}, x_t] + b_c)
$$</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>역할</th>
<th>값 범위</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>Candidate cell state</td>
<td>새로 저장할 정보의 “내용” 생성</td>
<td>-1 ~ 1</td>
<td>어떤 정보를 넣을지</td>
</tr>
<tr>
<td>Input gate</td>
<td>그 정보를 얼마나 저장할지 조절</td>
<td>0 ~ 1</td>
<td>얼마나 넣을지</td>
</tr>
</tbody></table>
<p><strong>Cell State Update</strong></p>
<ul>
<li>새 기억 = 유지할 과거 기억 + 새로 쓸 현재 기억</li>
<li>첫 번째 항은 이전 memory 중 선택된 부분을 유지하는 것이고 두 번째 항은 새 candidate memory 중 선택된 부분을 쓰는 것을 의미함.</li>
<li>LSTM은 메모리 유지와 메모리 업데이트를 명확하게 분리하는 것을 이 업데이트 식을 통해 확인할 수 있으며, 이러한 분리가 LSTM이 일반 RNN보다 더 긴 종속성을 더 잘 처리할 수 있는 주요 이유임.</li>
</ul>
<p>$$
c_t = f_t ⊙ c_{t-1} + i_t ⊙ c̃_t
$$</p>
<p><strong>Output Gate</strong></p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/e80acefe-62e4-41bf-91ad-65be7d395f74/image.png" alt=""></p>
<ul>
<li>내부 기억 <code>c_t</code> 중에서 얼마나 밖으로 보여줄지 결정하는 것</li>
<li>LSTM이 기억하고 있는 내용 c_t를 모두 밖으로 내보내는 것이 아닌, output gate가 허락한 만큼만 h_t로 내보냄.</li>
</ul>
<p>$$
o_t = σ(W_o [h_{t-1}, x_t] + b_o)
$$</p>
<p><strong>Hidden State</strong></p>
<ul>
<li>cell state를 tanh로 정리한 뒤, output gate가 허락한 만큼만 hidden state로 내보내는 것</li>
<li>정리된 내부 기억 × output gate의 허락량 = 현재 hidden state h_t</li>
</ul>
<p>$$
h_t = o_t ⊙ tanh(c_t)
$$</p>
<pre><code>h_{t-1}, x_t
      ↓
sigmoid
      ↓
o_t 계산

c_t → tanh(c_t)
          ↓
      o_t와 곱함
          ↓
        h_t

cell state c_t는 내부 기억이고, hidden state h_t는 output gate가 허락한 만큼 밖으로 드러난 표현임.</code></pre><pre><code>이전 기억 c_{t-1}
      ↓ **forget gate가 조절**
유지할 기억

새 후보 기억 c̃_t
      ↓ **input gate가 조절**
추가할 기억

유지할 기억 + 추가할 기억
      ↓
현재 cell state c_t
      ↓ **output gate가 조절**
hidden state h_t</code></pre><blockquote>
<p>Vanilla RNN은 과거 정보를 하나의 hidden state h_t에 계속 압축하여 저장하기 때문에 sequence가 길어질수록 정보가 손실되거나 덮이는 information bottleneck 문제가 발생할 수 있다. LSTM은 이를 해결하기 위해 별도의 기억 경로인 cell state <code>c_t</code>를 도입한다. <code>c_t</code>는 장기 기억을 저장하는 내부 memory pathway이고, <code>h_t</code>는 출력이나 다음 layer에 전달되는 hidden representation이다. 또한 LSTM은 forget gate, input gate, output gate를 사용하여 이전 기억을 얼마나 유지할지, 새로운 정보를 얼마나 추가할지, 저장된 기억을 얼마나 출력할지를 조절한다.</p>
<p> LSTM은 매 시점마다 이전 hidden state (h_{t-1})와 현재 입력 (x_t)를 함께 보고, cell state (c_t)와 hidden state (h_t)를 계산한다. 먼저 forget gate는 이전 cell state (c_{t-1})에 저장된 기억 중 어떤 정보를 유지하고 어떤 정보를 잊을지 결정한다. 그다음 input gate는 현재 입력으로부터 들어온 새로운 정보를 얼마나 cell state에 반영할지 결정하고, candidate cell state (\tilde{c}_t)는 새로 저장될 후보 기억을 만든다.</p>
<p> 이후 cell state update 단계에서 이전 기억 중 유지할 부분 (f_t \odot c_{t-1})과 새로 추가할 후보 기억 (i_t \odot \tilde{c}_t)를 더해 현재 cell state (c_t)를 만든다. 즉, (c_t)는 과거 기억 중 필요한 것은 남기고, 현재 시점에서 필요한 새 정보는 추가한 결과이다.</p>
<p> 마지막으로 output gate는 내부 기억인 (c_t) 중에서 현재 시점의 hidden state (h_t)로 얼마나 밖에 드러낼지 결정한다. 이때 (c_t)를 (tanh)로 정리한 뒤 output gate가 허락한 만큼만 (h_t)로 내보낸다. 따라서 LSTM은 단순히 모든 정보를 계속 덮어쓰는 것이 아니라, gate를 이용해 무엇을 잊고, 무엇을 저장하고, 무엇을 출력할지 조절하는 구조이다.</p>
<ul>
<li><strong>forget gate</strong> → 이전 기억 중 무엇을 유지할지 결정</li>
<li><strong>input gate</strong> → 새로운 정보 중 무엇을 추가할지 결정</li>
<li><strong>output gate</strong> → 저장된 기억 중 무엇을 밖으로 보여줄지 결정</li>
</ul>
</blockquote>
<h3 id="gru-gated-recurrent-unit">GRU (Gated Recurrent Unit)</h3>
<p> LSTM은 강력한 모델이지만 상대적으로 복잡하다. LSTM은 세 개의 gate를 가지고 있으며, <strong>cell state</strong>와 <strong>hidden state</strong>를 분리해서 사용한다.</p>
<p> 반면 <strong>GRU</strong>, 즉 <strong>Gated Recurrent Unit</strong>은 이러한 구조를 단순화한 모델이다. GRU는 LSTM처럼 별도의 cell state를 유지하지 않는다. 대신 <strong>hidden state</strong>를 주요 기억 표현으로 사용하고, 이를 update/reset gate라는 두 개의 gate로 조절한다. GRU는 LSTM과 달리 cell state도 없고, hidden state 하나를 기억으로 사용하고 gate도 두개만 사용하므로 LSTM보다 구조도 간단하고 계산량도 적은 편이다. 이 구조는 모델링 능력과 계산 효율성 사이에서 좋은 균형을 제공하는 경우가 많다.</p>
<blockquote>
<p> 정리하면, GRU는 LSTM의 구조를 단순화한 RNN 계열 모델이다. LSTM은 cell state와 hidden state를 분리하고 세 개의 gate를 사용하지만, GRU는 별도의 cell state 없이 hidden state를 주요 기억으로 사용하며 update gate와 reset gate 두 개만 사용한다.
 따라서 GRU는 LSTM보다 구조가 단순하고 계산 효율성이 좋으면서도, 순차 데이터의 장기 의존성을 어느 정도 잘 처리할 수 있다.</p>
</blockquote>
<table>
<thead>
<tr>
<th>모델</th>
<th>기억 상태</th>
<th>gate 개수</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>LSTM</td>
<td>cell state, hidden state 분리</td>
<td>3개</td>
<td>복잡하지만 장기 의존성 처리에 강함</td>
</tr>
<tr>
<td>GRU</td>
<td>hidden state 하나를 기억으로 사용</td>
<td>2개</td>
<td>구조가 단순하고 계산 효율적</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/fd307184-5bd9-4952-83e0-0ba0751d354a/image.png" alt=""></p>
<p><strong>reset gate</strong></p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/851bc3ad-dc38-4100-aef8-2a38999c2b6d/image.png" alt=""></p>
<ul>
<li>후보 hidden state를 만들 때 이전 hidden state를 얼마나 참고할지 결정함.</li>
<li>r_t ≈ 0 → 이전 기억을 거의 무시함 / r_t ≈ 1 → 이전 기억을 많이 참고함</li>
</ul>
<p>$$
r_t = σ(W_r [h_{t-1}, x_t] + b_r)
$$</p>
<p><strong>Candidate Hidden State</strong></p>
<ul>
<li>reset gate로 조절된 과거 정보와 현재 입력을 이용해 새 후보 hidden state를 만듦.</li>
<li><code>r_t⊙h_{t−1}</code> : 이전 hidden state를 그대로 쓰는 것이 아닌 reset gate를 곱해 이전 기억을 얼마나 반영할지 조절한 뒤 후보 기억을 만들어냄.</li>
<li>후보 기억은 현재 입력만 보고 만드는 게 아니라, <strong>필요한 만큼만 과거 기억을 참고해서</strong> 만듦.</li>
<li>현재 입력 x_t + 조절된 이전 기억 → 새 후보 기억 h̃_t</li>
</ul>
<p>$$
h̃<em>t = tanh(W_h [r_t ⊙ h</em>{t-1}, x_t] + b_h)
$$</p>
<p><strong>update gate</strong></p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/2336387b-a2c9-4a05-a342-9ccef0767d30/image.png" alt=""></p>
<ul>
<li>이전 hidden state를 유지할지, 새 후보 hidden state를 반영할지 결정함.</li>
</ul>
<p>$$
z_t = σ(W_z [h_{t-1}, x_t] + b_z)
$$</p>
<p><strong>Final Hidden State</strong></p>
<ul>
<li>최종 기억 = 이전 기억 중 남길 부분 + 새 후보 기억 중 반영할 부분</li>
<li>새 hidden state = 이전 hidden state 일부 + 새 후보 hidden state 일부</li>
<li>새 hidden state가 이전 hidden state와 새 candidate hidden state의 weighted mixture</li>
<li>z_t = 0이면 이전 정보를 거의 유지하고, 반대의 경우엔 새 후보 정보를 많이 사용함.</li>
</ul>
<p>$$
h_t = (1 - z_t) ⊙ h_{t-1} + z_t ⊙ h̃_t
$$</p>
<pre><code>1. reset gate r_t 계산
   → 후보 기억을 만들 때 이전 기억을 얼마나 참고할지 결정

2. candidate hidden state h̃_t 계산
   → 현재 입력과 조절된 이전 기억으로 새 후보 기억 생성

3. update gate z_t 계산
   → 이전 기억과 새 후보 기억을 어떤 비율로 섞을지 결정

4. final hidden state h_t 계산
   → 최종 기억 완성</code></pre><h2 id="rnn-lstm-gru-비교">RNN, LSTM, GRU 비교</h2>
<p> RNN, LSTM, GRU는 모두 시간에 따라 state를 유지하면서 sequential data를 처리한다. 차이는 <strong>memory를 어떻게 관리하느냐</strong>이다.</p>
<p> Vanilla RNN은 hidden state만 사용한다. 구조가 단순하지만 긴 sequence에서 long-term dependency를 학습하기 어렵다.</p>
<p> LSTM은 cell state와 세 개의 gate를 도입한다. 이를 통해 memory를 유지하고, 새로 쓰고, 외부로 노출하는 과정을 명시적으로 제어할 수 있다.</p>
<p> GRU는 별도의 cell state를 제거하고 두 개의 gate만 사용한다. 그래서 LSTM보다 단순하면서도 memory를 조절할 수 있어, 모델링 성능과 계산 효율 사이의 균형을 제공한다.</p>
<p> 실제 문제에서는 먼저 간단한 RNN을 baseline으로 사용해보고, 같은 검증 방식으로 GRU와 LSTM을 비교하는 것이 좋다. 실험 결과는 모델의 절대 순위가 아니라, 데이터 특성에 맞게 해석해야 한다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>RNN</th>
<th>LSTM</th>
<th>GRU</th>
</tr>
</thead>
<tbody><tr>
<td>기억 구조</td>
<td>hidden state만 사용</td>
<td>cell state와 hidden state 사용</td>
<td>hidden state만 사용하되 gate로 조절</td>
</tr>
<tr>
<td>게이트</td>
<td>없음</td>
<td>forget, input, output</td>
<td>update, reset</td>
</tr>
<tr>
<td>장기 의존성</td>
<td>약함</td>
<td>강함</td>
<td>비교적 강함</td>
</tr>
<tr>
<td>파라미터 수</td>
<td>적음</td>
<td>많음</td>
<td>LSTM보다 적음</td>
</tr>
<tr>
<td>계산 효율</td>
<td>높음</td>
<td>낮은 편</td>
<td>중간~높음</td>
</tr>
<tr>
<td>학습 안정성</td>
<td>불안정할 수 있음</td>
<td>보통 안정적</td>
<td>보통 안정적</td>
</tr>
<tr>
<td>추천 사용</td>
<td>짧은 sequence, baseline</td>
<td>긴 문맥, 정교한 memory 제어</td>
<td>정확도와 효율의 균형</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[ResNet 이해]]></title>
            <link>https://velog.io/@inhwaaa_v/ResNet-%EC%9D%B4%ED%95%B4</link>
            <guid>https://velog.io/@inhwaaa_v/ResNet-%EC%9D%B4%ED%95%B4</guid>
            <pubDate>Wed, 17 Jun 2026 21:39:08 GMT</pubDate>
            <description><![CDATA[<h2 id="단순히-층만-쌓으면-안-되는-이유"><strong>단순히 층만 쌓으면 안 되는 이유</strong></h2>
<p> 깊은 네트워크 학습을 어렵게 만드는 대표적인 문제로 <strong>gradient vanishing/exploding</strong>이 있다. 하지만 ResNet 논문에서 강조한 <strong>degradation problem</strong>은 단순히 gradient vanishing 때문만은 아니며, 깊은 plain network가 identity mapping에 가까운 쉬운 해조차 잘 찾지 못하는 최적화 문제로 볼 수 있다.</p>
<blockquote>
<p><strong>“20층 네트워크 위에 Identity Mapping을 추가하면 최소한 20층과 동일한 성능은 나와야 하지만 그렇게 되지 않는 이유”</strong>
→ <strong>추가된 층들이 항등 함수(Identity Mapping)조차 제대로 학습하지 못하는 것</strong>이 문제!
→ 기울기 소실 문제도 부분적인 원인이지만, 근본적으로 plain network가 identity mapping을 학습하기에 비효율적인 구조를 지니고 있기 때문에 발생하는 문제임.
→ 해결 : Residual Learning &amp; Skip connection</p>
</blockquote>
<h3 id="gradient-vanishing"><strong>Gradient Vanishing</strong></h3>
<p> 역전파 과정에서 입력층으로 갈수록, 기울기가 점차적으로 작아지는 현상이 발생할 수 있다. 이러한 현상으로 입력층에 가까운 층들에서 가중치들이 업데이트가 제대로 되지 않으면 결국 최적의 모델을 찾을 수 없게 되고, 이것을 기울기 소실이라 한다.</p>
<h3 id="gradient-exploding"><strong>Gradient Exploding</strong></h3>
<p> 기울기 소실과 반대로, 기울기가 입력층으로 갈수록 점차 커지다가 가중치들이 비정상적으로 큰 값이 되면서 발산되는 현상이다.</p>
<h3 id="degradation-problem"><strong>Degradation Problem</strong></h3>
<p> 레이어가 깊어질수록 모델이 입력 데이터의 복잡한 패턴과 특징을 찾을 수 있고, 모델이 더 나은 일반화 성능을 보일 수 있기 때문에 레이어를 많이 쌓는 것(깊은 모델을 만드는 것)에 대한 중요성은 늘 인지하고 있었으나, 이를 막고 있던 것이 degradation 문제이다. 이는 딥러닝 모델의 레이어가 깊어졌을 때 모델이 수렴했음에도 불구하고 오히려 레이어 개수가 적을 때보다 모델의 training error가 더 커지는 현상이다.</p>
<p> 이는 레이어가 깊다고 무조건 정확도가 증가하는 것이 아니라, 어느 정도 이상 깊으면 오히려 정확도가 떨어지는 문제로, 단순히 overfitting으로 인해 발생하는 문제가 아닌, 레이어가 깊어질수록 모델의 학습이 잘 되지 않아 발생하는 문제였다. 더 깊은 모델이 이론적으로는 얕은 모델의 성능을 최소한 따라갈 수 있어야 하지만, 실제 plain network에서는 optimizer가 identity mapping에 해당하는 쉬운 해조차 찾지 못해 training error가 증가했다.</p>
<blockquote>
<p>과적합으로 인해 발생하는 문제라면 training error는 낮아지고 validation error는 높아지지만, Degradation은 training error 자체가 오르기에 overfitting과는 거리가 있다.</p>
</blockquote>
<p> ResNet 논문에선 이를 해결하기 위해 residual learning을 제안한다. 원래 의도한 매핑에 skip connection을 추가하여 residual learning을 진행하면, 추가적인 파라미터나 계산 복잡성을 추가하지 않은 채로 기존보다 효율적으로 학습을 진행할 수 있고, degradation 문제를 해결해 레이어가 깊어질수록 training error 또한 줄어드는 것을 확인할 수 있었다.</p>
<h2 id="residual-learning"><strong>Residual Learning</strong></h2>
<p> Residual Learning이란 잔차 학습을 의미한다. ResNet 논문에서는 깊은 plain network가 단순히 층을 많이 쌓는다고 해서 항상 성능이 좋아지는 것이 아니며, 오히려 training error가 증가하는 degradation problem이 발생할 수 있다고 설명한다. 이는 단순한 overfitting 문제가 아니라, 깊은 네트워크가 이론적으로는 쉬워 보이는 identity mapping조차 실제 최적화 과정에서 잘 학습하지 못하는 문제로 볼 수 있다.</p>
<p> 기존 plain network에서는 여러 층이 원하는 매핑 H(x)를 직접 학습해야 한다. 여기서 H(x)는 입력 x가 몇 개의 층을 통과한 뒤 만들어져야 하는 이상적인 출력 feature를 의미한다. 즉, plain network는 입력 x를 원하는 출력 H(x)로 직접 변환하도록 학습한다.</p>
<p> 반면 ResNet은 <strong>H(x)를 직접 학습하는 대신, 입력과 원하는 출력의 차이인 잔차 F(x)를 학습하도록 구조를 바꾸</strong>었다. 이때 잔차는 <strong>F(x) = H(x) - x로 정의</strong>된다. 따라서 원래 학습해야 하는 매핑은 <strong>H(x) = F(x) + x</strong>로 표현할 수 있다. ResNet의 residual block은 <strong>내부 Conv 층을 통해 F(x)를 학습하고, skip connection을 통해 입력 x를 출력에 직접 더한다.</strong> 결과적으로 블록의 출력은 y = F(x) + x가 된다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/c36fc923-2c02-4018-97b6-1e129eb334ea/image.png" alt=""></p>
<p> 이 구조의 장점은 identity mapping이 필요한 상황에서 더 잘 드러난다. Identity mapping이란 입력 x를 아무런 변화 없이 그대로 출력하는 함수로, f(x) = x와 같은 형태를 의미한다. 네트워크 맥락에서 어떤 층이 identity mapping을 한다는 것은, 그 층이 입력 feature를 변형하지 않고 그대로 다음 층으로 전달한다는 뜻이다.</p>
<p> 깊은 네트워크에서는 추가된 층들이 항상 새로운 정보를 학습할 필요는 없다. 어떤 경우에는 입력에 이미 유용한 정보가 포함되어 있어 그대로 전달하는 것이 더 좋을 수 있다. plain network에서는 여러 Conv 층이 H(x) = x라는 identity mapping을 직접 학습해야 하지만, ResNet에서는 skip connection이 입력 x를 이미 그대로 전달해주기 때문에 residual branch는 F(x)를 0에 가깝게 만들기만 하면 된다. 그러면 출력은 y = F(x) + x ≈ 0 + x = x가 되어 identity mapping에 가까운 동작을 할 수 있다.</p>
<p> 즉, ResNet은 깊은 네트워크가 전체 출력을 처음부터 새로 학습하게 하는 대신, 입력에서 바뀌어야 할 부분만 학습하도록 만든다. 이를 통해 불필요한 층이 입력 정보를 망가뜨리는 문제를 완화하고, 깊은 네트워크에서도 최적화가 쉬워진다.</p>
<p> 또한 skip connection은 정보 전달과 gradient flow 측면에서도 중요한 역할을 한다. 일반 CNN에서는 입력 정보와 gradient가 모든 층을 순차적으로 통과해야 하므로, 네트워크가 깊어질수록 정보가 손실되거나 gradient가 약해질 수 있다. 반면 ResNet에서는 입력 x가 skip connection을 통해 출력에 직접 더해지므로, 중요한 feature가 깊은 층까지 보존될 수 있고, 역전파 과정에서도 gradient가 앞쪽 층까지 더 안정적으로 전달될 수 있다.</p>
<p> 따라서 Residual Learning의 핵심은 <strong>H(x)를 직접 학습하는 것이 아니라, 입력과 출력의 차이인 F(x) = H(x) - x를 학습하는 것</strong>이다. <strong>Skip Connection은 입력 x를 출력에 직접 더해주는 경로</strong>이며, 이를 통해 ResNet은 추가 파라미터를 거의 늘리지 않으면서도 깊은 네트워크의 학습 안정성과 최적화 성능을 높일 수 있다.</p>
<h2 id="skip-connection-shortcut-connection">Skip Connection (=Shortcut Connection)</h2>
<p> Shortcut Connection은 입력 x를 출력에 직접 더해주는 경로를 의미한다. 일반적인 딥러닝 네트워크에서는 입력 x가 여러 Conv 층을 순차적으로 통과한 뒤 출력이 만들어진다. 반면 ResNet에서는 <strong>이러한 일반 경로와 별도로, 입력 x가 중간 층들을 건너뛰어 출력 쪽으로 직접 전달되는 경로를 추가</strong>한다. <strong>이후 Conv 층을 거친 출력 F(x)와 shortcut connection을 통해 전달된 입력 x를 더해 최종 출력 y = F(x) + x를 만든다.</strong></p>
<p> 기존 plain network에서는 여러 층이 원하는 매핑 H(x)를 직접 학습해야 한다. 만약 어떤 블록에서 입력을 그대로 출력하는 identity mapping이 최적이라면, plain network는 여러 Conv 층의 가중치를 조정하여 H(x) = x가 되도록 직접 학습해야 한다. 하지만 실제 깊은 plain network에서는 이러한 identity mapping조차 쉽게 학습하지 못해 training error가 증가하는 degradation problem이 발생할 수 있다.</p>
<p> ResNet에서는 shortcut connection이 입력 x를 출력에 직접 더해주기 때문에, identity mapping이 필요한 경우 Conv branch가 F(x)를 0에 가깝게 학습하면 된다. 그러면 최종 출력은 y = F(x) + x ≈ 0 + x = x가 되어 입력을 그대로 전달하는 효과를 낼 수 있다. 즉, ResNet은 전체 출력 H(x)를 처음부터 새로 학습하는 대신, 입력에서 바뀌어야 할 부분인 잔차 F(x)만 학습하도록 만든다.</p>
<p> Shortcut Connection의 장점은 크게 세 가지이다. 첫째, 입력과 출력의 차원이 같은 경우 identity shortcut은 입력 x를 그대로 전달하므로 <strong>별도의 학습 파라미터를 추가하지 않</strong>는다. 둘째, Conv 연산을 추가하는 것이 아니라 단순히 두 feature map을 더하는 방식이므로 <strong>연산량 증가가 매우 작다.</strong> 셋째, <strong>역전파 과정에서 gradient가 Conv 층만을 거치지 않고 shortcut 경로를 통해 앞쪽 층으로 더 직접적으로 전달</strong>될 수 있다. 따라서 깊은 네트워크에서도 기울기 소실을 완화하고 학습을 안정화하는 데 도움을 준다.</p>
<p> 다만 입력과 출력의 채널 수나 feature map 크기가 다를 경우에는 x를 그대로 더할 수 없기 때문에, 1×1 Conv나 downsample을 사용해 shortcut 경로의 차원을 맞춰준다. 이 경우에는 projection shortcut이 사용되므로 일부 파라미터와 연산이 추가될 수 있다.</p>
<h2 id="bottleneck-block">BottleNeck Block</h2>
<p> Bottleneck Block은 ResNet-50 이상처럼 깊은 네트워크를 효율적으로 구성하기 위해 사용되는 Residual Block의 변형 구조이다. 기존 Basic Residual Block은 주로 3×3 Conv 두 개로 구성되지만, Bottleneck Block은 1×1 Conv, 3×3 Conv, 1×1 Conv의 세 개 합성곱 층으로 구성된다.</p>
<p> ResNet-50의 전체 구조를 보면, <strong>가장 앞부분에는 Stem이 존재</strong>한다. <strong>Stem은 입력 이미지가 네트워크에 들어온 직후 처음으로 처리되는 부분</strong>으로, ResNet-50에서는 7×7 Conv와 Max Pooling으로 구성된다. <strong>7×7 Conv는 비교적 큰 커널을 사용하기 때문에 초반에 넓은 영역의 정보를 한 번에 볼 수 있고, stride를 통해 이미지의 해상도를 빠르게 줄이는 역할</strong>도 한다. 즉, Stem은 초기에 큰 receptive field를 확보하면서 입력 이미지의 기본적인 특징을 추출하고, 이후 깊은 stage에서 처리할 수 있도록 feature map 크기를 줄여주는 역할을 한다.</p>
<p> ResNet-50 이상에서 Bottleneck 구조가 사용되는 이유는 <strong>네트워크가 깊어질수록 파라미터 수와 연산량이 크게 증가하기 때문</strong>이다. 특히 3×3 Conv는 입력 채널 수와 출력 채널 수가 클수록 연산량이 많이 증가한다. 따라서 채널 수가 큰 상태에서 바로 3×3 Conv를 수행하면 계산 비용이 커진다. 이를 해결하기 위해 Bottleneck Block을 활용한다.</p>
<p> 여기서 1×1 Conv는 공간 크기를 크게 바꾸기보다는 주로 채널 축에 작용하는 연산이다. <strong>즉, 입력 채널 수와 출력 채널 수를 어떻게 설정하느냐에 따라 채널을 압축하거나 확장할 수 있</strong>다. 예를 들어 C개의 채널을 더 적은 채널로 줄일 수도 있고, 반대로 C개를 2C 또는 4C처럼 더 많은 채널로 확장할 수도 있다. Bottleneck Block에서는 이 성질을 이용해 첫 번째 1×1 Conv로 채널을 축소하고, 마지막 1×1 Conv로 다시 채널을 확장한다.</p>
<p> 구조를 단계별로 보면, 첫 번째 1×1 Conv는 채널 수를 축소하여 연산량을 줄이는 역할을 한다. 이후 Batch Normalization과 ReLU를 통해 학습을 안정화하고 비선형성을 추가한다. 두 번째 3×3 Conv는 실제 공간적 특징을 추출하는 핵심 연산을 수행한다. 이때 앞에서 채널 수를 줄였기 때문에 3×3 Conv의 연산량을 크게 줄일 수 있다. 마지막 1×1 Conv는 줄어든 채널을 다시 확장하여 더 풍부한 feature 표현을 만들 수 있도록 한다. ResNet-50에서는 일반적으로 expansion 값이 4로 설정되어 출력 채널 수가 기본 채널 수의 4배가 된다.</p>
<p> Bottleneck이라는 이름은 중간에 채널 수가 좁아지는 구간이 존재하기 때문에 붙은 이름이다. 이러한 방식은 큰 채널 차원에서 바로 3×3 Conv를 수행할 때 발생하는 과도한 연산량 문제를 줄이면서도, 마지막에 채널을 확장해 충분한 표현력을 확보할 수 있게 한다.</p>
<p> 정리하면 Bottleneck Block의 흐름은 다음과 같다. 큰 차원에서 바로 3×3 Conv를 수행하면 연산량이 너무 크기 때문에, 먼저 1×1 Conv로 채널을 줄인다. 그다음 작아진 채널에서 3×3 Conv로 핵심 공간 특징을 추출한다. 마지막으로 1×1 Conv를 통해 채널을 다시 확장하여 표현력을 확보한다. <strong>즉, Bottleneck Block은 연산량은 줄이되 깊은 네트워크가 필요한 표현 능력은 유지하기 위한 구조</strong>이다.</p>
<p> ResNet-50은 이러한 Bottleneck Block을 사용하는 대표적인 구조이다. 전체 구조는 초기 Stem 부분에서 7×7 Conv와 Max Pooling을 거친 뒤, Conv2_x, Conv3_x, Conv4_x, Conv5_x 단계로 구성된다. 각 단계에는 Bottleneck Block이 각각 3개, 4개, 6개, 3개 배치된다. 마지막에는 Global Average Pooling과 Fully Connected Layer를 사용해 최종 클래스를 분류한다.</p>
<p> Bottleneck Block 역시 Residual Block의 한 종류이므로 skip connection을 사용한다. 블록 내부의 Conv 층들은 잔차 F(x)를 학습하고, 입력 x는 shortcut connection을 통해 출력에 더해진다. 따라서 최종 출력은 y = F(x) + x가 된다. 만약 입력과 출력의 채널 수나 feature map 크기가 다르면, downsample 또는 1×1 projection shortcut을 사용해 identity의 차원을 맞춘 뒤 더한다.</p>
<pre><code>문제점 : 큰 차원에서 바로 3×3 Conv 하면 연산량이 너무 큼

1×1 Conv로 채널을 줄임
↓
작아진 채널에서 3×3 Conv로 특징 추출
↓
1×1 Conv로 다시 채널을 확장해서 표현력 확보</code></pre><p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/d8c903d9-e477-4742-9950-8fed52a92d54/image.png" alt=""></p>
<table>
<thead>
<tr>
<th></th>
<th>Basic Residual Block</th>
<th>Bottleneck Block</th>
</tr>
</thead>
<tbody><tr>
<td>구성(conv)</td>
<td>3x3 → 3x3</td>
<td>1x1 → 3x3 → 1x1</td>
</tr>
<tr>
<td>층 수</td>
<td>2개</td>
<td>3개</td>
</tr>
<tr>
<td>사용 모델</td>
<td>ResNet-18, 34</td>
<td>ResNet-50, 101, 152</td>
</tr>
<tr>
<td>목적(특징)</td>
<td>단순하고 가벼운 구조를 가진다.</td>
<td>연산량을 절감하면서 층을 깊게 쌓을 수 있다.</td>
</tr>
</tbody></table>
<h2 id="gradient-flow"><strong>Gradient Flow</strong></h2>
<p> ResNet의 skip connection은 정보 전달과 gradient flow 측면에서도 중요한 역할을 한다. 일반적인 CNN에서는 입력 정보가 모든 층을 순차적으로 통과해야 하므로, 네트워크가 깊어질수록 정보가 점차 손실되거나 변형될 수 있다. 또한 역전파 과정에서도 gradient가 여러 층을 거치며 전달되기 때문에, 앞쪽 layer로 갈수록 gradient가 약해지는 gradient vanishing 문제가 발생할 수 있다.</p>
<p> 반면 ResNet은 입력 x를 shortcut connection을 통해 출력에 직접 더하는 구조를 사용한다. Residual Block의 출력은 H(x) = F(x) + x로 표현되며, 여기서 F(x)는 Conv 층들이 학습하는 잔차이고 x는 shortcut을 통해 그대로 전달되는 입력이다. 따라서 입력에 이미 유용한 정보가 포함되어 있고 이를 크게 변형하지 않는 것이 유리한 경우, residual branch는 F(x)를 0에 가깝게 학습할 수 있다. 그러면 H(x) = F(x) + x ≈ x가 되어, 블록은 입력을 크게 변형하지 않는 identity mapping에 가까운 동작을 할 수 있다.</p>
<p> 이 구조 덕분에 불필요한 layer가 입력 정보를 망가뜨리는 문제를 완화할 수 있다. 즉, ResNet은 모든 층이 전체 출력을 처음부터 새로 학습하도록 만드는 것이 아니라, 입력 정보는 shortcut을 통해 보존하고 Conv 층은 입력에서 필요한 수정값만 학습하도록 한다. 이로 인해 깊은 네트워크에서도 정보 흐름이 안정적으로 유지된다.</p>
<p> 또한 skip connection은 역전파 과정에서도 gradient가 흐를 수 있는 직접 경로를 제공한다. 일반 CNN에서는 gradient가 모든 층을 거쳐야 하므로 네트워크가 깊어질수록 gradient가 소실되거나 왜곡될 수 있다. 하지만 ResNet에서는 shortcut 경로를 통해 gradient가 앞쪽 layer까지 더 직접적으로 전달될 수 있으므로, 학습 초기에 gradient 흐름이 차단되는 문제를 완화할 수 있다. 따라서 identity shortcut은 추가 파라미터를 거의 늘리지 않으면서도 정보와 gradient의 흐름을 안정화하여 깊은 네트워크의 최적화를 쉽게 만든다.</p>
<blockquote>
<p><strong>정리
-</strong> 네트워크가 너무 깊으면 gradient가 앞쪽까지 가는 동안 계속 약해지는 gradient Vanishing 문제가 있는데, skip connection을 통해서 네트워크가 깊어질 때도 앞쪽 layer까지 전달되도록 함.</p>
</blockquote>
<ul>
<li>F(x)가 0에 가까워지면 H(x)=F(x)+x≈x가 되므로, residual block은 최소한 입력을 크게 변형하지 않는 identity mapping에 가까운 동작을 할 수 있다. 따라서 불필요한 layer가 입력 정보를 망가뜨리는 문제를 완화하고, 깊은 네트워크의 최적화를 쉽게 만든다.</li>
<li>입력에 이미 유용한 정보가 포함되어 있고 이를 크게 변형하지 않는 것이 유리한 경우, ResNet은 residual branch의 출력인 F(x)를 0에 가깝게 학습하여 skip connection을 통해 입력 정보를 보존한 채 전달할 수 있다. 따라서 블록 내부의 Conv 층은 전체 출력을 처음부터 새로 만들 필요 없이, 입력에서 필요한 수정값만 학습하면 된다. 또한 identity shortcut은 추가 파라미터를 거의 사용하지 않으면서도 정보와 gradient가 흐를 수 있는 직접 경로를 제공하므로, 깊은 네트워크에서도 학습을 안정화하고 최적화를 쉽게 만든다. (즉, 정보량이 차있으면 학습하지 않고 흘러가므로 학습 파라미터 늘리지 않고도 효율적으로 학습 가능)<blockquote>
</blockquote>
</li>
</ul>
<blockquote>
<p>**일반 CNN과 ResNet 간의 Gradient Flow 비교</p>
</blockquote>
<ul>
<li>일반 CNN** : 네트워크가 깊어질수록 gradient가 소실되거나 왜곡되어 학습이 불안정해짐</li>
<li><strong>ResNet</strong> : skip connection 경로를 통해 gradient가 직접 앞쪽 층까지 전달되며, 학습 초기에 gradient 흐름이 차단되지 않음<blockquote>
</blockquote>
</li>
</ul>
<h2 id="code">Code</h2>
<p>Bottleneck Block은 ResNet-50에서 사용하는 핵심 블록이다. 구조는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/eb7ae0f8-49ea-4da1-ac45-9356d05ff5e5/image.png" alt=""></p>
<pre><code>1x1 Conv → BN → ReLU
3x3 Conv → BN → ReLU
1x1 Conv → BN
+ identity
ReLU</code></pre><p> 첫 번째 <code>1x1 Conv</code>는 채널 수를 줄여 연산량을 감소시킨다. 두 번째 <code>3x3 Conv</code>는 줄어든 채널 상태에서 공간적 특징을 추출한다. 마지막 <code>1x1 Conv</code>는 채널 수를 다시 확장한다. 이때 <code>expansion = 4</code>이므로 최종 출력 채널은 <code>out_channels × 4</code>가 된다. 예를 들어 <code>out_channels = 64</code>라면 Bottleneck Block의 최종 출력 채널은 <code>64 × 4 = 256</code>이 된다. </p>
<p> forward 흐름 이해에서 중요한 것은 <code>identity = x</code> 를 저장하는 부분이다. 이는 입력 <code>x</code>를 skip connection에 사용하기 위해 따로 저장한다. 그 다음 Conv 층들을 통과한 결과가 <code>out</code>이다. 이 <code>out</code>은 ResNet 관점에서 <strong>잔차 F(x)</strong> 에 해당한다. 마지막에 <code>out += identity</code> 를 수행함으로써 <code>출력 = F(x) + x</code>가 된다. 이게 ResNet의 핵심 구조다. 입력 <code>x</code>를 그대로 더해주기 때문에, 블록 내부 Conv 층은 전체 출력 H(x)를 처음부터 새로 만들 필요 없이 입력에서 바뀌어야 할 부분인 F(x)만 학습하면 된다.</p>
<p> 아래의 코드에서도 <code>identity = x</code>로 입력을 저장하고, 필요한 경우 downsample을 수행한 뒤 <code>out += identity</code>로 skip connection을 구현한다.</p>
<p> 또, downsample이 필요한 경우에서 두 텐서를 더하려면 shape이 같아야 한다. 예를 들어 main path의 출력이 [N, 256, 56, 56]인데 identity가 [N, 64, 56, 56]이면 채널 수가 달라 더할 수 없고, stride=2가 적용되어 main path의 feature map 크기가 줄어들면 아래와 같이 공간 크기도 달라질 수 있다.</p>
<pre><code>out      : [N, 512, 28, 28]
identity : [N, 256, 56, 56]</code></pre><p> 그래서 이런 경우 shortcut path에도 <code>1x1 Conv + BN</code>을 적용해서 identity의 크기를 맞춘다. 즉, downsample의 역할은 identity의 채널 수와 feature map 크기를 main path 출력과 맞추는 것이다.</p>
<pre><code class="language-python">import torch.nn as nn # 신경망 네트워크(레이어, 손실 함수 등)를 구성하는 기본 모듈
import torch.nn.functional as F # Pytorch에서 자주 쓰는 함수들(functional API). 예 : F.relu, F.conv2d 등.

class Bottleneck(nn.Module):
  expansion = 4 # Bottleneck 확장 배수 (마지막 1*1 Conv를 통해 채널 수를 4배로 확장한다는 설정값)
  # in_channels : 블록의 입력 특징 맵(feature map)의 채널 수. (이전 블록의 출력 채널 수)
  # out_channels : 1*1과 3*3을 처리한 후, 마지막 단계에서 확장되기 전 기본 채널 수
  # stride : 합성곱의 보폭. 보통은 1이지만, 블록에 따라 2로 설정되어 크기를 절반으로 줄이는 역할을 할 수도 있음.
  # downsample : skip connection에서 입력(identify)의 차원을 맞춰주기 위한 추가 모듈(합성곱+BN 등)이 들어갈 수 있음.
  def __init__(self, in_channels, out_channels, stride=1, downsample=None):
    super(Bottleneck, self).__init__()
    # 부모 클래스(nn.Module)의 초기화 메서드를 호출해 필요한 내부 구조 설정
    # 1*1 Conv
    self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False)
    # bias=False : 합성곱 후에 BatchNorm을 바로 쓰면, 합성곱의 bias가 불필요하다고 판단하여 생략하는 경우가 많음.
    self.bn1 = nn.BatchNorm2d(out_channels) # Batch Normalization으로 학습을 안정화하고, 학습 속도를 높여준다.
    # 왜? internal covariant shift 문제를 완화시켜 입력 분포를 맞춰주니까

    # 3*3 Conv
    self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
    self.bn2 = nn.BatchNorm2d(out_channels)

    # 1*1 Conv(채널 확장, 출력 채널을 out_channels * expansion(기본 4배)로 확장)
    self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False)
    self.bn3 = nn.BatchNorm2d(out_channels * self.expansion)

    # ResNet의 Bottleneck 구조는 이렇게 첫 번째 1*1 conv에서 채널을 줄였다가 (연산량 감소 효과),
    # 3*3에서 특징 추출 후, 마지막에 다시 1*1 conv로 채널 확장

    self.relu = nn.ReLU(inplace=True)
    self.downsample = downsample
    # skip connection에서 입력 x와 출력 out의 차원(채널수 및 가로/세로 크기)이 다를 경우,
    # 이 값이 합성곱+BN 모듈 등이 되어 identity를 변환해줌
    # (예를 들어 블록의 stride=2로 크기가 절반이 되면 identity도 크기를 맞춰야 하기 때문)

  def forward(self, x): # 블록에서 입력 x가 들어왔을 때, 어떤 계산을 할지 정의
    # 입력(x)를 identity로 복사
    identity = x # skip connection에 사용할 원본 입력을 identity 라는 변수에 잠시 저장

    # 1*1 conv + BN + ReLU -&gt; 3*3 conv + BN + ReLU -&gt; 1*1 Conv + BN 수행
    out = self.conv1(x)
    out = self.bn1(out)
    out = self.relu(out)

    out = self.conv2(out)
    out = self.bn2(out)
    out = self.relu(out)

    out = self.conv3(out)
    out = self.bn3(out)

    # downsample이 있으면 skip connection의 차원을 맞춤
    # 필요 시(채널이나 해상도가 맞지 않을 때) Downsample을 통해 identity 크기 조정
    # 이때, 입력(identity)을 별도 합성곱+BN으로 조정함.
    if self.downsample is not None:
      identity = self.downsample(x)

    # 입력과 출력을 더한 뒤 ReLU를 적용해 잔차를 학습하는 형태 구현
    out += identity # skip connection (잔차 연결) --&gt; out.shape == identity.shape
    # ResNet의 핵심 아이디어는 입력(x)을 출력에 더해 모델이 잔차(residual)만을 학습하도록 한 것
    out = self.relu(out)

    # 결과 반환
    return out</code></pre>
<p> ResNet-50은 크게 다음 구조로 이뤄진다. <code>AdaptiveAvgPool2d((1, 1))</code>은 feature map의 공간 크기가 얼마든 최종적으로 <code>1×1</code> 크기로 줄여준다. 그 다음 <code>torch.flatten(x, 1)</code>을 통해 <code>[batch, channel, 1, 1]</code> 형태를 <code>[batch, channel]</code> 형태로 바꾸고, FC layer에 넣어 최종 클래스 수만큼 출력한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/77c0d77f-7c84-4f03-8d2b-d5f1b0af5768/image.png" alt=""></p>
<h3 id="1-stem">1. Stem</h3>
<p>Stem은 네트워크의 초반 처리 부분이다. ResNet-50에서는 다음과 같이 구성된다.</p>
<pre><code>7×7 Conv(stride=2) + BN + ReLU + MaxPool</code></pre><p> 초반에 7×7처럼 큰 커널을 사용하는 이유는 이미지의 넓은 영역을 한 번에 보면서 초기 특징을 뽑기 위해서다. 또한 stride=2와 MaxPool을 사용해 feature map 크기를 빠르게 줄여 이후 연산량을 줄인다.</p>
<p>정리하면 Stem = 초반에 넓은 영역을 보고, 해상도를 줄이며, 기본 특징을 추출하는 부분이다.</p>
<hr>
<h3 id="2-1×1-convolution">2. 1×1 Convolution</h3>
<p>1×1 Conv는 공간 크기보다는 <strong>채널 축</strong>에 주로 작용한다.</p>
<p>예를 들어 입력이 [N, C, H, W]일 때, <code>out_channels</code>를 어떻게 설정하느냐에 따라</p>
<pre><code>C → 2C
2C → C
C → C/4</code></pre><p>처럼 채널 수를 확장하거나 축소할 수 있다.</p>
<p>Bottleneck Block에서는 이 성질을 이용한다.</p>
<pre><code>1×1 Conv로 채널 축소
→ 3×3 Conv로 특징 추출
→ 1×1 Conv로 채널 확장</code></pre><p>즉, 1×1 Conv는 Bottleneck에서 연산량을 줄이고 표현력을 조절하는 핵심 역할을 한다.</p>
<hr>
<h3 id="3-bottleneck-block">3. Bottleneck Block</h3>
<p> Bottleneck Block의 핵심 목적은 연산량은 줄이고, 표현력은 유지하는 것이다.</p>
<p> 큰 채널 수에서 바로 3×3 Conv를 하면 연산량이 너무 커진다. 그래서 먼저 1×1 Conv로 채널을 줄이고, 줄어든 채널에서 3×3 Conv를 수행한 뒤, 마지막에 다시 1×1 Conv로 채널을 확장한다.</p>
<pre><code>문제점:
큰 채널에서 바로 3×3 Conv를 하면 연산량이 큼

해결:
1×1 Conv로 채널 축소
↓
작아진 채널에서 3×3 Conv로 공간 특징 추출
↓
1×1 Conv로 채널 확장</code></pre><p> Bottleneck이라는 이름도 중간에 채널 수가 좁아지는 구간이 있기 때문에 붙은 이름이다.</p>
<hr>
<h3 id="4-residual-learning">4. Residual Learning</h3>
<p> 기존 plain network는 입력 <code>x</code>를 원하는 출력 <code>H(x)</code>로 직접 바꾸려고 한다.</p>
<pre><code>Plain Network:
x → H(x)</code></pre><p> ResNet은 다르게 접근한다.</p>
<pre><code>ResNet:
H(x)를 직접 학습하지 않고,
F(x) = H(x) - x 를 학습</code></pre><p> 그리고 출력은 H(x) = F(x) + x로 만든다</p>
<p>즉, 전체 출력을 처음부터 새로 만드는 것이 아니라, 입력에서 바뀌어야 할 차이만 학습하는 방식이다.</p>
<hr>
<h3 id="5-skip-connection">5. Skip Connection</h3>
<p> Skip Connection은 입력 <code>x</code>를 중간 Conv 층을 건너뛰어 출력에 직접 더해주는 연결이다.</p>
<pre><code>출력 = F(x) + x</code></pre><p> 이 구조의 장점은 두 가지다. 첫째, 입력에 이미 유용한 정보가 있으면 그대로 보존할 수 있다. 둘째, 역전파 과정에서 gradient가 shortcut 경로를 통해 앞쪽 층까지 더 안정적으로 전달될 수 있다. 그래서 깊은 네트워크에서 발생하는 degradation problem과 gradient vanishing 문제를 완화하는 데 도움을 준다.</p>
<pre><code class="language-python">class MyResNet50(nn.Module):
  def __init__(self, num_classes=1000):
    super(MyResNet50, self).__init__()

    # 초기 Stem (보통 ResNet에서는 첫 번째 Conv+MaxPool을 거쳐 이미지 크기를 빠르게 줄이는 과정을 Stem이라고 부름.)
    self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
    self.bn = nn.BatchNorm2d(64)
    self.relu = nn.ReLU(inplace=True)
    self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)

    # inplanes (현재 블록의 입력 채널 수를 추적하는 변수) 설정
    # 처음에는 64(Stem의 출력 채널)로 설정
    self.inplanes = 64

    # 레이어 구성
    self.layer1 = self._make_layer(Bottleneck, 64, 3, stride=1)
    self.layer2 = self._make_layer(Bottleneck, 128, 4, stride=2)
    self.layer3 = self._make_layer(Bottleneck, 256, 6, stride=2)
    self.layer4 = self._make_layer(Bottleneck, 512, 3, stride=2)

    # 분류기 (Head) 부분
    self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) # 입력 Feature Map 크기와 상관 없이 (1,1) 크기로 만듦
    self.fc = nn.Linear(512*Bottleneck.expansion, num_classes)

  # 레이어 구성
  # 각 레이어의 첫 번째 블록 : 이전 layer 출력 채널 -&gt; out_channels -&gt; out_channels -&gt; out_channels*4
  # 나머지 블록 : out_channels*4 -&gt; out_channels -&gt; out_channels -&gt; out_channels*4
  # layer1 : Bottleneck 3개
  # layer2 : Bottleneck 4개
  # layer3 : Bottleneck 6개
  # layer4 : Bottleneck 3개
  # Bottleneck Block 여러 개를 묶어서 하나의 stage를 만드는 함수
  def _make_layer(self, block, out_channels, blocks, stride=1):
    downsample = None

    # stride!=1이거나, 채널 수가 맞지 않으면 다운 샘플 구성
    # 다운 샘플(downsample) : stride가 2가 되면(또는 채널 수가 달라지면),
    # skip connection에서 입력 x와 출력의 크기를 맞춰줘야 함.
    # downsample은 1*1 conv와 BN으로 구성되며, stride를 조정해 공간 크기를 줄이거나, 채널 수를 맞춰줌.

    # Feature Map 크기가 바뀌거나 채널 수가 바뀔 경우 다운 샘플 수행
    if stride != 1 or self.inplanes != out_channels * block.expansion:
      # downsample 수행
      # 1. kernel_size=1 Conv로 채널 수 맞춤 (out_channels * block.expansion == Bottleneck 블록의 최종 출력 채널 수)
      # 입력 채널 수 != 출력 채널 수 확
      # 2. stride=stride로 feature map 크기 맞춤
      # main path 말고 shortcut path에서 사용할 layer 생성 --&gt; out.shape == identity.shape 이어야 하므로
      downsample = nn.Sequential(
          nn.Conv2d(self.inplanes, out_channels * block.expansion, kernel_size=1, stride=stride, bias=False),
          nn.BatchNorm2d(out_channels * block.expansion)
      )

    layers = [] # Bottleneck 블록을 담을 빈 리스트
    # 첫 번째 Bottleneck 블록 추가
    layers.append(block(self.inplanes, out_channels, stride, downsample)) # 입력이 줄어들거나 채널이 변경되는 지점
    self.inplanes = out_channels * block.expansion # self.inplanes를 업데이트 (새로운 블록의 출력 채널 / 다음 Bottleneck 블록들의 입력 채널 수)

    # 나머지 블록은 stride=1이므로 downsample 없이 이어짐
    # ResNet 블록들을 하나씩 담음
    # 3 / 4 / 6 / 3
    for _ in range(1, blocks):
      layers.append(block(self.inplanes, out_channels))

    # Bottleneck 여러 개를 하나의 layer로 묶어 반복
    return nn.Sequential(*layers) # 마지막에 nn.Sequential(*layers)로 묶어 하나의 큰 레이어로 반환

  def forward(self, x):
    # Stem
    x = self.conv1(x)
    x = self.bn(x)
    x = self.relu(x)
    x = self.maxpool(x)

    # 4개의 레이어
    x = self.layer1(x)
    x = self.layer2(x)
    x = self.layer3(x)
    x = self.layer4(x)

    # 분류
    x = self.avgpool(x)
    x = torch.flatten(x, 1)
    x = self.fc(x)
    return x

# 모델 예시 생성 (최종 출력)
model = MyResNet50(num_classes=1000)
print(model)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[AlexNet]]></title>
            <link>https://velog.io/@inhwaaa_v/AlexNet</link>
            <guid>https://velog.io/@inhwaaa_v/AlexNet</guid>
            <pubDate>Wed, 17 Jun 2026 21:36:23 GMT</pubDate>
            <description><![CDATA[<h1 id="1-alexnet이-중요한-이유">1. AlexNet이 중요한 이유</h1>
<h3 id="설정-수치에-관하여">설정 수치에 관하여</h3>
<p> 설정 수치에서 나오는 ILSVRC는 ImageNet Large Scale Visual Recognition Challenge 의 약자로, 매년 열린 이미지 인식 대회이다.</p>
<ul>
<li>2010년 데이터셋의 특징 :<ul>
<li>테스트 데이터의 정답 레이블이 공개되어 있어, 연구자들이 모델을 학습시킨 후 자유롭게 성능을 검증하고 논문에 수치를 기록하기 용이했다.</li>
<li>AlexNet이 처름 발표된 논문에서 저자들은, 자신들의 모델이 기존 기술보다 얼마나 뛰어난지 증명하기 위해, 이미 기준점이 명확한 2010년 데이터로 먼저 성능을 측정하였고, 이 때 나온 수치가 top-1 37.5%, top-5 17.0% 이다.</li>
<li>AlexNet은 2012년에 논문으로 발표된 모델로, ILSVRC-2010 수치는, 연구 도중 성능 증명을 위해 2010년 당시 대회의 데이터셋을 사용해 AlexNet을 돌려본 결과로, 당시 우승 기록이던 Top-5 오차율 28.2%를 압도적으로 넘는 모습을 보여준다.</li>
<li>2012년 대회는 AlexNet이 실제로 우승을 차지한 대회로, 강의 자료에서 자주 보이는 15.3%는 대회에 제출하기 위해 여러 개의 모델을 앙상블하거나 미세 조정한 버전의 결과값이다.</li>
</ul>
</li>
</ul>
<h3 id="top-1-vs-top-5-error-이란">Top-1 vs Top-5 Error 이란?</h3>
<p>이 수치들은 모델이 얼마나 틀렸는지를 나타내는 오차율(Error Rate)이다. 숫자가 낮을수록 똑똑한 모델임을 의미한다.</p>
<ul>
<li>Top-1 Error (1순위 오차)<ul>
<li>모델이 사진을 보고 “이것은 무엇이다” 라고 예측한 가장 확률이 높은 1개의 클래스가 실제 정답과 일치하지 않는 경우로, 사진은 강아지인데, 모델이 1순위로 고양이라고 예측하면 틀린 것으로 간주된다.</li>
</ul>
</li>
<li>Top-5 Error (5순위 오차)<ul>
<li>모델이 예측한 확률 상위 1위부터 5위까지의 후보군 안에 실제 정답이 없는 경우이다.</li>
<li>예를 들어, 실제 정답이 “체리” 라고 가정했을 때, 모델의 예측 순위가 1위 사과, 2위 배, 3위 자두, 4위 체리, 5위 포도라면, Top-5 기준으로는 정답 처리된다.</li>
<li>이미지넷 데이터셋은 클래스가 1,000개나 되고, 사진 속에 여러 물체가 섞여 있거나 정답이 모호한 경우가 많기 때문에 사용하며, 모델의 전반적인 식별 능력을 보기 위해 Top-5 지표를 중요하게 사용한다.</li>
</ul>
</li>
</ul>
<h3 id="alexnet의-구조적-특이점">AlexNet의 구조적 특이점</h3>
<p> 오늘날엔, ReLU, Dropout 등의 방법을 사용하는 것이 당연하다고 느껴지지만, AlexNet 이전에는 조금 달랐다.</p>
<ul>
<li><p>활성화 함수 :</p>
<ul>
<li>당시 활성화 함수는 Sigmoid나 Tanh와 같은 함수를 주로 사용했지만, 층이 깊어지면 기울기가 사라지는 기울기 소실 문제 때문에 학습 속도가 느리거나 아예 안 되는 경우가 많았다. AlexNet은 ReLU를 활성화 함수로 선택함으로서, 학습 속도를 6배 이상 높일 수 있었다.</li>
</ul>
</li>
<li><p>연산 장치 :</p>
<ul>
<li>딥러닝은 CPU로 진행하기에는 매우 무거웠다. AlexNet의 저자인 훌리안은, 직접 CUDA를 이용해 CPU 2대를 병렬로 연결하는 코드를 짜서 거대한 AlexNet 모델을 학습시켰다. (+ 당시에는 텐서플로우, 파이토치와 같은 프레임워크가 존재하지 않았음)</li>
</ul>
</li>
<li><p>데이터 부족과 과적합 :</p>
<ul>
<li>데이터는 많아졌지만, 모델이 너무 커서 금밤 외워버리는(과적합, Overfitting) 문제가 매우 심했다. 이를 해결하기 위해 Dropout과 Data Augmentation 기술을 적용해 모델의 범용성을 확보했다.</li>
</ul>
<p>위에서 설명한 요소들은, 각각 하나의 기법으로 당시에 논문으로 존재했을 수 있지만, 이들을 조합해 이미지넷이라는 대규모 데이터셋을 뚫어낸 것이 AlexNet이 처음이었다.</p>
<p>추가로, AlexNet이 설계 당시 고민했던 성능 향상과 과적합 방지를 위한 구체적인 장치가 존재한다.</p>
</li>
<li><p>LRN(Local Response Normalization)</p>
<ul>
<li>강한 자극이 주변 자극을 억제하게 만드는 장치이다. 우리 눈의 신경세포는 강한 빛을 받으면, 옆의 세포들이 느끼는 자극을 억제해 대비를 뚜렷하게 만든다. LRN은 이를 흉내 내어, <strong>특정 채널의 값이 너무 크면 주변 채널의 값을 낮춰서 특징을 더 뚜렷하게 부각</strong>시킨다. 하지만, 요즘에는 Batch Normalization(배치 정규화)가 훨씬 효과적이라는 것이 밝혀져 잘 사용하지 않는다.</li>
</ul>
</li>
<li><p>Overlapping Pooling(중첩 풀링)</p>
<ul>
<li>Pooling은 이미지 크기를 줄여 핵심만 뽑는 과정으로, 보통은 겹치지 않게 칸을 나누지만 AlexNet은 칸을 겹쳐서 풀링을 진행했다.</li>
<li>예를 들어, 일반 풀링은 2x2 크기로 2칸씩 이동해 겹치지 않게 설정하지만, 중첩 풀링은 3x3 크기로 2칸씩 이동시켜 1칸씩 겹치도록 설정한 것이다.</li>
<li>AlexNet 연구원들은 이렇게 겹쳐서 정보를 추출했더니, 과적합을 줄이는 효과가 있었다고 이야기했으며, 모델이 이미지를 더 꼼꼼하고 유연하게 보게 만든 역할을 하게 되었다.</li>
</ul>
</li>
</ul>
<h2 id="alexnet의-전체-구조">AlexNet의 전체 구조</h2>
<h3 id="convolution-pooling-densefc-layer에-대하여">Convolution, Pooling, Dense(FC) Layer에 대하여</h3>
<p>AlexNet 구조를 살펴보기 전에 Convolution, Pooling, Fully Connected Layer에 대해 설명하겠다.</p>
<ul>
<li>Convolution<ul>
<li>특징 추출을 위한 Layer로, 필터(혹은 커널)가 입력 이미지를 훑으며, 특징을 추출하는 레이어이다.</li>
<li>연산의 결과값은 Padding, Stride, 필터의 크기로 결정되며, 보통 이미지의 가로x세로 크기가 유지 혹은 줄어들게 되지만, 채널의 수는 깊어진 “특징맵” 이라는 결과값이 나온다.</li>
</ul>
</li>
<li>Pooling<ul>
<li>추출된 특징을 압축하는 레이어이다. 특정 영역(2x2 or 3x3 등)에서 가장 대표적인 값을 하나씩 뽑아내, 압축이 되는 원리를 사용하며, 가장 큰 값을 뽑으면 Max Pooling, 평균 값을 뽑으면 Average Pooling 와 같이 어떤 값을 대표값으로 설정할 것인지 결정한다.</li>
<li>이 때 Pooling은 영역 크기, Stride로 결정되지만, 드물게 Padding을 사용하는 경우도 존재한다. Padding은 오직 이미지의 해상도(가로x세로)를 축약하는 역할만을 가지며, 채널별 연산을 통해 결과적으로 해상도는 줄어들고 채널 수는 그대로 압축된 특징맵을 가진다.</li>
</ul>
</li>
<li>FC Layer<ul>
<li>FC Layer는 Fully Connected Layer의 약자로, 컨볼루션과 풀링 연산을 통해 추출되고, 압축된 특징을 1채널로 평탄화한 후 이 모든 노드들을 다음 단계의 모든 노드들과 하나하나 전부 선으로 연결하는 역할을 한다.</li>
<li>FC는 Dence Layer에서 진행되며, 1채널로 Flatten 시키는 작업은 Dense Layer에 진입하기 바로 직전에 따로 진행하여, 정확하게는 Dense layer 안에는 Flatten 작업이 진행되지 않는다.</li>
<li>연결을 통해 최종적으로, 마지막 층의 노드 개수를 분류하고자 하는 클래스(카테고리)의 개수로 일치시켜 특정 클래스일 점수를 최종 출력값으로 얻는다.</li>
</ul>
</li>
</ul>
<h3 id="alexnet의-전체-구조-보기">AlexNet의 전체 구조 보기</h3>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/5ab53360-ed85-414e-ae8c-64e3b41cfb40/image.png" alt=""></p>
<p> 처음 입력 이미지는 224x224x3 이미지이다. 해당 이미지에 우선 11x11 커널과 Stride를 4로 적용해 첫 컨볼루션 연산이 진행된다. 연산의 결과물은 55x55x48 의 특징맵으로 보이지만, 실제로 두 GPU를 사용하기에 55x55x96의 특징맵이 나온다.</p>
<p> 이 과정 뒤에 이미지에선 생략되어 있지만 LRN 정규화 과정을 거치고, Max Pooling + 컨볼루션 연산을 진행해 27x27x256 특징맵을 추출한 뒤 LRN, Max Pooling 과정을 거쳐 13x13x384 형태의 특징맵을 추출한다.</p>
<p> 이 때 LRN을 초기 두 과정에서만 사용하는 이유는, <strong>AlexNet 개발 당시에 초기 레이어에서 강한 자극(특징)이 나타날 때 주변을 억제하는 것이 효과적이라고 판단</strong>해, <strong>뒤로 갈수록 특징이 추상화</strong>되기 때문에 굳이 LRN을 쓰지 않아도 된다고 판단했기 때문이다.</p>
<p> 이후 Pooling 없이 Convolution 연산만 2회 추가로 진행하는데, AlexNet 개발 당시 특징맵의 축소를 하기 전 이미지의 특징을 더 많이 추출한 뒤 최종 Pooling을 하는 것을 목적으로 하여 해당 방법을 사용했고, 최후에 Max Pooling 후 Fallten으로 1차원으로 펼쳐 Dense Layer를 통해 Fully Connected 구조를 완성하고, 데이터셋의 클래스 개수인 1000개로 최종 연결해 판별한다.</p>
<h3 id="224-227-입력-스케일">224, 227 입력 스케일</h3>
<p> 논문 본문과 그림에는 224x224x3 크기의 이미지를 사용하지만, Conv1 과정 이후 55x55의 이미지 스케일 출력을 얻기 위해선, 입력 이미지를 227x227 no padding 구조나 224x224 padding=2 구조를 사용할 수 있다.</p>
<p> 그럼에도 224x224x3으로 설명했으면 그대로 보면 되는 것이 아니냐. 라고 할 수 있지만, 이유가 존재한다. 저자들은 원본 이미지를 256x256 크기로 리사이징 한 후 이미지에서 여기저기를 224x224 크기로 랜덤하게 잘라내는 Random Cropping 과정을 거쳐 학습 데이터로 사용하였다. 논문에선 자른 크기인 224를 사용했지만, 실제 구현 코드나 실연산 과정에서는 패딩을 추가하거나 입력을 227x227로 맞춰 연산의 정밀도를 높였을 것으로 추정되기에 두 가지 입력 스케일에 관한 이야기가 나온 것이다.</p>
<p> 하지만, 입력 스케일보다 핵심은 큰 커널(11x11)과 큰 Stride(4)에 있다. 이미지넷 데이터는, 224x244(실연산 시 227x227)로 AlexNet 개발 당시에는 <strong>고해상도</strong>에 속하였다. Stride를 1이나 2로 작게 설정하면, 다음 층으로 넘어가는 데이터(특징맵)의 크기가 너무 커져서 당시 GPU 메모리가 감당할 수 없었기에, Stride를 4로 사용함으로써 가로와 세로 크기를 단숨에 1/4로 줄였고, 결과적으로 전체 연산량을 낮춰 학습이 가능하게 만들었다.</p>
<p> 추가로, 커널 또한 큰 커널을 사용함으로, 한 번에 이미지의 더 넓은 구역을 보고자 하였다. 작은 커널은 작은 선이나 점과 같은 세밀한 특징을 찾는 것에 유리하지만, 전체적인 물체의 윤곽이나 구조를 파악하기 위해선 여러 층을 깊게 쌓아야만 한다. AlexNet은 당시 하드웨어적 한계, 소요 시간 등 층을 깊게 쌓기 어려운 환경이었기 때문에, 앞부분에서 큰 커널을 사용해 전반적인 특징을 빠르게 훑어내고자 하였다.</p>
<p> 결국, 초기 레이어에선 이미지의 거칠거나 큰 특징을 잡아내는 역할을 한다. 픽셀 하나하나의 세밀한 정보보단 큰 덩어리와 경계선과 같은 정보가 더 중요하였고, 큰 커널과 Stride를 사용해 큰 특징들을 빠르게 요약해 상위 레이어로 전달하는 역할을 수행하기 위해 위와 같은 구조를 사용하였다.</p>
<h1 id="alexnet-핵심-아이디어">AlexNet 핵심 아이디어</h1>
<h3 id="relu-활성화-함수">ReLU 활성화 함수</h3>
<p> 당시 사용하던 Sigmoid, TanH와 같은 활성화 함수는 포화 계열로, 깊은 네트워크에서 오차 역전파 시 가중치 업데이트가 거의 일어나지 않아 학습 속도가 매우 느려지게 만들었고, 기울기 소실 문제를 동시에 가지고 있었다. 이에 반해, ReLU 함수는 f(x) = max(0, x) 형태로, 양수 영역에서는 기울기가 항상 1로 일정해, 아무리 층이 깊어져도 기울기가 소멸되지 않고 전달되어 관련 연구를 통해 Sigmoid보다 약 6배 더 빠르게 목표 오차율에 도달할 수 있었다.</p>
<p> 추가로, Sigmoid 함수의 구조상 지수 연산이 포함되어 있어 당시 하드웨어 입장에서 꽤나 무거운 연산을 진행해야 했지만, ReLU 함수는 단순히 양수인지만을 판단하는 조건문 하나로 끝나 하드웨어 자원을 아끼는 역할도 동시에 맡을 수 있었다.</p>
<h3 id="lrnlocal-response-normalization">LRN(Local Response Normalization)</h3>
<p> LRN은 같은 공간 위치에서 인접한 채널들의 반응을 함께 바라보는 정규화 방법이다. 쉽게 설명하면 특징맵이 존재할 때 이미지의 위치(x,y)는 고정하고 같은 위치의 다른 채널들의 반응을 살피는 정규화 방법으로, 한 채널의 반응이 두드러진다면, 주변 채널 값을 억제해 그 값을 상대적으로 살려주고, 여러 채널이 동시에 반응한다면, 분모 값이 커지면서 값을 눌러, 툭 튀어나오는 특출난 특징일 수록 비교적 더 높은 값을 가질 수 있도록 하는 정규화 방법이다.</p>
<p> 수식에서 $a_{x,y}^i$는 우리가 정규화하고 싶은 현재 채널의 활성화 값이고, 분모에 있는 $\sum (a_{x,y}^j)^2$은 현재 채널 i를 기준으로 앞뒤로 인접한 n개의 채널을 의미한다. 따라서 현재 채널에 비해, 앞뒤 n개의 채널과의 서열, 관계를 의미한다. 주변 j가 조용하면, 분모가 작아져 나의 값은 그대로 유지되거나 돋보이고, 주변 j가 시끄러우면 분모가 커져 나의 값을 깎는 형태의 정규화가 진행된다. 이때 n, $\alpha$, $\beta$, k는 이를 얼마나 세게 누를 것인가를 결정하는 하이퍼 파라미터일 뿐 수식의 핵심은 위와 같다.</p>
<p> LRN 과정을 ReLU 다음에 하는 이유는, ReLU는 음수를 모두 0으로 만들어버리기 때문에, LRN 계산 시 분모가 음수 때문에 꼬일 일이 없어, 오직 얼마나 강하게 활성화되었는지(양수 값)만 가지고 정규화를 진행할 수 있게 되기 때문이다.</p>
<blockquote>
<p>현대적 Batch Norm은 데이터 배치 방향을 기준으로, 한 번 학습할 때 들어오는 여러 장의 이미지(Batch) 전체를 대상으로 각 채널별 평균과 분산을 구해 전체적인 평균에 맞춰 모든 사진의 값을 고르게 펴주는 방식이다.</p>
</blockquote>
<h3 id="overlapping-pooling-설계">Overlapping Pooling 설계</h3>
<p> 일반적인 Max Pooling은 압축을 위한 Window 크기와 Stride 크기를 같게 설정하여 Pooling 영역이 서로 겹치지 않게 설정한다.(ex: 2x2 window, 2 stride) 하지만, AlexNet은 3x3 window에 stride 2를 사용하여, 인접한 pooling window가 서로 겹치게 설정하였다.</p>
<p> 이러한 방식은, 같은 특징에 대한 작은 위치 변화에 덜 예민하게 만들어, 이미지를 더욱 유연하게 보기 위한 결정이다. 일반적인 풀링 방식은 구역을 딱딱 나누기 때문에, 특징이 경계선에 걸쳐 있으면 정보가 왜곡되거나 소실될 확률이 높지만, 중첩 풀링을 사용해 필터가 겹치면서 지나가기 때문에, 이미지의 한 점이 여러 번의 풀링 연산에 중복해 참여하게 되면서, 특징이 이미지 내에서 옆으로 몇 픽셀 정도 이동하더라도 겹쳐진 윈도우 중 하나에는 여전히 강하게 잡히게 된다. AlexNet 논문에서는 이러한 Overlapping Pooling을 통해 오차율과 과적합을 줄일 수 있었다는 결과를 얻었다고 설명한다.</p>
<h3 id="두-gpu를-사용한-이유">두 GPU를 사용한 이유</h3>
<p> AlexNet 연구 당시 GTX 580 한 장의 메모리는 3GB로, 큰 규모의 네트워크를 한 GPU에 올리기 어려웠기 때문에, AlexNet 저자들은 네트워크를 두 GPU에 분할해 학습하였다.</p>
<p> 이러한 구조적 특징으로 인해, 데이터를 주고받을 때 발생하는 통신 비용으로 인해 학습 속도가 너무 느려지는 문제가 발생하였고, 저자들은 이에 모든 층에서 데이터를 주고받는 것이 아닌 일부 층에서만 주고받는 구조로 변경하였다.</p>
<p> Conv 2, 4, 5 채널에선 각자의 채널에 집중하는 내부 연산 구조를 사용하였다. 이 레이어들은 위 아래 GPU가 서로 소통하지 않고, 자기가 가진 데이터만 가지고 연산을 진행한다. 기본적인 이유는 통신량을 줄이기 위해서였지만, 이러한 구조적 특징으로 인해 각 GPU는 서로 다른 종류의 특징을 독립적으로 더 깊게 학습하는 결과를 함께 얻게 되었다.</p>
<p> Conv 3은 서로의 정보를 공유하는 위치로, Conv1, 2를 거치며 각 GPU가 따로 뽑아낸 특징들은 한 번은 섞어주어야 더 고차원적인 특징을 만들 수 있기 때문에 각 GPU간 교류를 채택하였다.</p>
<p> 마지막 Dense(FC) Layer에서는 모든 단서를 모아 분류해야 하는 단계이기 때문에, 모든 노드가 두 GPU의 결과를 모두 참조하는 구조를 채택하였다.</p>
<h3 id="data-augmentation-dropout">Data Augmentation, Dropout</h3>
<p> AlexNet은 당시 기준으로 매우 큰 규모의 이미지 데이터셋인 ImageNet을 학습 대상으로 사용했고, 네트워크의 깊이와 파라미터 수 역시 상당히 큰 편이었다. 이러한 구조는 높은 표현력을 제공한다는 장점이 있지만, 반대로 학습 데이터의 패턴을 지나치게 외워버리는 과적합(overfitting)이 발생하기 쉽다는 문제도 함께 가진다. 따라서 AlexNet은 모델의 일반화 성능을 높이기 위해 다양한 데이터 증강(data augmentation) 기법을 적극적으로 사용하였다.</p>
<p> 대표적으로 학습 단계에서는 이미지를 256×256 크기로 맞춘 뒤, 그중 임의의 위치에서 224×224 영역을 랜덤하게 crop하여 입력으로 사용하였다. 또한 좌우 반전(horizontal flip)도 함께 적용하였다. 이 방식은 하나의 원본 이미지로부터 여러 형태의 학습 샘플을 만들어내는 효과를 가지므로, 모델이 특정 위치나 방향에만 의존하지 않고 보다 다양한 형태의 특징을 학습하도록 돕는다. 즉, 같은 객체라도 위치가 조금 달라지거나 좌우가 뒤집힌 상황을 함께 경험하게 하여 일반화 능력을 높인 것이다.</p>
<p> 여기에 더해 AlexNet은 RGB PCA, 즉 Lighting Augmentation 기법도 사용하였다. 이 기법의 핵심은 단순히 이미지를 밝게 하거나 어둡게 만드는 것이 아니라, <strong>자연스러운 조명 변화에 가까운 색상 변형을 만들어내는 것</strong>에 있다. 만약 모든 픽셀의 RGB 값에 일정한 수를 더하는 식으로 밝기를 조절하면, 이미지 전체가 단순히 하얗게 뜨거나 어둡게 변할 뿐 실제 환경에서 나타나는 조명 변화와는 거리가 있다. 반면 PCA를 사용하면 학습 데이터 전체의 RGB 분포를 분석하여, 어떤 색 조합이 가장 지배적으로 나타나는지, 그리고 RGB 채널 사이에 어떤 상관관계가 있는지를 파악할 수 있다. 이후 이 주성분 축 방향으로 작은 잡음을 추가하면, 단순한 색 변화가 아니라 실제 조명의 강도나 색온도가 달라지는 것과 유사한 자연스러운 변화를 만들 수 있다. 즉, 모델이 특정 색감이나 조명 조건에만 의존하지 않도록 하여, 조명 변화에 더 강인한 특징을 학습하게 만든 것이다.</p>
<p> AlexNet은 학습 단계뿐 아니라 테스트 단계에서도 성능을 높이기 위한 기법을 사용했다. 대표적인 예가 Multi-Crop 평가이다. 테스트 시에는 한 장의 이미지에서 5개의 crop, 즉 좌상단(top-left), 우상단(top-right), 중앙(center), 좌하단(bottom-left), 우하단(bottom-right) 영역을 잘라내고, 여기에 각각 좌우 반전까지 적용하여 총 10장의 이미지를 만든다. 그리고 완성된 모델에 이 10장의 이미지를 각각 입력하여 예측 결과를 얻는다.</p>
<p> 여기서 중요한 점은, 이러한 과정이 모델 내부의 특정 레이어에 내장된 연산이 아니라는 것이다. 즉, 네트워크 구조 안에 “10-crop 평가 레이어”가 들어 있는 것이 아니라, <strong>추론(inference) 단계에서 동일한 이미지를 여러 방식으로 잘라 만든 뒤 각각을 모델에 통과시키고, 그 결과를 평균내어 최종 예측을 결정하는 외부 평가 전략</strong>이라고 이해하는 것이 정확하다. 각 crop에 대해 모델은 클래스별 softmax 확률을 출력하고, 이 10개의 확률 벡터를 평균낸 뒤 가장 높은 평균 확률을 가진 클래스를 최종 정답으로 선택한다.</p>
<p> 이러한 평가 방식을 사용하는 이유는 한 번의 crop만으로는 이미지의 중요한 특징이 잘려나갈 수 있기 때문이다. 예를 들어 강아지 사진에서 코나 눈과 같은 중요한 부분이 crop 과정에서 일부 사라지면, 해당 crop만 본 모델은 오답을 낼 가능성이 높아진다. 그러나 서로 다른 위치에서 자른 여러 이미지를 함께 사용하면, 어떤 crop에서는 중요한 특징이 잘릴 수 있어도 다른 crop에서는 그 특징이 포함될 수 있다. 따라서 여러 예측을 평균내면 특정 crop의 우연한 실패를 완화할 수 있고, 보다 안정적이고 신중한 최종 판단이 가능해진다. 즉, Multi-Crop 평가는 모델 자체를 바꾸는 기법이라기보다, <strong>이미 학습된 모델의 성능을 더 안정적으로 끌어내기 위한 전략</strong>이라고 볼 수 있다.</p>
<p> 정리하면, AlexNet은 큰 모델에서 발생하기 쉬운 과적합을 줄이기 위해 학습 단계에서 랜덤 크롭, 좌우 반전, RGB PCA 기반의 조명 증강을 사용하였고, 테스트 단계에서는 10-crop 평가를 통해 하나의 이미지에 대한 여러 예측을 평균내어 최종 분류 결과를 결정하였다. 이러한 전략은 모델이 특정 위치, 방향, 색감, 조명 조건에 과도하게 의존하지 않도록 만들고, 최종적으로 더 강인하고 안정적인 분류 성능을 얻는 데 중요한 역할을 하였다.</p>
<p> Dropout은 학습 과정에서 일부 뉴런의 출력을 무작위로 0으로 만들어, 모델이 특정 뉴런이나 특정 연결 구조에 과도하게 의존하지 못하도록 하는 정규화 기법이다. AlexNet에서는 특히 <strong>첫 번째와 두 번째 Fully Connected Layer에 Dropout을 p=0.5</strong>로 적용하였다. 이는 학습 시 각 뉴런이 50% 확률로 비활성화된다는 뜻으로, 한 번의 forward가 수행될 때마다 해당 층의 뉴런들 중 절반 정도가 무작위로 꺼지고, 나머지 뉴런들만을 이용해 예측이 이루어진다. 따라서 모델은 매번 일부 뉴런이 없는 상황에서도 동작할 수 있도록 학습되어야 하며, 자연스럽게 특정 뉴런 하나에만 의존하는 방식의 학습을 피하게 된다.</p>
<p> 이러한 Dropout의 핵심 목적은 <strong>co-adaptation</strong>, 즉 뉴런들 사이의 과도한 상호 의존을 줄이는 데 있다. co-adaptation이란 특정 뉴런들이 서로 강하게 결합되어, 항상 특정 조합으로만 의미 있는 특징을 만들어내는 현상을 말한다. 예를 들어 어떤 뉴런 A가 불완전한 특징을 만들어도, 옆의 뉴런 B가 항상 그것을 보완해주는 식으로 학습이 진행되면 두 뉴런은 서로에게 지나치게 의존하게 된다. 이렇게 형성된 표현은 학습 데이터에서는 잘 작동할 수 있지만, 새로운 데이터가 들어왔을 때는 동일한 조합이 나타나지 않아 성능이 급격히 떨어질 수 있다. 즉, 이는 모델이 데이터의 일반적인 패턴을 배우기보다 특정한 조합을 외워버리는 과적합으로 이어질 위험이 있다. Dropout은 학습 중 매번 다른 뉴런을 무작위로 제거함으로써 이러한 지나친 상호 의존을 끊고, 각 뉴런이 보다 독립적이고 일반적인 특징을 학습하도록 유도한다.</p>
<p> AlexNet에서 Dropout을 <strong>FC1, FC2에는 적용하고 마지막 FC3에는 적용하지 않은 이유</strong>도 구조적으로 설명할 수 있다. AlexNet은 convolution layer를 거쳐 추출한 feature를 마지막의 fully connected layer에서 종합해 분류를 수행하는 구조인데, 이때 전체 파라미터의 상당수가 fully connected layer에 집중되어 있다. 특히 FC1과 FC2는 각각 4,096개의 노드를 가지며, 파라미터 수가 매우 많기 때문에 모델이 학습 데이터를 그대로 외워버릴 가능성이 가장 큰 구간이다. 다시 말해, AlexNet에서 과적합이 가장 심하게 발생할 수 있는 부분이 바로 이 두 개의 큰 fully connected layer이며, Dropout은 이 구간을 강하게 규제하기 위한 장치로 사용된 것이다.</p>
<p> 반면, 마지막 fully connected layer인 FC3는 최종 클래스 점수를 출력하는 층으로, 예를 들어 ImageNet 기준으로 1,000개의 클래스와 직접 연결된다. 이 층은 이미 앞선 층에서 추출되고 정리된 정보를 바탕으로 최종 판별을 수행하는 역할을 하므로, 여기서까지 뉴런을 무작위로 끄면 오히려 분류 결과 자체를 불안정하게 만들 가능성이 있다. 따라서 AlexNet은 과적합 위험이 특히 큰 FC1, FC2에만 Dropout을 적용하고, 최종 판별을 담당하는 FC3에는 적용하지 않았다.</p>
<p> 추가로 한 가지 더 정확히 짚고 넘어가면, <strong>Dropout의 스케일 보정 방식은 구현 방식에 따라 설명이 조금 다를 수 있다.</strong> 고전적인 설명에서는 테스트 시 모든 뉴런을 사용하되, 학습 때 절반만 사용했던 효과를 맞추기 위해 출력값에 0.5를 곱한다고 설명하기도 한다. 그러나 현대 딥러닝 프레임워크, 예를 들어 PyTorch에서 사용하는 방식은 보통 <strong>학습 시 살아남은 뉴런의 출력을 1/(1−p)</strong>만큼 키워 주고, 평가 시에는 아무 조정 없이 모든 뉴런을 그대로 사용하는 <strong>inverted dropout</strong> 방식이다. 따라서 개념적으로는 “학습과 평가 사이의 출력 크기 균형을 맞춘다”는 점이 핵심이고, 실제 보정이 학습 때 들어가느냐 평가 때 들어가느냐는 구현 방식에 따라 다를 수 있다.</p>
<p> 정리하면, AlexNet에서 Dropout은 파라미터 수가 많아 과적합 위험이 큰 첫 두 개의 fully connected layer에 적용되어, 매 학습 단계마다 일부 뉴런을 무작위로 제거함으로써 특정 뉴런 조합에 대한 의존과 co-adaptation을 줄이는 역할을 한다. 이를 통해 모델은 보다 일반적인 특징을 학습하게 되고, 새로운 데이터에 대해서도 더 잘 일반화할 수 있게 된다. 반면 마지막 fully connected layer는 최종 분류를 수행하는 단계이므로, 판별 안정성을 위해 Dropout을 적용하지 않았다.</p>
<h3 id="alexnet의-파라미터">AlexNet의 파라미터</h3>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/4e65d6df-0628-4222-837a-9bb31f6e4f5e/image.png" alt=""></p>
<p> AlexNet은 다음과 같은 파라미터 구조를 가지고 있다.</p>
<p> 위 표에서 확인할 수 있듯, AlexNet의 파라미터 대부분은 Convolution 층이 아닌, FC 층에 있다. 최하단 세 개의 FC 층만 합쳐도 전체 파라미터의 약 94%를 차지한다.</p>
<p>이러한 이유 때문에 이후 세대의 CNN들은 다양한 방법을 사용하였다.</p>
<ul>
<li>1x1 Convolution (ResNet 등)<ul>
<li>필터 크기를 1x1로 할당하여 오직 채널 수만 조절할 수 있는 Convolution 연산을 진행하는 방법이다.</li>
<li>3x3, 5x5와 같은 연산량이 큰 비싼 연산을 진행하기 전에 1x1 연산으로 채널을 확 줄여버린다면, 연산량을 획기적으로 줄일 수 있고, 그 사이에 ReLU를 넣어 비선형성(모델의 깊이)은 더 확보할 수 있어 가성비가 높은 기법이다.</li>
</ul>
</li>
<li>Global Average Pooling(GAP)<ul>
<li>Flatten 과정 없이 바로 클래스 개수만큼 출력하는 방법이다.</li>
<li>FC Layer는 어느 위치에 어떤 특징이 있는지를 다 외워야하기 때문에 파라미터가 매우 많아 폭발한다. 하지만 GAP는 각 채널(특징맵)의 모든 값을 평균내어 하나의 숫자로 만들어, 파라미터를 0개로 만든다. 그렇기에 학습해야 할 가중치가 아예 없어 모델의 크기가 줄고 위치 정보를 평균화하기 때문에 이미지 내 물체의 위치가 비뀌어도 훨씬 더 잘 잡아낸다.</li>
</ul>
</li>
<li>FC Layer의 축소 및 제거<ul>
<li>AlexNet 이후 모델들은 4096개나 되는 노드가 2층씩이나 필요한지에 대한 의문을 가졌다.</li>
<li>이에 FC층을 사용하지만, 전체 구조에서 비중을 최적화하려 노력하였고, 마지막에 GAP를 도입해 단 1개의 FC Layer(최종 분류용)만 남기거나 아예 없애는 방향으로 진화했다.</li>
<li>파라미터의 90% 이상이 판단에 쓰이는 것보다 특징을 뽑는 데 쓰이는 것이 모델의 성능(정확도) 향상에 훨씬 유리하다는 것을 깨달핬기 때문에 발생하였다.</li>
</ul>
</li>
</ul>
<h3 id="alexnet의-첫-번째-convolution-층">AlexNet의 첫 번째 Convolution 층</h3>
<p> AlexNet의 첫 층은 주로 방향성 Edge Detector, 주파수 선택적 패턴, 색 blob 또는 color-opponent 패턴을 배운다. 이는 비단 AlexNet 뿐만 아니라 모든 CNN의 공통적인 특징이기도 하다. 복잡한 함수를 인식하려면 가장 먼저 선이 어디에 있고, 색이 어디서 변하는지부터 알아야 하기 때문이다. 추가로 픽셀을 그대로 복사하는 것보다, 가로선, 세로선, 초록색 덩어리와 같이 기저 패턴을 먼저 인식하는 것이 정보를 압축하고 다음 층으로 전달하는 데 훨씬 효율적이기 때문이다.</p>
<p> AlexNet 에서는 두 GPU 사이의 전문화 또한 함께 관찰했다. 전문화란, 한 쪽은 상대적으로 색에 덜 민감하고 다른 쪽은 색 정보에 더 민감한 필터들이 나타났던 것인데, 첫 층부터 이미 단순 픽셀 복사가 아닌, 에지, 색, 텍스처에 대한 유용한 기저들을 형성하고 있음을 확인할 수 있었던 것이다.</p>
<p> 이러한 현상이 발생한 가장 큰 이유는 Dual GPU를 사용한 것이다. 두 GPU에 파라미터를 나누어 담고 소통을 끊어놓으니, 각 GPU가 서로 중복된 일을 하기보다 모양을 맡거나 색을 맡는 등 자연스럽게 역할 분담이 일어난 것이다.</p>
<p> 추가적으로 위에서 언급한 RGB PCA Augmentation을 통해 조명에 속지 않도록 데이터가 구축되어, 색이 조금 변해도 물체의 본질(형태)은 변하지 않는다는 것을 학습한다. 이에 모델의 필터들은 색상에 집착하기보다는 에지나 질감 같은 조명 변화에 강한 특징들에 더 집중하게 되어, 한 쪽 GPU에서 색에 덜 민감한 에지 검출기들이 나타난 주요 원인 중 하나이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Pytorch 모델 학습]]></title>
            <link>https://velog.io/@inhwaaa_v/Pytorch-%EB%AA%A8%EB%8D%B8-%ED%95%99%EC%8A%B5</link>
            <guid>https://velog.io/@inhwaaa_v/Pytorch-%EB%AA%A8%EB%8D%B8-%ED%95%99%EC%8A%B5</guid>
            <pubDate>Wed, 17 Jun 2026 21:33:06 GMT</pubDate>
            <description><![CDATA[<h1 id="1-cifar-10-데이터셋과-데이터-분할의-의미">1. CIFAR-10 데이터셋과 데이터 분할의 의미</h1>
<h2 id="11-cifar-10-소개">1.1 CIFAR-10 소개</h2>
<p> CIFAR-10은 컴퓨터 비전 입문에서 가장 널리 쓰이는 이미지 분류 데이터셋이다.</p>
<ul>
<li><p><strong>총 10개 클래스</strong> (airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck)</p>
</li>
<li><p>학습 데이터 : <strong>50,000장</strong> / 테스트 데이터 : <strong>10,000장</strong></p>
</li>
<li><p>이미지 크기 : <strong>32 × 32</strong></p>
</li>
<li><p>채널 수 : <strong>3 (RGB)</strong></p>
<p>  <img src="https://velog.velcdn.com/images/inhwaaa_v/post/0c08d7c8-7905-472b-85fb-0f0bca3536eb/image.png" alt=""></p>
</li>
</ul>
<blockquote>
<p><strong>CIFAR-10이 MNIST보다 더 어려운 이유</strong>
→ CIFAR-10은 흑백이 아니라 컬러(3채널) 이미지이다. 따라서 한 픽셀마다 단순 밝기 하나가 아니라, RGB 정보를 함께 처리해야 하기에 모델 입장에서는 고려해야 할 정보량이 더 많아진다. 또, 0~9 숫자의 경우엔 숫자 형태 자체가 클래스마다 확연히 달라 모양 패턴이 비교적 단순하지만, CIFAR-10의 경우, 개/고양이, 자동차/트럭 등 형태가 유사한 클래스가 존재하며, 같은 클래스도 정면/측면/위에서 본 각도 등 다양한 형태로 존재하며, 객체의 종류가 더 다양하고, 배경 변화도 존재한다. 또, 입력 차원(Input Dimension)도 더 크고, 분류 경계(Decision Boundary)도 더 복잡하다. 이러한 차이 때문에 MNIST보다 CIFAR-10이 더 어렵다.</p>
</blockquote>
<h2 id="12-데이터-전처리와-증강">1.2 데이터 전처리와 증강</h2>
<h3 id="데이터셋의-구분">데이터셋의 구분</h3>
<ul>
<li><strong>Training set</strong> : <strong>모델을 학습할 때 사용하는 데이터</strong></li>
<li><strong>Vaildation set</strong> : 모델 학습 하이퍼파라미터 최적화와 과적합 방지, 모델 학습 중 모델 일반화 성능 검증을 위해 활용되는 데이터 <strong>(학습 중 모델 성능을 점검하는 데이터)</strong><ul>
<li>Validation set은 학습 중에 모델이 잘 학습되고 있는지를 확인할 기준 역할을 한다. 이때, 확인 절차에 Training set을 사용하지 않고 <strong>Validation set을 사용하는 이유는, Training set을 활용해 정확도나 손실 등의 결과를 확인하고 분석하면 해당 데이터를 통해 얻은 결과가 학습 데이터를 잘 외워서 나온 것인지(과적합), 높은 일반화 성능을 지녀 새로운 데이터를 잘 맞출 수 있는 것인지(높은 일반화 성능 모델)를 확인할 수 없기 때문</strong>이다.</li>
<li>다시 말해, 검증에 Training set을 활용하면 모델에 과적합(Overfitting)이 발생하고 있는지 알아채기 어렵다. 따라서 학습에 활용되지 않은 Validation set을 활용하며, 해당 데이터를 통해 얻은 Val Loss가 올라가면 과적합 신호로 판단할 수 있다.</li>
<li>이러한 Validation set은 설정한 <strong>에폭마다 성능을 점검</strong>하여 <strong>모델 선택, 학습률/배치 크기/드랍아웃 비율과 같은 하이퍼파라미터 튜닝에 활용</strong>한다.</li>
</ul>
</li>
<li><strong>Test set</strong> : 실제 평가 시 사용되는 데이터로 최종적으로 모델 성능을 평가하는 데이터이므로 학습에 관여하지 않음. <strong>(최종 성능을 한 번 평가하는 데이터)</strong><ul>
<li>테스트 데이터는 최종 평가용으로만 활용하고, <strong>모델 학습 과정에는 절대 관여해선 안 된</strong>다. 테스트 데이터를 검증 데이터로 활용하지 않는 이유도 이 이유에서이다. <strong>모델 개발 과정에서 테스트 데이터를 검증 용도로 사용하면, 모델이 그 데이터를 직접적으로 학습하지 않았더라도 하이퍼파라미터 최적화나 모델 구조 선택에 간접적으로 활용되어 실제 새로운 데이터에 대한 일반화 성능을 왜곡</strong>한다.</li>
<li>테스트 데이터는 반드시 모델이 본 적 없는 완전히 새로운 데이터를 가정하고 평가해야 한다.</li>
</ul>
</li>
</ul>
<h3 id="정규화-normalization">정규화 (Normalization)</h3>
<p> 데이터 정규화란 <strong>입력 데이터의 값 범위</strong>를 <strong>일정한 스케일(scale</strong>)로 조정하는 과정을 의미한다. CIFAR-10의 데이터(이미지)를 예로 들면, 원본 픽셀 값은 RGB 3채널에 맞춘 0<del>255의 데이터로 이루어져 있다. 이때 정규화를 통해 -1</del>1 또는 0~1 과 같이 작은 스케일로 통일시키는 것이다.</p>
<p> 그렇다면 왜 정규화가 필요할까? 정규화를 거치는 이유는 다음과 같다.</p>
<ul>
<li><strong>학습 안정성</strong><ul>
<li>단순 픽셀 값을 기준으로 255의 입력과 1 입력은 기울기의 크기가 매우 커지거나 작아진다. 정규화 작업을 진행하면 기울기 크기가 일정해져, 학습이 안정적으로 진행된다.</li>
</ul>
</li>
<li><strong>학습 속도</strong><ul>
<li>값 범위가 맞춰져 있으면, Loss 곡면이 균형을 이뤄, 최적점을 더 빠르게 찾아간다. loss 곡면이 둥글지 않고 찌그러진 타원형과 같은 형태를 띄고 있으면 최적점을 찾기 힘들어 학습 속도가 느려지게 된다.</li>
</ul>
</li>
<li><strong>특정 채널이 학습을 지배하는 것을 방지</strong><ul>
<li>RGB 3채널에서 R 채널 값이 유독 크면, 모델이 R 채널에만 크게 의존하게 될 수 있다. 따라서 채널별로 평균과 표준편차를 맞추어, 모든 채널이 균등하게 학습에 기여하도록 한다.</li>
</ul>
</li>
<li><strong>활성화 함수의 효율</strong><ul>
<li>sigmoid, tanh와 같은 활성화 함수는, 함수 수식의 특성 상 입력값이 크면 포화 현상으로 인해 기울기가 0에 수렴하는 기울기 소실 문제가 발생할 가능성이 매우 높다. 이 때 입력 값을 정규화하면 활성화 함수가 기울기가 살아있는 구간에서 동작하게 된다.</li>
</ul>
</li>
</ul>
<h3 id="데이터-증강-data-augmentation">데이터 증강 (Data Augmentation)</h3>
<p> 데이터 증강이란, <strong>기존 데이터에 변환을 가해서 새로운 학습 데이터를 만들어내는 것</strong>을 의미한다. 모델은 학습 데이터에서 본 패턴만을 학습하지만, 현실 데이터는 학습 데이터와 완전히 같지 않다.</p>
<p> 예를 들어, 학습 데이터의 고양이 이미지가 정면에 밝은 배경이라면, 실제 고양이 이미지는 측면일 수도 있고, 배경이 어두울 수도 있고, 일부가 잘린 구도일 수도 있다. 이때, 모델은 현실의 고양이는 한 번도 본 적 없는 데이터처럼 느끼게 된다. 따라서, 같은 이미지에서 다양한 변형을 만들어냄으로써, 적은 데이터로도 다양한 상황을 경험한 것처럼 학습시킬 수 있게 된다.</p>
<p> 간단하게 정리하면, “모델이 외울 수 없도록 데이터를 다양하게 만들어 과적합을 막고, 현실의 다양한 상황에 대응할 수 있게 하는 것”이 데이터 증강이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/ec13016e-cd56-4ed5-ad01-4d61371a9f54/image.png" alt=""></p>
<p><strong>주로 사용되는 증강 기법</strong></p>
<ul>
<li><p><strong>RandomHorizontalFlip</strong> : 이미지를 n% 확률로 좌우반전한다. (default : 0.5)</p>
<ul>
<li>좌우 반전을 통해 모델이 방향 변화에 덜 민감해지도록 함.</li>
</ul>
</li>
<li><p><strong>RandomCrop</strong> : 이미지 주변에 패딩을 추가한 뒤 랜덤한 위치에서 원래 크기로 잘라낸다.</p>
<ul>
<li>가장자리에 padding을 준 뒤 랜덤하게 잘라 위치 변화에 강인해지도록 함</li>
<li>crop의 크기가 이미지 크기보다 크면 에러 발생</li>
</ul>
</li>
<li><p><strong>RandomResizedCrop</strong> : crop 후 주어진 크기만큼 Resize</p>
<ul>
<li>crop의 크기가 이미지 크기보다 커도 Resize하기에 오류가 발생하지 않음</li>
</ul>
</li>
<li><p><strong>ColorJitter :</strong> 밝기, 대비, 채도, 색조를 랜덤하게 변화시킨다.</p>
<ul>
<li>(ex) <code>torchvision.transforms.ColorJitter(brightness=0.5, contrast=0.5, saturation=0.5, hue=0.5)</code></li>
</ul>
</li>
<li><p><strong>RandomRotation :</strong> 이미지를 랜덤한 각도로 회전시킨다.</p>
<ul>
<li>각도는 (-degree, +degree) 사이만큼 회전함.</li>
</ul>
<p>RandomVerticalFlip(상하반전)도 있지만, 뒤집힌 자동차나 비행기는 현실에서 거의 없으므로 오히려 잘못된 패턴을 학습시킬 수 있어 사용하지 않는다.</p>
</li>
</ul>
<blockquote>
<p>참고 : <a href="https://blog.joonas.io/193#randomresizedcrop">https://blog.joonas.io/193#randomresizedcrop</a></p>
</blockquote>
<pre><code class="language-python"># CIFAR-10에서 자주 쓰이는 평균 / 표준편차 (CIFAR-10 표준 정규화 통계값)
mean = (0.4914, 0.4822, 0.4465) # 채널별 평균값
std = (0.2470, 0.2435, 0.2616) # 채널별 표준편차값

# 학습용 데이터 전처리 (Train)
# 학습 단계 전용 전처리 및 증강 파이프라인
# transforms.Compose() 여러 단계의 변환이 필요할 때 여러 단계를 묶는 역할
train_transform = transforms.Compose([
        # 이미지 주변에 4픽셀 패딩을 주고, 32*32 크기 크롭 (주변 패딩 후 32*32 임의 절단 증강)
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(), # 좌우 반전 증강
    transforms.ToTensor(), # PIL 이미지를 [0, 1] 텐서로 변환
    # 정규화 : (x&#39; = (x - mean) / std)
    # (스케일 맞춰줌 + 학습 과정에서 Gradient 표면이 일관적이지 않은 문제 해결
    # // 중요중요 Normalize)
    transforms.Normalize(mean, std) # 채널별 정규화 수행
])

# 평가용 데이터 전처리 (Validation / Test)
# 평가용 데이터에는 augmentation이 없음
# -&gt; 모델 성능을 공정하게 평가해야 하기 때문
# 검증 및 테스트 단계 전용 전처리 파이프라인
eval_transform = transforms.Compose([
    transforms.ToTensor(), # 텐서 변환만 수행
    # 입력 기준 맞추기 위해 여기서도 정규화
    transforms.Normalize(mean, std) # 학습과 동일 통계 기준 정규화
])

print(&quot;Train transform : &quot;) # 학습 파이프라인 확인용 출력
print(train_transform) # 학습 전처리 상세 구성 출력
print() # 출력 가독성 확보용 빈 줄
print(&quot;Eval transform : &quot;) # 평가 파이프라인 확인용 출력
print(eval_transform) # 평가 전처리 상세 구성 출력</code></pre>
<blockquote>
<p><strong>Train에는 데이터 증강을 적용하지만, Validation/Test에서는 데이터 증강을 적용하지 않는 이유</strong>
 ****각 데이터셋의 목적이 다르기 때문이다.</p>
</blockquote>
<ul>
<li><strong>train set</strong> : 데이터 증강을 적용하며, 모델이 다양한 번형을 경험하게 해, 일반화 능력을 키우는 것을 목적으로 매 에폭마다 같은 이미지가 조금씩 다르게 변형되어 들어오므로 모델이 특정 패턴을 외우기 어려워진다.</li>
<li><strong>validaion / test set</strong> : 증강은 기본적으로 랜덤이다. 검증 / 테스트 셋에 증강을 적용하면, 이미지가 어떻게 잘렸는지, 좌우반전은 진행되었는지 등 매번 다른 이미지가 들어가서 성능 측정이 일관되지 않는다. 모델이 좋아진 것인지 운이 좋아 특정 데이터만 편향적으로 입력된 것인지 알 수 없어지기 때문이다. 따라서 동일한 입력으로 성능을 정확하게 측정하기 위해 증강을 적용하지 않는다.</li>
</ul>
<h2 id="13-데이터셋-분할과-dataloader">1.3 데이터셋 분할과 DataLoader</h2>
<h3 id="데이터셋-분할">데이터셋 분할</h3>
<p>CIFAR-10 학습 데이터 50,000장을 다음과 같이 나눈다.</p>
<ul>
<li>train : 45,000 장</li>
<li>validation : 5,000 장</li>
<li>test : 10,000 장</li>
<li><strong>train용 데이터셋은 데이터 증강이 적용된 파이프라인을 적용하고 validation, test용 데이터셋은 데이터 증강이 적용되지 않은 파이프라인을 적용</strong>한다.</li>
<li><strong>train, validation 셋은 train=True가 적용되어 있고, test 셋은 False로 설정</strong>되어 있다. train 옵션은 학습/테스트 방식을 설정하는 것이 아닌, 단순히 어느 파일을 열지 지정하는 파일 선택자로, 실제 CIFAR-10 데이터셋에 포함되어 있는 구조 중 50,000장에 포함되어 있는 데이터를 읽어오겠다는 의미를 가진다.</li>
<li><strong>train 데이터셋은 CIFAR-10에서 따로 제공하고 있으므로, 우리는 validation 데이터셋을 train 데이터셋의 일부를 슬라이싱해 사용하는 형태로 진행</strong>하기 때문에 다음과 같은 설정을 사용한다.</li>
</ul>
<pre><code class="language-python"># 같은 CIFAR-10 학습 데이터를
# - train용 (증강 적용)
# - validation용 (증강 미적용)
# 으로 따로 준비합니다 (차이점 기억 - Train / Test Data 분리 &amp; 용도)
# 동일 CIFAR-10 학습 원본에서 train용 증강 버전과 validation용 비증강 버전 분리 구성 목적

# train용 (증강 적용) / 학습용 증강 데이터셋 생성부
full_train_aug = torchvision.datasets.CIFAR10(
    root = DATA_ROOT, # 데이터 저장 경로
    train=True, # 학습 원본 사용 설정
    download=True, # 미존재 시 자동 다운로드 설정
    transform=train_transform # 학습용 증강 파이프라인 적용
)

# validation용 (증강 미적용) / 검증용 비증강 데이터셋 생성부
# 검증 데이터 - 평가용 전처리 적용 (중요)
# 테스트용과 동일하게 성능 평가에 활용하기 때문에 검증 데이터에는 transform이 적용되면 안 됨.
full_train_eval = torchvision.datasets.CIFAR10(
    root=DATA_ROOT, # 데이터 저장 경로
    train=True, # 동일 학습 원본 사용 설정
    download=True, # 미존재 시 자동 다운로드 설정
    transform=eval_transform # 평가용 전처리만 적용
)

# test용 (증강 미적용) / 최종 성능 측정용 테스트 세트 생성부
test_dataset = torchvision.datasets.CIFAR10(
    root=DATA_ROOT, # 데이터 저장 경로
    train=False, # 공식 테스트 세트 사용 설정
    download=True, # 미존재 시 자동 다운로드 설정
    transform=eval_transform # 평가용 전처리 적용
)

num_total = len(full_train_aug) # 전체 학습 데이터 개수
# torch.randperm(n) : 0 ~ (n-1) 사이의 값이 랜덤하게 들어있는 tensor를 출력
# torch.Generator() : 난수 생성 알고리즘의 상태 확인 객체
# 0부터 49999까지 인덱스를 랜덤하게 섞음
indices = torch.randperm(num_total, generator=torch.Generator().manual_seed(SEED)
                    .tolist() # 시드 고정 셔플 인덱스 생성

# train / validation 인덱스 분리
train_indices = indices[:-VAL_SIZE] # 앞부분을 학습 인덱스로 사용
val_indices = indices[-VAL_SIZE:] # 뒷부분을 검증 인덱스로 사용

# Subset() : 특정 조건에 맞는 데이터나 변수 추출
# 실제 train / validation dataset 생성
train_dataset = Subset(full_train_aug, train_indices) # 증강 적용 학습 부분집합
val_dataset = Subset(full_train_eval, val_indices) # 비증강 적용 학습 부분집합

print(f&quot;Total train data : {num_total}&quot;) # 전체 학습 원본 개수 확인
print(f&quot;Train Subset : {len(train_dataset)}&quot;) # 학습 부분집합 개수 확인
print(f&quot;Validation Subset : {len(val_dataset)}&quot;) # 검증 부분집합 개수 확인
print(f&quot;Test Dataset : {len(test_dataset)}&quot;) # 테스트 세트 개수 확인</code></pre>
<h3 id="dataloader">DataLoader</h3>
<p>DataLoader는 데이터를 미니배치 단위로 묶어서 모델에 공급해주는 역할을 한다.</p>
<ul>
<li><code>shuffle=True</code> : 매 Epoch마다 데이터 순서를 섞음</li>
<li><code>batch_size</code> : 한 번에 몇 장씩 학습할지 결정</li>
<li><code>pin_memory=True</code> : GPU 사용 시 전송 효율 개선에 도움</li>
</ul>
<pre><code class="language-python"># 훈련용 DataLoader (학습용 DataLoader 구성부)
train_loader = DataLoader(
    train_dataset, # 학습용 subset (학습 데이터셋 입력)
    batch_size=BATCH_SIZE, # 미니배치 크기 설정
    # 학습에 사용되는 게 또 다시 사용될 수 있어서 shuffle이 iteration이 아닌 epoch 단위로 사용
    shuffle=True, # 매 epoch마다 셔플 적용 설정
    num_workers=NUM_WORKERS, # 데이터를 미리 읽어오는 worker 수 (배치 로딩 병렬 수)
    pin_memory=PIN_MEMORY # GPU 전송 가속용 메모리 고정 여부
)

# 검증용 DataLoader 구성부
val_loader = DataLoader(
    val_dataset, # 검증용 subset (검증 데이터셋 입력)
    batch_size=BATCH_SIZE, # 미니배치 크기 동일 적용
    shuffle=False, # 검증 시 순서 고정 설정 (인덱스 위치 같아야 하니까 shuffle 안함)
    num_workers=NUM_WORKERS, # 배치 로딩 병렬 수
    pin_memory=PIN_MEMORY # GPU 전송 가속 설정
)

# 테스트용 DataLoader 구성부
test_loader = DataLoader(
    test_dataset, # 테스트용 subset (테스트 데이터셋 입력)
    batch_size=BATCH_SIZE, # 미니배치 크기 동일 적용
    shuffle=True, # 검증 시 순서 고정 설정
    num_workers=NUM_WORKERS, # 배치 로딩 병렬 수
    pin_memory=PIN_MEMORY # GPU 전송 가속 설정
)

print(f&quot;Number of train batches : {len(train_loader)}&quot;) # 학습 배치 수 확인
print(f&quot;Number of val batches : {len(val_loader)}&quot;) # 검증 배치 수 확인</code></pre>
<h2 id="14-데이터-시각화">1.4 데이터 시각화</h2>
<p> 정규화가 적용된 이미지를 그대로 시각화하면 색상이 원본과 다르게 보일 수 있다. 이는 모델 입력을 위해 픽셀값의 분포를 조정하는 과정에서, 사람이 일반적으로 인식하는 이미지의 값 범위가 바뀌기 때문이다. 원본 이미지는 보통 <code>0~255</code>의 정수값 또는 <code>0~1</code> 범위의 실수값으로 표현되지만, 정규화 이후에는 각 채널 값이 평균과 표준편차를 기준으로 변환되어 음수나 1보다 큰 값을 포함할 수 있다.</p>
<p> 정규화는 일반적으로 다음과 같이 수행된다.</p>
<p>$$
x_{norm} = \frac{x-\mu}{σ}
$$</p>
<p> 이렇게 변환된 텐서는 모델 학습에는 적합하지만, 시각화에는 적합하지 않다. 예를 들어 RGB 이미지의 각 채널 값이 정규화된 상태로 남아 있으면, 원래의 색 비율이 사람이 기대하는 범위와 달라져 색감이 이상하게 표현될 수 있다. 또한 음수값이 포함된 경우 <code>matplotlib</code>와 같은 시각화 라이브러리에서 값이 자동으로 잘리거나(clipping), 예상치 못한 색으로 출력될 수 있다.</p>
<p> 따라서 정규화된 이미지를 사람이 해석 가능한 형태로 보기 위해서는 <strong>역정규화(denormalization)</strong> 과정이 필요하다. 역정규화는 다음 식으로 수행된다.</p>
<p>$$
x = x_{norm}⋅σ+μ
$$</p>
<p> 이 과정을 거치면 정규화 이전의 스케일로 값을 되돌릴 수 있다. 엄밀히 말하면, 이는 항상 <code>0~255</code>로 직접 복원된다기보다 <strong>정규화 직전의 값 범위</strong>로 되돌리는 과정이다. 예를 들어 <code>ToTensor()</code> 이후 정규화를 수행했다면, 역정규화 결과는 보통 <code>0~1</code> 범위에 가까운 값으로 복원되며, 이 상태가 <code>matplotlib</code>에서 올바르게 시각화되기 적절하다.</p>
<p> 정규화된 텐서를 시각화할 때는 보통 다음 순서를 따른다.</p>
<ol>
<li><p><strong>텐서를 CPU로 옮긴다.</strong></p>
<ul>
<li>학습 중 텐서는 GPU에 올라가 있을 수 있다. matplotlib 같은 시각화 라이브러리는 CPU의 numpy 배열만 처리할 수 있으므로, 텐서를 CPU로 옮겨야 한다.</li>
</ul>
</li>
<li><p><strong>정규화를 되돌리는 역정규화(denormalize) 과정을 거친다.</strong></p>
<ul>
<li>정규화가 진행된 각 채널의 스케일을 원본 RGB 스케일 0~255에 맞게 역정규화 과정을 거친다.</li>
</ul>
</li>
<li><p><strong>HWC 형태로 바꾸어 출력한다.</strong></p>
<ul>
<li>PyTorch의 이미지 텐서는 일반적으로 <code>(C, H, W)</code> 형식을 사용하지만, <code>matplotlib</code>는 <code>(H, W, C)</code> 형식을 기대한다. 따라서 <code>permute()</code> 등을 이용해 차원 순서를 바꿔주어야 정상적으로 이미지가 출력된다.</li>
</ul>
<p>즉, 정규화는 <strong>모델 학습을 위한 전처리</strong>이고, 역정규화는 <strong>사람이 이미지를 올바르게 해석하기 위한 복원 과정</strong>이라고 볼 수 있다. 따라서 학습용 텐서를 그대로 시각화하기보다는, 반드시 역정규화와 차원 변환을 거친 뒤 출력해야 원본에 가까운 색상과 형태를 확인할 수 있다.</p>
</li>
</ol>
<pre><code class="language-python"># 정규화된 이미지를 다시 원래 색감에 가깝게 되돌리는 함
def denormalize(images : torch.Tensor, mean : Tuple[float, float, float], 
                        std : Tuple[float, float, float]) -&gt; torch.Tensor: # 시각화용 역정규화 함수
  # (batch_size, channels, height, width) 배추 ㅋ ㅋ
  # 평균과 표준편차 -&gt; channels 단위니까
  # 평균 텐서 브로드캐스팅 형태 변환
  mean_tensor = torch.tensor(mean, device=images.device).view(1, 3, 1, 1)
  # 표준편차 텐서 브로드캐스팅 형태 변환
  std_tensor = torch.tensor(std, device=images.device).view(1, 3, 1, 1)
  # x&#39; = (x - mean) / std (정규화)
  # x = x&#39; * std + mean (복원)
  return images * std_tensor + mean_tensor # 정규화 이전 스케일 복원

def show_batch(images: torch.Tensor, labels : torch.Tensor,
                                class_names: List[str], n: int=8, cols: int=4): # 배치 시각화 함수
    # 앞 n개만 선택 후 CPU 이동 및 역정규화 범위 제한
  images = denormalize(images[:n].cpu(), mean, std).clamp(0, 1)
  labels = labels[:n].cpu() # 시각화 대상 라벨만 CPU 이동

  rows = int(np.ceil(n / cols)) # 필요한 행 개수 계산
  plt.figure(figsize=(3 * cols, 3 * rows)) # 서브플롯 크기 설정

  for i in range(n): # 개별 이미지 반복 출력 루프
    plt.subplot(rows, cols, i + 1) # 현재 이미지 서브플롯 위치 지정
    img = images[i].permute(1, 2, 0).numpy() # [C, H, W] -&gt; [H, W, C] 변환 후 NumPy 변환
    plt.imshow(img) # 이미지 출력
    plt.title(class_names[labels[i].item()]) # 정답 클래스명 제목 표시
    plt.axis(&quot;off&quot;) # 축 제거 설정

  plt.tight_layout() # 서브플롯 간 간격 자동 조정
  plt.show() # 최종 그림 출력

images, labels = next(iter(train_loader)) # 첫 학습 배치 추출
show_batch(images, labels, class_names, n=8, cols=4) # 샘플 배치 시각화 실행</code></pre>
<h1 id="2-baselinecnn과-improvedcnn-구조-비교">2. BaselineCNN과 ImprovedCNN 구조 비교</h1>
<h2 id="21-cnn">2.1 CNN</h2>
<p> CNN은 이미지 처리/인식을 위한 신경망이다. 시각적인 데이터 처리를 위한 딥러닝의 한 형태로, 사물 형태나 특징/위치 변화에 강인해 이미지/비디오 인식에 주로 사용된다. 이러한 CNN은 기존의 DNN과는 달리 이미지 그 자체를 입력으로 받는다. 즉, 기존의 DNN처럼 이미지를 flatten시켜서 입력으로 받을 필요 없이, 이미지 그대로를 입력으로 받을 수 있다. 그렇기에 이미지 자체의 공간적/지역적 정보를 유지한 채로 연산을 진행할 수 있다<strong>.</strong></p>
<p> CNN은 Feature Learning(특징 학습) 단계와 Classifier Learning(분류기 학습) 단계로 구성되며, 입력층에서 컨볼루션 연산을 통해 특징을 뽑아내는 Feature map이 존재하고, Feature map에 풀링 연산을 적용해 입력 차원의 차원을 줄인다. 이를 통해 데이터를 간소화하고, 필요한 정보만을 유지하며, 오버피팅을 방지하여 계산 효율을 높일 수 있다. 이러한 컨볼루션과 풀링 레이어는 여러 번 되풀이된다. 그리고 신경망의 맨 끝에는 Fully Connected Layer가 존재해 추출된 특징을 기반으로 물체를 인식하고 분류할 수 있다.</p>
<p> 전반적인 과정을 다시 살펴보면 다음과 같다. 입력으론 이미지 그 자체가 입력되고, Convolutional layer에선 필터를 통해 이미지의 지역적 특징을 잡아내 이를 Feature map으로 출력한다. 이후, Feature map에 비선형성을 도입해서 복잡한 패턴을 학습할 수 있도록 도움을 주기 위한 Activation function을 적용한 뒤, 이미지의 중요한 특징만을 남기고 공간 크기를 줄이는 Pooling layer를 거친다.</p>
<p> 여기서 중요한 것은, 각 Convolutional layer의 입력이 원본 이미지가 아닌, 이전 레이어의 출력 결과라는 것이다. 즉, 추출된 특징들을 바탕으로 새로운 특징들을 계속 학습해 나간다. 우리가 Pooling layer를 사용해야 하는 이유는 여기에 있다. 이미지의 중요한 특징만을 남기고 크기를 줄이지 않으면, 연산량이 너무 많고, 이로 인해 overfitting이 발생할 우려가 있기 때문이다.</p>
<p> 모든 Conv → Pooling 과정을 거친 후엔 Fully Connected Layer로 이동하는데, 이때 FC Layer로 이동하기 전, Flatten 과정을 거쳐 다차원의 Feature map을 1차원 벡터로 평탄화시키는 과정이 필요하다. 왜냐하면 FC Layer는 1차원 배열 형태로 평탄화된 행렬을 통해 이미지를 분류하는 데 사용되는 계층이기 때문이다. 그래서 Flatten 과정을 거친 후, FC Layer에서 입력으로 받은 1차원 벡터의 특징들을 바탕으로 활성화 함수나 softmax를 적용해 최종적인 분류를 결정한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/e811f065-e078-43f0-9f0f-d3d963a7944c/image.png" alt=""></p>
<p> 이러한 과정을 거치면서 이미지의 중요한 특징들이 Feature map에 통합되고, 중요한 특징들을 파악해 나가면서 이미지가 어떤 클래스에 속하는지 찾아내는 과정이 CNN 모델의 전반적인 학습 과정이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/2271e746-dade-4c5c-9f4d-2705f889b900/image.png" alt=""></p>
<p> 컨볼루션 신경망은 층이 깊어질수록 점차 더 복잡하고 세밀한 패턴을 포착할 수 있다. 초기 층에서는 엣지나 코너와 같은 단순한 국소적 특징(simple cell)을 학습하고, 중간 층으로 갈수록 이러한 특징들이 조합되어 텍스처나 부분 구조를 인식하며, 최종적으로는 객체 전체를 나타내는 복잡한 고수준 특징(complex cell)을 학습하게 된다.</p>
<p> 이러한 계층적 특징 학습은 Receptive Field와 밀접한 관련이 있다. <strong>Receptive Field란 컨볼루션 신경망에서 특정 출력 뉴런 하나에 영향을 미치는 입력 이미지 영역의 크기를 의미</strong>한다. 컨볼루션과 풀링 연산이 반복되면서 각 층의 뉴런이 참조하는 입력 영역은 점점 넓어지게 되고, 이에 따라 상위 층의 뉴런은 더 넓은 공간적 정보를 통합하여 바라보게 된다.</p>
<p> 즉, 얕은 층의 뉴런은 이미지의 작은 국소 영역만을 관찰하는 반면, 네트워크가 깊어질수록 Receptive Field가 확장되어 점차 이미지의 큰 부분, 나아가서는 전체 이미지에 대한 전역적 정보를 포착할 수 있게 된다. 이러한 특성 덕분에 컨볼루션 신경망은 로컬한 특징에서부터 전역적인 의미 정보까지 단계적으로 학습할 수 있으며, 이는 이미지 분류·객체 인식과 같은 비전 문제에서 뛰어난 성능을 보인다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/b6bbb14c-f06b-4343-9af9-30ab2089f18d/image.png" alt=""></p>
<p> 또한 CNN은 하위 레이어의 유닛과 상위 레이어의 유닛이 모두 연결되지 않고 부분적으로만 연결되는 구조를 가지므로, 전체 네트워크의 복잡도가 낮아지고 과적합(overfitting)에 빠질 가능성이 줄어든다. 이러한 특성을 지역적 수용 영역(local receptive field)이라고 한다.</p>
<p> 기존의 DNN은 모든 입력 뉴런이 다음 층의 모든 뉴런과 연결되는 Fully Connected Layer 구조를 사용하는 반면, <strong>CNN은 입력의 국소 영역에 대해서만 연산을 수행하는 희소 연결성(sparse connectivity)을 가진다</strong>. 이는 컨볼루션 필터가 슬라이딩 윈도우 방식으로 이미지 전체를 이동하며 연산을 수행하기 때문에 나타나는 구조적 특성이다.</p>
<p> 구체적으로, 하나의 필터는 이미지를 작은 영역으로 나누어 각 영역에서 특정 패턴을 추출하며, 이 필터는 이미지의 모든 위치에 동일하게 적용된다. 이 과정에서 <strong>동일한 가중치가 전체 이미지에 공유(weight sharing)되므로, Fully Connected Layer에 비해 학습해야 할 파라미터 수가 크게 감소</strong>한다.</p>
<p> 이러한 지역 연결성, 희소 연결성, 가중치 공유라는 CNN의 구조적 특성은 계산 효율성을 높일 뿐만 아니라, 불필요한 파라미터 증가를 억제하여 일반화 성능을 향상시키는 데 중요한 역할을 한다.</p>
<blockquote>
<ul>
<li><strong>컨볼루션 신경망</strong> : 정보 추출/축소를 반복하며 최종적으로 주어진 이미지를 기반으로 핵심 특징을 학습함.</li>
<li><strong>컨볼루션</strong> : 지역적 특징 감지 및 추출. 레이어가 깊어질수록 추상적/복잡한 특징 추출</li>
<li><strong>풀링</strong> : 이미지 압축. 위치 변화에 강건한 특징을 보이게 해줌.</li>
</ul>
</blockquote>
<h3 id="convolution-layer"><strong>Convolution Layer</strong></h3>
<ul>
<li>이미지 처리에서 가장 중요한 연산은 합성곱(Convolution) 이다. 이는 필터(또는 커널)라는 작은 가중치 행렬을 이용해 이미지(또는 이전 레이어의 feature map)에 슬라이딩 연산을 수행하여 특징을 추출한다.</li>
<li>커널 크기가 3x3이고, 입력 채널이 1, 출력 채널이 32라면, 각 필터는 (1,3,3) 크기의 가중치를 가진다. 이런 필터가 32개 있어(출력 채널 수 만큼) 다른 특징을 잡아내도록 학습된다.</li>
<li>PyTorch에서는 nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, …) 등을 통해 구현이 가능하다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/255a26f2-109b-43c6-8df9-a3a7cb8859c9/image.png" alt=""></p>
<h3 id="pooling-layer"><strong>Pooling Layer</strong></h3>
<ul>
<li>풀링은 지역적 영역에서 최대값 또는 평균 등을 취해 특성맵 크기를 줄이고, 불변성을 높여준다.</li>
<li>가장 흔한 방식 중 하나인 nn.MaxPool2d(kernel_size=2, stride=2)는 2x2 영역에서 최대값을 취해 가로 세로를 절반으로 줄인다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/3b91d3de-5fad-4b91-a4f7-07bdd21b4357/image.png" alt=""></p>
<h3 id="fc-layerfully-connected"><strong>FC Layer(Fully Connected)</strong></h3>
<ul>
<li>추출된 특징을 기반으로 최종 분류를 수행한다.</li>
<li>FC Layer에 진입하기 전 Flatten 과정을 거쳐 특징을 1차원으로 펼친 뒤 진입한다.</li>
</ul>
<h3 id="padding--stride"><strong>Padding &amp; Stride</strong></h3>
<ul>
<li>패딩은 컨볼루션 전 후에 주변을 0과 같은 숫자로 둘러싸 입력 크기를 유지시켜주거나, 정보를 더 많이 보존하려는 목적으로 사용된다. 이를 위해 입력 이미지의 가장자리(테두리)에 특정 값을 지닌 픽셀을 추가하여 컨볼루션 연산 이후에도 출력 이미지와 원본 이미지 크기 크기가 같도록 한다. 작은 이미지나 경계값 주변에 중요한 정보를 지니는 경우, 패딩을 적용하지 않으면(<strong>valid 패딩</strong>) 경계값 주변의 픽셀 정보가 손실되므로 성능이 저하되는 문제점이 있다. 이를 해결하기 위해 등장한 것이 패딩이다. 패딩은 합성곱 연산 전에 입력 데이터 주변에 특정 값을 채우므로(<strong>same 패딩</strong>) 출력 데이터의 크기를 조정할 수 있고, 패딩을 통해 경계값을 확장할 수도 있다. 하지만 인위적인 픽셀 값 추가로 인하여 정보 왜곡이 발생할 수 있다는 문제점도 존재한다.</li>
<li>스트라이드는 필터를 얼마나 건너뛰면서 이동할지 정하며 일반적으로 1 또는 2(혹은 커널 사이즈에 맞춰) 사용한다. (컨볼루션 연산에서 필터가 입력 데이터(이미지) 위에서 한 번에 이동하는 거리, 즉 커널을 적용하는 간격) →  <strong>패딩을 크게 하면 출력 데이터의 크기가 커지는 반면, 보폭을 크게 하면 출력 데이터의 크기는 작아진다.</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/56d8830e-e6c4-4d0d-9567-d9b154dc92a9/image.png" alt=""></p>
<p> CNN에서 Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1) 형태로 컨볼루션 연산을 진행한다고 가정했을 때</p>
<ul>
<li>필터 1개의 가중치 수: 1 x 3 x 3 = 9 (in 채널 x 커널 높이 x 커널 너비)</li>
<li>bias 파라미터까지 합하면 필터 1개당 9+1 = 10</li>
<li>필터가 32개이므로 총 파라미텨 = 32 x  10 = 320</li>
<li>총 파라미터는 320개로 위와 같은 과정을 통해 계산이 가능하며, 풀링 레이어는 단순 연산을 진행하는 레이어로 학습 파라미터가 존재하지 않는다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/1eb86975-eb76-413d-869e-b2a843618357/image.png" alt=""></p>
<h3 id="baseline--improved-해석-전-참고-사항">(Baseline / Improved 해석 전) 참고 사항</h3>
<h3 id="batch-normalization">Batch Normalization</h3>
<p> Batch Normalization은 <strong>신경망의 각 층에 들어가는 입력 분포를 정규화하여 학습을 안정화하는 기법</strong>이다. 딥러닝 모델을 학습할 때 각 층의 입력값 분포가 계속 변하면, 뒤쪽 층은 매번 달라지는 분포에 적응해야 하므로 학습이 불안정해질 수 있다. 이렇게 학습 중 각 층의 입력 분포가 계속 변하는 현상을 internal covariate shift라고 한다.</p>
<p> Batch Normalization은 미니배치 단위로 평균과 분산을 계산한 뒤, 각 feature를 평균이 0이고 분산이 1에 가까운 형태로 정규화한다. 하지만 단순히 정규화만 하면 표현력이 제한될 수 있기 때문에, 이후 학습 가능한 파라미터를 통해 다시 스케일과 이동을 적용한다. 즉, 입력 분포를 무조건 고정하는 것이 아니라, 학습에 유리한 방향으로 안정화하면서도 필요한 표현력은 유지할 수 있도록 설계된 것이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/a6797b5c-7959-4117-bfd2-b845b36c5bd6/image.png" alt=""></p>
<p> 이 기법을 사용하는 가장 큰 이유는 학습을 더 빠르고 안정적으로 만들 수 있기 때문이다. 각 층이 받는 입력 분포가 지나치게 흔들리면 뒤쪽 층은 매번 달라지는 입력에 적응해야 하므로 학습이 어려워진다. Batch Normalization은 이러한 변화를 완화하여 gradient 흐름을 안정시키고, 경우에 따라 더 큰 learning rate를 사용할 수 있게 하여 수렴 속도를 높이는 데 도움을 준다. 또한 미니배치 통계를 사용한다는 특성 때문에 약한 regularization 효과도 가져와 과적합 완화에 긍정적인 영향을 줄 수 있다.</p>
<p> Batch Normalization은 학습 단계와 추론 단계에서 동작 방식이 다르다. <strong>학습 시에는 현재 미니배치의 평균과 분산을 사용하여 정규화를 수행</strong>한다. <strong>반면 추론 시에는 학습 과정에서 누적해 둔 running mean과 running variance를 사용</strong>한다. 이는 테스트 단계에서는 입력이 한 장씩 들어오거나 배치 크기가 일정하지 않을 수 있기 때문에, 학습 때처럼 현재 배치 통계를 그대로 사용하는 것이 적절하지 않기 때문이다.</p>
<p> CNN에서는 Batch Normalization을 보통 Convolution 연산 뒤, 활성화 함수인 ReLU 앞에 배치한다. 즉, 일반적으로는 <strong>Conv → BatchNorm → ReLU 순서로 많이 사용</strong>된다. 그 이유는 convolution 결과의 분포를 먼저 정규화한 뒤 활성화 함수를 적용하는 것이 더 자연스럽기 때문이다. 만약 ReLU를 먼저 적용하면 음수값이 0으로 잘리면서 원래 feature 분포가 달라지게 되고, 이후 Batch Normalization은 이미 변형된 분포를 기준으로 정규화를 수행하게 된다. 따라서 기본적인 구조에서는 ReLU 이전에 Batch Normalization을 두는 경우가 많다. 다만 실제 모델 설계에서는 항상 이 순서만 정답인 것은 아니며, 구조에 따라 순서를 다르게 했을 때 더 좋은 성능이 나오는 경우도 있다.</p>
<p> 정리하면, Batch Normalization은 미니배치 단위의 평균과 분산을 이용해 feature 분포를 정규화하고, 학습 가능한 파라미터를 통해 표현력을 유지하면서 학습 안정성과 수렴 속도를 높이는 기법이다. 따라서 딥러닝 모델의 성능 향상뿐 아니라 학습 효율을 높이는 데에도 중요한 역할을 한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/643f35ed-0003-45fe-a5a9-725577de63d9/image.png" alt=""></p>
<blockquote>
<p><strong>배치</strong> : 한 번에 모델에 넣어서 같이 처리하는 데이터 묶음
<strong>Iteration</strong> : 배치 한 번을 처리하는 것
<strong>Epoch</strong> : 전체 학습 데이터를 다 보는</p>
</blockquote>
<h3 id="dropout">Dropout</h3>
<p> 드롭아웃이란, 학습 단계마다 무작위로 일부 뉴런을 비활성화하여 신경망이 특정 뉴런에만 과하게 의존하는 것을 방지하기 위한 기법을 의미한다. 이는 과적합을 줄이고 일반화 성능을 향상시키는 효과가 있는데, 그 이유는 학습 과정에서 특정 가중치/뉴런에 과하게 의존하지 않고, 다양한 경로를 학습할 수 있게 하기 때문이다.</p>
<p> 다시 말해, Dropout은 <strong>co-adaptation(신경망에서 특정 뉴런들이 서로 강하게 의존되는 현상)을 방지</strong>하는 효과가 있다. 특정 뉴런이 특정 feature만 잘 잡아내서 다른 뉴런이 그 feature를 잡아내지 않고, 다른 역할만 하면 뉴런들이 독립적으로 일반화된 표현을 학습하지 못 하는 현상이 발생할 수 있는데, Dropout은 이를 방지할 수 있고, 이를 통해, 각 뉴런이 다른 뉴런에 과도하게 의존하지 않고 자신만의 독립적 표현을 학습할 수 있다.</p>
<p> 이러한 Dropout은 <strong>보통 epoch 단위가 아니라 iteration 단위, 즉 배치가 들어올 때마다 새롭게 적용</strong>된다. 이렇게 하는 이유는 매 배치마다 서로 다른 뉴런 조합을 사용하도록 하여, 모델이 특정 노드나 특정 연결에 과도하게 의존하는 것을 막기 위해서이다. 만약 동일한 dropout mask를 오랫동안 고정하면, 일부 뉴런 조합에 편향된 학습이 일어날 수 있다. 따라서 iteration마다 dropout을 새로 적용함으로써 일반화 성능을 높일 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/8d58dd61-8b39-4952-b00e-5f8484dc2321/image.png" alt=""></p>
<h3 id="adaptive-average-pooling">Adaptive Average Pooling</h3>
<p> Global Average Pooling은 <strong>각 채널의 전체 공간 영역(H×W)을 전부 평균내서, 채널마다 값 1개만 남기는 방식</strong>이다. Adaptive Average Pooling은 GAP의 특수한 경우로, Adaptive Average Pooling은 <strong>입력 feature map의 크기와 관계없이, 사용자가 지정한 출력 크기에 맞추어 평균 풀링을 수행하는 기법</strong>이다. 일반적인 Average Pooling은 kernel size와 stride를 직접 설정해야 하며, 그 결과 출력 크기가 입력 크기에 따라 달라진다. 반면 Adaptive Average Pooling은 <strong>출력 크기를 먼저 정하면</strong>, 이에 맞도록 내부적으로 pooling 영역을 자동으로 나누어 평균값을 계산한다.</p>
<p> 이 방식의 장점은 입력 이미지 크기나 중간 feature map의 크기가 달라도, 최종적으로 항상 <strong>고정된 크기의 출력</strong>을 얻을 수 있다는 점이다. 따라서 CNN의 마지막 단계에서 feature map을 분류기(fully connected layer)에 넣기 전, 크기를 일관되게 맞추는 용도로 자주 사용된다.</p>
<p> 특히 출력 크기를 <code>(1, 1)</code>로 설정한 경우, 각 채널의 전체 공간 영역 H×W를 모두 평균내어 <strong>채널당 하나의 값만 남기게 되는데</strong>, 이것이 바로 <strong>Global Average Pooling(GAP)</strong> 이다. 즉, GAP는 Adaptive Average Pooling의 특수한 형태라고 볼 수 있다.</p>
<p> 예를 들어 입력 feature map의 크기가 (C,H,W)일 때, <code>AdaptiveAvgPool2d((1,1))</code>를 적용하면 출력은 (C,1,1)이 된다. 이 과정에서 각 채널은 하나의 대표값으로 요약되며, 공간적 정보는 압축되고 채널별 특징만 남게 된다.</p>
<p> 정리하면, Adaptive Average Pooling은 <strong>출력 크기를 원하는 형태로 유연하게 조절할 수 있는 평균 풀링 방법</strong>이며, 그중 가장 대표적인 경우가 <code>(1,1)</code> 출력으로 각 채널을 하나의 값으로 요약하는 Global Average Pooling이다.</p>
<h2 id="23-baselinecnn-vs-improved-cnn">2.3 BaselineCNN vs Improved CNN</h2>
<h3 id="baselinecnn"><strong>BaselineCNN</strong></h3>
<p> 가장 기본적인 CNN 구조로 다음과 같이 레이어가 구성되어 있다.</p>
<ul>
<li>Conv → ReLU</li>
<li>Conv → ReLU</li>
<li>MaxPool</li>
<li>Conv → ReLU</li>
<li>Conv → ReLU</li>
<li>MaxPool</li>
<li>Flatten → Linear → ReLU → Linear</li>
</ul>
<pre><code class="language-python">from torch.nn.modules.pooling import MaxPool2d

# 컨볼루션 다음엔 반드시 ReLU가 따라옴
# Dense하게 안 된 얘한테 추가해주는 게 표현력 증가 (픽셀 정보 토대로 묶은 거에 비선형성 추가)
class BaselineCNN(nn.Module): # 기존 CNN 기준 모델 정의부
  def __init__(self, num_classes: int=10): # 출력 클래스 수 설정 가능 구조
    super().__init__() # nn.Module 초기화 호출

    self.features = nn.Sequential( # 합성곱 기반 특징 추출부
            # 3채널 입력 -&gt; 32채널 특징맵 변환, 해상도 유지
        nn.Conv2d(3, 32, kernel_size=3, padding=1),
        nn.ReLU(inplace=True), # 비선형성 부여용 활성화 함수

                # 32채널 -&gt; 64채널 확장, 저수준 특징 심화
        nn.Conv2d(32, 64, kernel_size=3, padding=1),
        nn.ReLU(inplace=True), # 비선형 표현력 추가

                # 32*32 -&gt; 16*16 다운 샘플링, 위치 불변성 일부 확보
        nn.MaxPool2d(kernel_size=2, stride=2),

                # 64채널 -&gt; 128채널 확장, 중간 수준 특징 추출
        nn.Conv2d(64, 128, kernel_size=3, padding=1),
        nn.ReLU(inplace=True), # 비선형성 추가

        nn.Conv2d(128, 128, kernel_size=3, padding=1), # 128채널 유지, 특징 정교화 단계
        nn.ReLU(inplace=True), # 비선형성 추가

        nn.MaxPool2d(kernel_size=2, stride=2) # 16* 16 -&gt; 8*8 다운 샘플링
    )

    self.classifier = nn.Sequential( # 분류기 구성부
        nn.Flatten(), # 3차원 특징맵을 1차원 벡터로 평탄화
        # Tensor shape : [B, C, H, W]
        **# 고차원 특징 -&gt; 은닉 표현 압축 (이거 in_channel, out_channel 계산 가능해야 함)
        nn.Linear(128 * 8 * 8, 256),** 
        nn.ReLU(inplace=True), # 분류 전 비선형성 부여
        nn.Linear(256, num_classes) # 최종 클래스 로짓 출력
    )

  def forward(self, x): # 순전파 정의부
    x = self.features(x) # 특징 추출부 통과
    x = self.classifier(x) # 분류기 통과
    return x # 최종 로짓 반환</code></pre>
<h3 id="improvedcnn"><strong>ImprovedCNN</strong></h3>
<p> 보다 실질적인 개선 구조이다.</p>
<ul>
<li><p><strong>BatchNorm2d 추가</strong></p>
<p>  : BaselineCNN은 Conv → ReLU 구조이다. 이 구조는 Conv 출력값이 레이어를 거칠수록 분포가 점점 치우쳐져, 어떤 채널은 값이 매우 크고, 어떤 채널은 매우 작아지는 현상이 발생해 학습이 불안정해진다. ImprovedCNN은 이 사이에 BatchNorm을 추가해 Conv → BatchNorm → ReLU 구조로 변경했다.</p>
<p>  : BatchNorm은 미니배치 단위로 각 채널의 출력을 정규화하는 레이어로, 매 레이어마다 평균을 0으로, 표준편차로 1로 다시 맞춰준다. BatchNorm을 추가하면, 학습이 안정적으로 진행되고 학습률을 크게 써도 발산하지 않는다는 장점이 있으며, 자체적인 정규화 효과로 과적합이 억제되는 효과를 가진다.</p>
</li>
<li><p><strong>Dropout 추가</strong></p>
<p>  : BaselineCNN의 분류기는 Flatten → Linear(128<em>128</em>8) → ReLU → Linear(256, 10) 구조로 이루어져 있다. 위 구조는, FC 레이어에서 특정 뉴런이 학습을 지배하게 되면, 그 뉴런에 과도하게 의존하는 과적합이 발생할 가능성이 높다.
  : ImprovedCNN은 Flatten 직후 Dropout을 추가해 다음과 같은 구조로 변경했다. Flatten → Dropout(p=0.3) → Linear(256, 10). 30%의 뉴런을 랜덤하게 꺼버려, 특정 뉴런 의존을 막고 다양한 경로로 학습을 강제하지만, <strong>model.eval() 시에는 자동으로 비활성화되며 모든 뉴런이 동작</strong>한다.</p>
</li>
<li><p><strong>마지막에 AdaptiveAvgPool2d((1,1)) 사용</strong></p>
<p>  : BaselienCNN은 마지막 MaxPool 과정 이후 특징 맵이 8x8 형태로 남는다. 여기서 Flatten을 하게 되면 128 * 128 * 8로 8192차원 벡터가되고, 이는 FC 레이어의 입력 크기가 8192로, 파라미터 수가 매우 많아진다는 것을 의미한다. 이렇게 되면, 입력 이미지의 크기가 바뀌면 FC 레이어의 크기도 바꿔야 한다.</p>
<p>  : ImpovedCNN은 마지막에 AdaptiveAvgPool2d((1,1))을 추가하는 구조로 변경했다. 이는 출력 크기를 고정하는 풀링 방식이며, 일반적인 풀링은 입력 크기에 따라 출력 크기가 달라지지만, 이는 입력크기에 상관없이 출력을 항상 지정한 크기(1,1)로 맞춰준다. Flatten 전에 AdaptiveAvgPooling을 통해 입력 크기에 상관 없이 항상 1x1로 압축하므로 FC 입력이 채널 수만 남고, 파라미터 수가 대폭 줄어들게 된다. 또한, MaxPool보다 정보 손실이 적다.</p>
</li>
<li><p><strong>분류기 구조를 더 간결하게 정리</strong></p>
<p>  : 위에서 설명했던 것과 같이, 기존 분류기에서는 8192 차원이 입력으로 들어와 파라미터 수가 많고, 중간 FC 레이어가 추가될수록 과적합 위험이 커진다. 이를 방지하기위해, AdaptiveAvgPool로 이미 256차원으로 압축된 특징이 들어오기 때문에, 중간 FC 레이어 없이 바로 분류가 가능해진다. 이런 구조는 파라미터 수가 줄고 구조가 단순해져 과적합을 방지해준다.</p>
</li>
</ul>
<p>즉, ImprovedCNN은 <strong>학습 안정성이 더 좋아지고 / 과적합 완화에 도움이 될 수 있으며 / 마지막 feature map 크기에 덜 민감한 구조로 변경</strong>한 것임.</p>
<pre><code class="language-python">class improvedCNN(nn.Module): # 정규화/드롭아웃 포함 개선 모델 정의부
  def __init__(self, num_classes: int=10): # 출력 클래스 수 설정 가능 구조
    super().__init__() # nn.Module 초기화 호출

    self.features = nn.Sequential( # 개선형 특징 추출부
        nn.Conv2d(3, 32, kernel_size=3, padding=1), # 초기 저수준 특징 추출용 합성곱
        # BatchNorm -&gt; internal covariate shift를 해결하기 위해 사용함.
        # internal covariate shift : 입력 데이터의 분포가 학습할 때와
        # 테스트할 때 다르게 나타나는 현상
        # 평균이 안 흔들리게 도와주는 것
        # BatchNorm이 컨볼루션 다음 나오는 이유
        # -&gt; ReLU 다음 하면 0 이하는 버리니까 원래 데이터 분포와 달라짐
        # ReLU 때문에 내부 분포가 계속 변하면서 원래 분포에서 벗어나있는데
        # 이 상태에서 적용하면 이상해짐
        # 근데 실제적인 상황에서는 바꿨을 때 더 잘 나오기도 함.
        nn.BatchNorm2d(32), # 채널별 활성값 분포 안정화 계층
        nn.ReLU(inplace=True), # 비선형성 부여

        nn.Conv2d(32, 64, kernel_size=3, padding=1), # 채널 확장 단계
        nn.BatchNorm2d(64), # 학습 안정용 정규화
        nn.ReLU(inplace=True), # 비선형성 부여
        nn.MaxPool2d(kernel_size=2, stride=2), # 32*32 -&gt; 16*16 다운샘플링

        nn.Conv2d(64, 128, kernel_size=3, padding=1), # 중간 수준 특징 확장 단계
        nn.BatchNorm2d(128), # 분포 안정화 계층
        nn.ReLU(inplace=True), # 비선형성 부여

        nn.Conv2d(128, 256, kernel_size=3, padding=1), # 고수준 특징 채널 확장 단계
        nn.BatchNorm2d(256), # 깊은 층 학습 안정화 목적
        nn.ReLU(inplace=True), # 비선형성 부여
        nn.MaxPool2d(kernel_size=2, stride=2), # 16*16 -&gt; 8*8 다운샘플링

        nn.Conv2d(256, 256, kernel_size=3, padding=1), # 고수준 특징 재정련 단계
        nn.BatchNorm2d(256), # 마지막 특징 분포 안정화
        nn.ReLU(inplace=True), # 비선형성 부여

        # 전역 평균 풀링 수행 시 (1, 256, 8, 8) -&gt; (1, 256, 1, 1)이 되므로
        # classifier 단에서 8*8*256이 아니라 Linear(256, num_classes)
        # CNN + FC(Fully Connected) Layer에서 classifier인 FC Layer를 없애기 위한 방법
        nn.AdaptiveAvgPool2d((1, 1)) # 공간 차원을 1*1로 압축하는 전역 평균 풀링
    )

    self.classifier = nn.Sequential( # 개선형 분류기 구성부
        nn.Flatten(), # [B, 256, 1, 1] -&gt; [B, 256] 변환
        # 스케일이 큰 값이 우위를 가지는 걸 방지하기 위해 일부 뉴런을 꺼줌으로써
        # 다른 뉴런이 학습할 기회를 줌.
        **# epoch 단위보다는 Iteration 단위로 Dropout**
        # 배치 단위 학습 -&gt; 그 배치에 의존하는 노드 발생하므로 특정 노드 강화하는 방식 학습
        # (더 편향적)
        nn.Dropout(p=0.3), # 과적합 완화용 일부 뉴런 비활성화
        nn.Linear(256, num_classes) # 최종 클래스 로짓 출력
    )

  def forward(self, x): # 순전파 정의부
    x = self.features(x) # 특징 추출부 통과
    x = self.classifier(x) # 분류기 통과
    return x # 최종 로짓 반환</code></pre>
<pre><code class="language-python">baseline_model = BaselineCNN(num_classes=10) # 기준 모델 인스턴스 생성
improved_model = improvedCNN(num_classes=10) # 개선 모델 인스턴스 생성

dummy_input = torch.randn(4, 3, 32, 32) # 배치 크기 4인 가상 입력 텐서 생성

# (Batch, Num_classes)
# 기준 모델 출력 차원 확인
print(&quot;Baseline output shape : &quot;, baseline_model(dummy_input).shape)
# 개선 모델 출력 차원 확인
print(&quot;Improved output shape : &quot;, baseline_model(dummy_input).shape)
print() # 출력 가독성 확보용 빈 줄

# 층별 특징맵 크기 추적 함수
def inspect_feature_shapes(model : nn.Module, input_shape=(1, 3, 32, 32)):
  x = torch.randn(*input_shape) # 지정 크기 가상 입력 생성
  print(f&quot;input shape : {tuple(x.shape)}&quot;) # 입력 크기 출력

  for layer in model.features: # 특징 추출부의 각 계층 순차 순회
    x = layer(x) # 현재 계층 통과 결과 갱신
    # 계층명과 출력 크기 동시 출력
    print(f&quot;{layer.__class__.__name__:&lt;20} -&gt; {tuple(x.shape)}&quot;)

print(&quot;=== improvedCNN Feature Shape  Flow ===&quot;) # shape 흐름 분석 시작 안내
inspect_feature_shapes(improved_model) # 개선 모델 특징맵 크기 흐름 출력</code></pre>
<ul>
<li><strong>출력 shape 확인하기</strong>
<img src="https://velog.velcdn.com/images/inhwaaa_v/post/13c3ef10-fef9-4c28-b283-6fd1a1ec9450/image.png" alt=""></li>
</ul>
<h1 id="3-학습-루프와-최적화-설정-이해">3. 학습 루프와 최적화 설정 이해</h1>
<h3 id="modeltrain--modeleval">model.train() / model.eval()</h3>
<p> <code>model.train()</code>과 <code>model.eval()</code>은 <strong>모델의 학습 모드와 평가 모드를 전환하는 함수</strong>이다. 딥러닝 모델에는 학습할 때와 평가할 때 동작 방식이 달라져야 하는 레이어들이 존재하므로, 학습 단계와 검증·테스트 단계에서 이를 명시적으로 구분해주어야 한다. 이때 사용하는 것이 <code>train()</code>과 <code>eval()</code>이다.</p>
<p> <code>model.train()</code>을 호출하면 모델은 학습 모드로 전환되고, <code>model.eval()</code>을 호출하면 평가 모드로 전환된다. 학습 모드에서는 주로 forward 연산 이후 loss를 계산하고, backward를 통해 gradient를 구한 뒤 optimizer step으로 파라미터를 업데이트하는 흐름이 사용된다. 반면 평가 모드에서는 일반적으로 파라미터를 업데이트하지 않고, 검증 데이터나 테스트 데이터에 대해 loss나 accuracy 같은 성능 지표를 측정한다.</p>
<p> 다만 여기서 주의해야 할 점은, <code>model.eval()</code>이 <strong>자동으로 gradient 계산을 꺼주는 것은 아니라는 점</strong>이다. <code>eval()</code>은 단지 특정 레이어들의 동작 방식을 평가용으로 바꾸는 역할을 한다. 따라서 평가 단계에서는 보통 <code>with torch.no_grad():</code>를 함께 사용하여 gradient 계산을 비활성화하고, 메모리 사용량과 연산량을 줄인다. 즉, <code>train()</code>과 <code>eval()</code>은 모델의 모드를 바꾸는 함수이고, gradient 계산 여부는 별도로 제어해야 한다.</p>
<p>또, <code>train()</code>과 <code>eval()</code> 에서 동작 방식이 다른 레이어는 두 가지가 존재한다.</p>
<ul>
<li><p><strong>Dropout</strong> : 학습 모드에서는 일부 뉴런을 랜덤하게 비활성화하여 특정 뉴런이나 연결에 과도하게 의존하는 것을 방지한다. 하지만 평가 모드에서는 더 이상 뉴런을 랜덤하게 끄지 않고, 모든 뉴런을 사용한 상태로 연산을 수행한다. 이는 평가 시마다 결과가 달라지는 것을 막고, 학습된 모델의 실제 성능을 안정적으로 측정하기 위함이다.</p>
</li>
<li><p><strong>BatchNorm</strong> : 학습 모드에서는 현재 미니배치의 평균과 분산을 사용하여 feature를 정규화한다. 이 과정에서 동시에 running mean과 running variance도 업데이트된다. 반면 평가 모드에서는 현재 입력 배치의 통계를 사용하지 않고, 학습 과정에서 누적해둔 running mean과 running variance를 사용하여 정규화를 수행한다. 평가 시마다 입력 배치의 통계에 따라 결과가 달라지면 예측이 일관되지 않을 수 있으므로, 학습 중 축적한 통계를 사용하는 것이다.</p>
<p>정리하면, <code>model.train()</code>과 <code>model.eval()</code>은 <strong>학습과 평가 상황에 맞게 모델 내부 레이어의 동작 방식을 전환하기 위해 반드시 필요한 설정</strong>이다. 특히 Dropout과 BatchNorm처럼 모드에 따라 동작이 달라지는 레이어가 포함된 경우, 이를 올바르게 설정하지 않으면 학습 성능이나 평가 결과가 왜곡될 수 있다. 따라서 학습 시에는 <code>model.train()</code>, 검증 및 테스트 시에는 <code>model.eval()</code>을 명확히 구분하여 사용하는 것이 중요하다.</p>
</li>
</ul>
<h3 id="zero_grad-→-forward-→-loss-계산-→-backward-→-step-흐름">zero_grad() → forward → loss 계산 → backward → step 흐름</h3>
<p>학습 루프를 그대로 따라가면 다음과 같다.</p>
<pre><code class="language-python">optimizer.zero_grad()       # 1. 이전 기울기 초기화
outputs = model(images)     # 2. 순전파 (grad_fn 체인 생성)
loss = criterion(outputs, labels)  # 3. loss 계산
loss.backward()             # 4. 역전파 (기울기 계산)
optimizer.step()            # 5. 파라미터 업데이트</code></pre>
<ul>
<li><p><strong>optimizer.zero_grad()</strong> : 이전 배치에서 계산된 기울기(gradient)를 초기화하는 단계</p>
<ul>
<li>리프 텐서의 .grad는 누적된다. 이전 배치에서 계산된 기울기가 남아있으면, 이번 배치의 기울기와 더해져서 잘못된 방향으로 업데이트된다. 그래서 매 배치 시작 전에 모든 파라미터의 .grad를 0으로 초기화해야 한다.</li>
</ul>
</li>
<li><p><strong>output = model(images)</strong> : 순전파(forward propagation) 단계</p>
<ul>
<li>순전파로, model(images)를 호출하면 내부적으로 forward()가 실행된다.</li>
<li>이 과정에서 PyTorch는 각 연산마다 grad_fn을 체인으로 연결해, 계산 그래프를 만들어두고, 결과로 나오는 outputs는 로짓(logit)이다.</li>
<li>로짓은 확률이 아닌 raw 점수로, 값이 클수록 그 클래스일 가능성이 높다고 모델이 판단한 것이다.</li>
</ul>
</li>
<li><p><strong>loss = criterion(outputs, labels) :</strong> 모델의 예측과 실제 정답을 비교하여 손실(loss)을 계산</p>
<ul>
<li>손실함수가 로짓을 받아 Loss를 계산한다. CrossEntropyLoss에서 모델이 한 클래스를 높게 예측할 수록 loss는 낮아지며, 배치 전체의 loss는 128개 샘플의 평균이다.</li>
</ul>
</li>
<li><p><strong>loss.backward()</strong> : 역전파(backpropagation) 단계</p>
<ul>
<li>역전파로, loss를 시작점으로 계산 그래프를 거꾸로 타고 올라가면서 각 파라미터에 대한 기울기를 계산한다.</li>
<li>Chain Rule에 의해, 각 레이어의 파라미터에 dloww/dw가 계산되어 .grad에 저장된다.</li>
<li>이 때 기울기의 의미는 “이 파라미터를 조금 키우면 loss가 얼마나 변하는가”로, 양수면 파라미터를 줄여야 낮아지고 음수면 키워야 loss가 낮아진다.</li>
</ul>
</li>
<li><p><strong>optimizer.step() :</strong> 계산된 gradient를 바탕으로 실제 파라미터를 업데이트하는 단계</p>
<ul>
<li>계산된 기울기를 이용해 파라미터를 실제로 업데이트 하는 과정이다.</li>
<li>Adam을 기준으로 w = w - lr * w.grad 와 같은 수식을 통해 이루어지며, no_grad() 상태에서 진행되어 파라미터가 leaf 텐서로 유지된다.</li>
</ul>
<p>위 과정이 배치마다 반복되고, 전체 train_dataset을 한 번 다 돌면 1 epoch가 끝난다. CIFAR-10을 기준으로 하면, 45,000장 / 128 배치 크기 = 352 배치가 1 epoch가 된다.</p>
</li>
</ul>
<h3 id="cross-entropy-loss의-의미">Cross Entropy Loss의 의미</h3>
<p>다중 클래스 분류에서 사용하는 손실 함수로, 다음과 같은 두 가지 연산을 내부적으로 합친다.</p>
<ul>
<li><p>모델은 먼저 <strong>로짓(logit)</strong> 을 출력하고</p>
</li>
<li><p>그 로짓을 <strong>Softmax</strong> 로 확률처럼 바꾸고</p>
</li>
<li><p>그 확률에 <strong>log</strong>를 취한 다음</p>
</li>
<li><p>정답 클래스의 값만 골라서 손실을 계산한다 (Negative Log Likelihood / NLL Loss)</p>
<p>결국 모델이 정답 클래스를 높은 확률로 예측할 수록 loss가 0에 가까워지고, 틀릴 수록 loss가 커지는 손실 함수이다.</p>
</li>
</ul>
<h3 id="optimizer--scheduler">Optimizer / Scheduler</h3>
<p> Optimizer와 Scheduler는 딥러닝 모델의 학습 과정에서 서로 다른 역할을 담당한다. 먼저 <strong>Optimizer는 역전파를 통해 계산된 기울기(gradient)를 이용해 모델의 파라미터를 실제로 업데이트하는 역할</strong>을 한다. 즉, <code>loss.backward()</code> 단계에서 각 파라미터의 <code>.grad</code>에 저장된 기울기를 바탕으로, 손실이 줄어드는 방향으로 가중치를 조정하는 것이 Optimizer의 핵심 기능이다. 반면 <strong>Scheduler는 학습이 진행되는 동안 학습률(learning rate)을 자동으로 조정하는 역할</strong>을 한다. 학습률은 파라미터를 얼마나 크게 업데이트할지를 결정하는 중요한 하이퍼파라미터이므로, 학습 초반과 후반에 서로 다른 값으로 조절하면 더 효율적인 학습이 가능해진다.</p>
<p> 일반적으로 학습 초반에는 비교적 큰 학습률을 사용하여 빠르게 최적점 근처로 이동하고, 학습이 어느 정도 진행된 이후에는 더 작은 학습률을 사용하여 세밀하게 파라미터를 조정하는 전략이 자주 사용된다. 이때 Scheduler가 이러한 학습률 변화를 자동으로 관리해준다. 예를 들어 <code>StepLR</code>은 일정 epoch마다 학습률을 특정 비율만큼 감소시키는 방식이다. 만약 초기 학습률이 0.0003이고, 5 epoch마다 0.5배씩 줄이도록 설정했다면, epoch 1<del>5에서는 0.000300, epoch 6</del>10에서는 0.000150, epoch 11~15에서는 0.000075와 같이 학습률이 단계적으로 감소하게 된다. 이러한 방식은 초반에는 빠른 수렴을 유도하고, 후반에는 보다 안정적이고 세밀한 최적화를 가능하게 한다. 반대로 Scheduler를 사용하지 않으면 학습률은 처음 설정한 값으로 고정되며, 학습 내내 같은 크기로 업데이트가 이루어진다.</p>
<p> Optimizer는 모델의 파라미터를 등록받아 이를 관리한다. 예를 들어 <code>optimizer = optim.Adam(model.parameters(), lr=1e-3)</code>와 같이 작성하면, <code>model.parameters()</code>에 포함된 모든 학습 가능한 파라미터를 Adam Optimizer가 관리하면서 업데이트하게 된다. 가장 기본적인 예로 SGD는 기울기를 이용해 파라미터를 단순하게 갱신한다. 직관적으로 보면 이는 <code>w = w - lr * grad</code>와 같은 형태에 가깝다. 즉, gradient가 양수이면 파라미터를 줄이고, gradient가 음수이면 파라미터를 키우는 방향으로 업데이트가 이루어진다. 그러나 SGD는 모든 파라미터에 동일한 학습률을 적용하기 때문에, 어떤 파라미터에는 너무 크게, 어떤 파라미터에는 너무 작게 업데이트가 이루어질 수 있다는 한계가 있다. 이러한 문제를 개선하기 위해 등장한 대표적인 Optimizer가 Adam이다.</p>
<pre><code class="language-python">optimizer_baseline = optim.Adam(baseline_model.parameters(), lr=1e-3)
optimizer_improved = optim.AdamW(improved_model.parameters(), lr=3e-4, weight_decay=1e-4)

scheduler_improved = optim.lr_scheduler.StepLR(
    optimizer_improved, step_size=5, gamma=0.5
) # 5 epoch마다 학습률을 0.5배로 줄임</code></pre>
<p> 코드 실행 결과에서 ImprovedCNN의 학습률 변화를 보면 다음과 같은 변화를 확인할 수 있다.</p>
<blockquote>
<p>epoch 1<del>5:  lr = 0.000300
epoch 6</del>10: lr = 0.000150  ← 0.5배
epoch 11~15: lr = 0.000075 ← 0.5배
...</p>
</blockquote>
<p> 이 과정에서 학습률(learning rate)을 0.5배씩 줄여주는 것이 scheduler의 역할이다.</p>
<p> 초반에는 큰 학습률로 빠르게 수렴하고, 후반에는 작은 학습률로 세밀하게 조정하는 전략으로, BaselineCNN은 scheduler 없이 고정 학습률을 쓰기 때문에 학습률 그래프가 평평하게 나타난다.</p>
<p> Optimizer는 backward()로 계산된 기울기를 이용해서 파라미터를 실제로 업데이트 하는 역할을 한다.</p>
<pre><code class="language-python">optimizer = optim.Adam(model.parameters(), lr=1e-3)</code></pre>
<p> model.parameters() 를 넘기는 것은, 해당 파라미터들을 관리해달라고 등록하는 것이다.</p>
<p> SGD로 예시를 들어보면, 가장 단순한 업데이트 수식으로</p>
<blockquote>
<p>w = w- lr * w.grad</p>
</blockquote>
<p> 와 같은 형태로 업데이트를 진행하는데, 모든 파라미터에 동일한 학습률을 적용한다는 것이 문제가 되었다. 어떤 파라미터는 기울기가 크고 어떤 파라미터는 기울기가 작은데, 같은 학습률로 업데이트하면 효율이 떨어진다는 문제를 발견했다.</p>
<h3 id="adam--adamw">Adam / AdamW</h3>
<p> Adam은 단순히 현재 gradient만 사용하는 것이 아니라, <strong>기울기의 1차 모멘텀과 2차 모멘텀을 함께 추적</strong>한다. 여기서 1차 모멘텀은 gradient의 이동 평균으로, 기울기가 여러 step 동안 비슷한 방향으로 계속 나타나면 그 방향으로 더 안정적으로 이동하도록 돕는다. 직관적으로 말하면, 같은 방향의 gradient가 반복될 때 업데이트에 일종의 관성을 부여하여 더 빠르고 부드럽게 수렴하도록 만드는 역할을 한다. 2차 모멘텀은 gradient 제곱값의 이동 평균으로, gradient의 크기가 큰 파라미터에 대해서는 업데이트 크기를 줄이고, gradient의 크기가 작은 파라미터에 대해서는 상대적으로 더 큰 업데이트가 가능하도록 조정한다. 따라서 Adam은 파라미터마다 적응적으로 학습률을 조정하는 방식이라고 볼 수 있다. 이로 인해 SGD보다 학습이 더 안정적이고, 하이퍼파라미터 튜닝 부담도 비교적 적은 편이다.</p>
<p> Adam은 Optimizer로 많이 사용하던 SGD의 문제를 해결하기 위해 기울기에 1차 모멘텀과 2차 모멘텀을 추적하며 다음을 계산한다.</p>
<ul>
<li>1차 모멘텀(m) : 기울기의 이동 평균</li>
<li>2차 모멘텀(v) : 기울기 제곱의 이동 평균</li>
</ul>
<blockquote>
<p>w = w - lr * m / (√v + ε)</p>
</blockquote>
<p> 위 수식과 1,2차 모멘텀을 함께 직관적으로 이야기하면 1차 모멘텀은 기울기가 계속 같은 방향이면 가속하게 되고, 2차 모멘텀은 기울기가 크면 학습률을 줄여서 안정화하는 역할을 한다.</p>
<p> 즉, 파라미터마다 적응적으로 학습률이 조정되는 방식으로, 기울기가 작은 파라미터는 학습률이 커지고, 기울기가 큰 파라미터는 학습률이 작아진다.</p>
<p> AdamW는 Adam의 장점은 유지하면서, <strong>weight decay를 보다 올바르게 적용하도록 개선한 Optimizer</strong>이다. 여기서 weight decay는 파라미터 값이 지나치게 커지는 것을 억제하는 정규화 기법으로, 모델이 특정 feature에 과도하게 의존하거나 과적합되는 것을 완화하는 데 도움을 준다. 일반적인 Adam에서 weight decay를 적용하면, 정규화 항이 gradient에 섞여 들어가면서 Adam의 적응적 학습률 조정 과정과 함께 처리된다. 이 경우 weight decay가 원래 의도한 방식으로 작동하지 않고, 파라미터마다 다르게 약화되거나 왜곡될 수 있다. 반면 AdamW는 Adam에 의한 업데이트와 weight decay를 <strong>분리해서 적용</strong>한다. 즉, 먼저 Adam 방식으로 파라미터를 업데이트한 뒤, 별도로 weight decay를 적용하여 파라미터의 크기를 줄이는 방식이다. 이렇게 하면 정규화 효과가 더 명확하고 일관되게 작동하게 된다.</p>
<blockquote>
<p>grad_with_decay = grad + weight_decay * w
w = w - lr * m / (√v + ε)   ← weight_decay가 모멘텀에 섞여버린다.</p>
</blockquote>
<p> 위 방식으로 진행할 경우, weight decay가 적응적 학습률 보정의 영향을 받아, 정규화 효과가 의도보다 약해지게 된다. AdamW는 여기에서 weight를 완전히 분리한 방식을 사용해, 결과적으로 정규화 효과가 더 정확하게 작동하게 된다.</p>
<blockquote>
<p>w = w - lr * m / (√v + ε)   # Adam 업데이트
w = w - lr * weight_decay * w  # weight decay 별도 적용</p>
</blockquote>
<p> 코드에서 확인해보면 다음과 같다.</p>
<pre><code class="language-python"># BaselineCNN: 기본 Adam
optimizer_baseline = optim.Adam(
    baseline_model.parameters(),
    lr=1e-3
)

# ImprovedCNN: AdamW + weight_decay
optimizer_improved = optim.AdamW(
    improved_model.parameters(),
    lr=3e-4,
    weight_decay=1e-4  # 파라미터 크기를 억제해서 과적합 방지
)</code></pre>
<p> 여기서 사용되는 weight_decay란, 파라미터 값 자체가 너무 커지지 않도록 억제하는 정규화 역할로, 파라미터가 크면 모델이 특정 특징에 과도하게 의존하는 경향을 보이는데 이를 방지하기 위해 사용한다.</p>
<blockquote>
<p>정리하면, Optimizer는 기울기를 바탕으로 파라미터를 실제로 갱신하는 역할을 하고, Scheduler는 학습이 진행됨에 따라 학습률을 자동으로 조절하는 역할을 한다. 또한 Adam은 1차 및 2차 모멘텀을 이용해 파라미터별로 적응적인 업데이트를 수행하는 Optimizer이며, AdamW는 여기에 weight decay를 분리 적용하여 정규화 효과를 더 정확하게 반영하도록 만든 개선된 방식이다. 따라서 학습을 안정적으로 진행하고, 후반부에 더 세밀하게 최적화하며, 동시에 과적합을 줄이기 위해서는 Optimizer와 Scheduler를 함께 적절히 설정하는 것이 중요하다.</p>
</blockquote>
<h3 id="best-validation">Best Validation</h3>
<p>우선 fit_model 의 코드를 먼저 가져와서 보면,</p>
<pre><code class="language-python">if val_acc &gt; best_val_acc:
    best_val_acc = val_acc
    best_state_dict = copy.deepcopy(model.state_dict())

# 학습이 종료된 후
model.load_state_dict(best_state_dict)</code></pre>
<p>위와 같은 형식을 가지고 있다.</p>
<p> 이렇게 best validation 모델을 별도로 저장하는 이유는, validation set을 사용해 검증을 진행할 때 Acc나 Loss가 갑자기 낮아지는 부분에서 과적합이 발생할 가능성이 매우 높다. 이는 결국 학습이 완전히 종료되었을 때의 모델이 항상 최선의 모델이 아니라는 것을 의미한다.</p>
<p> 그렇기 때문에 val_acc가 가장 높았던 시점의 가중치를 deepcopy()로 저장해두었다가 학습이 끝나면 해당 모델을 복원한 후 최종 test를 진행한다.</p>
<h3 id="주의사항">주의사항</h3>
<p> 모델을 평가할 때에는 최종 정확도 하나만 보는 것보다, 학습이 어떤 경로를 거쳐 진행되었는지를 보는 습관이 가장 중요하다. 아래는 train / validation loss, train / validation accuracy / learning rate를 시각화한 자료이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/1e09721a-0545-4df4-a340-d11d7c19f490/image.png" alt=""></p>
<p>위 그림을 행별로 살펴보겠다.</p>
<ul>
<li><p>1행(BaselineCNN) :</p>
<ul>
<li><strong>Loss 그래프</strong> - train, val loss가 비슷한 속도로 함께 내려가고 있다. 둘 사이의 간격이 크지 않아, 현재까지는 과적합 징후가 거의 없는 상태임을 확인할 수 있지만 epoch 10에서도 두 곡선이 아직 수렴하지 않고 내려가는 중기기 때문에 학습이 더 필요한 상태임을 확인할 수 있다.</li>
<li><strong>Accuracy 그래프</strong> - train acc와 val acc가 거의 붙어서 함께 올라가고 있다. epoch 10 기준으로 약 81% 정도의 수준인데, 곡선이 아직 평탄해지지 않았으므로 epoch을 더 늘리면 성능이 더 오를 가능성이 존재한다.</li>
<li><strong>Learning Rate 그래프</strong> - Scheduler 없이 고정 학습률 0.001을 사용하고 있어 그래프가 완전히 평평하다.</li>
</ul>
</li>
<li><p>2행(ImprovedCNN) :</p>
<ul>
<li><p><strong>Loss 그래프</strong> - 전반적으로 Loss값 자체가 BaselineCNN보다 더 높게 시작하고 높게 유지되고 있음을 확인할 수 있다. 이는, 아래 코드에서 확인할 수 있는 label_smoothing=0.1 옵션 때문으로, 정답 레이블을 1.0 대신 0.9로 처리하기 때문에 모델이 아무리 잘 맞혀도 loss가 일정 수준 아래로 내려가기 어렵기 때문이지, 숫자 자체가 크다고 해서 성능이 나쁘다는 것을 의미하지는 않는다.</p>
<p>  train loss와 val loss 간격이 매우 좁고, val loss가 train loss보다 오히려 낮은 구간도 존재한다. 이는 Dropout과 BatchNorm의 정규화 효과가 잘 작동하고 있음을 확인할 수 있는 것이며, Dropout이 학습 중에는 뉴런을 일부 끄기 때문에 train loss가 상대적으로 높게 측정되고, eval 시에는 모든 뉴런이 켜지면서 val loss가 더 낮아진다.</p>
</li>
<li><p><strong>Accuracy 그래프</strong> - epoch 12 기준으로, train과 val acc가 약 81% 수준으로 BaselineCNN과 비슷함을 확인할 수 있다.</p>
</li>
<li><p><strong>Learning Rate 그래프</strong> - StepLR scheduler가 작동하면서, 5 epoch마다 학습률이 절반으로 줄어드는 것을 확인할 수 있다.</p>
</li>
</ul>
</li>
<li><p>3행(With Rotation) :</p>
<ul>
<li><strong>Loss 그래프</strong> - BaselineCNN과 구조는 같고, RandomRotation 증강만 추가된 경우다. 차이를 확인해보면, val loss가 train loss보다 일관되게 낮음을 확인할 수 있다. 이는 데이터 증강의 추가로 인해 학습 데이터가 더 어려워졌기 때문에 train loss가 상대적으로 높게 측정되는 것으로, 증강이 일종의 정규화 역할을 하고 있음을 확인할 수 있다.</li>
<li><strong>Accuracy 그래프</strong> - 이 그래프 또한 특이하게 val acc가 train acc보다 높다. train set에는 rotation 증강이 적용되어 더 어려운 데이터로 학습하지만, val set에는 증강 없이 평가하기 때문에 역전 현상이 발생했음을 확인할 수 있다. epoch 10 기준으로 val acc가 약 79% 정도인데, BaselineCNN보다 낮은 것을 확인할 수 있지만, epoch 수가 적어서 아직 수렴 전일 가능성이 높으므로 epoch 수를 늘려 확인해 볼 가치가 있다.</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># 모델을 어떻게 학습시키고, 학습이 잘 되고 있는지 어떻게 평가하는가!
# train_one_epoch -&gt; evaluate -&gt; fit_model 반복
# train_one_epoch : 한 에폭 동안 학습 데이터로 모델을 실제로 업데이트하는 함수
# evaluate : 검증 데이터나 테스트 데이터로 성능 확인 함수
# fit_model : 여러 epoch을 반복하며 전체 학습 진행
# epoch : 학습 데이터 전체를 학습에 이용하는 것
def train_one_epoch(model, dataloader, criterion, optimizer, device): # 학습 1 epoch 수행 함수
  # 학습할 때랑 평가할 때 차이 파악하기
  model.train() # 모델을 학습 모드로 전환

  running_loss = 0.0 # 샘플 수 가중 손실 누적 변수
  correct = 0 # 정답 개수 누적 변수
  total = 0 # 전체 샘플 수 누적 변수

  # non_blocking=True로 하면 non_blocking=True로 줌 (빠르게 복사 위해)
  for images, labels in dataloader: # 미니배치 단위 반복 루프
    images = images.to(device, non_blocking=True) # 입력 이미지를 연산 장치로 이동
    labels = labels.to(device, non_blocking=True) # 정답 라벨을 연산 장치로 이동

    # Pytorch는 기본적으로 Gradient 안 덮어쓰고 누적함.
    # 이전 배치에서 구했던 기울기가 남아있는 상태에서 현재 기울기가 더해질 수 있는 상태
    # 따라서 반복할 때마다 기울기를 초기화해줘야 함.
    optimizer.zero_grad() # 이전 step 기울기 초기화

    # 로짓값은 확률 값은 아님 ! 크로스 엔트로피 쓰면 Softmax 이전에 로짓 값이 나옴
    # 로짓값은 각 클래스별 점수 값임
    outputs = model(images) # 순전파 결과 로짓(logit) 계산
    loss = criterion(outputs, labels) # 현재 배치 손실 계산

    loss.backward() # 역전파 수행 (모델의 각 가중치에 대해 gradient를 계산, 틀린 만큼 어떻게 고쳐야 할지를 피드백 받음)
    optimizer.step() # 파라미터 갱신(update) 수행

    # images.size(0) : 몇 개 들어있는지 계산
    # 이미지 배치에서 예를 들어 102개라고 했다 하면 2개가 남음 -&gt; 마지막 배치 남아서
    # 각 배치 사이즈가 달라질 수 있으므로 배치 사이즈 계산을 위해 사용
    # (안 하면 마지막 배치가 2개뿐이어도, 20개짜리 배치와 똑같은 비중으로 들어갈 수 있음)
    running_loss += loss.item() * images.size(0) # 배치 크기 가중 손실 누적
    preds = outputs.argmax(dim=1) # 예측 클래스 인덱스 추출
    correct += (preds == labels).sum().item() # 정답 예측 개수 누적
    total += labels.size(0) # 전체 샘플 수 누적

  epoch_loss = running_loss / total # epoch 평균 손실 계산
  epoch_acc = correct / total # epoch 평균 정확도 계산
  return epoch_loss, epoch_acc # 학습 손실/정확도 반환

@torch.no_grad() # 평가 단계 기울기 계산 비활성화 데코레이터 (메모리 사용량이 적어지고, 속도가 빨라짐)
def evaluate(model, dataloader, criterion, device): # 검증/테스트 데이터에 대해서 모델 성능을 평가하는 함수
  model.eval() # 모델을 평가 모드로 전환 (Dropout이 꺼짐, BatchNorm은 평가 시 현재 배치의 통계가 아니라 저장된 통계를 사용해야 함.)

  running_loss = 0.0 # 샘플 수 가중 손실 &quot;누적 변수&quot;
  correct = 0 # 정답 개수 &quot;누적 변수&quot;
  total = 0 # 전체 샘플 수 &quot;누적 변수&quot;

  for images, labels in dataloader: # 미니배치 반복 루프
    images = images.to(device, non_blocking=True) # 입력 이미지를 연산 장치로 이동
    labels = labels.to(device, non_blocking=True) # 정답 라벨을 연산 장치로 이동

    outputs = model(images) # 순전파 결과 로짓 계산
    loss = criterion(outputs, labels) # 현재 배치 손실 계산 (배치별 loss 기억하기)

    running_loss += loss.item() * images.size(0) # 배치 크기 가중 손실 누적
    preds = outputs.argmax(dim=1) # 예측 클래스 인덱스 추출
    correct += (preds == labels).sum().item() # 정답 예측 개수 누적
    total += labels.size(0) # 전체 샘플 수 누적

  epoch_loss = running_loss / total # epoch 평균 손실 계산
  epoch_acc = correct / total # epoch 평균 정확도 계산
  return epoch_loss, epoch_acc # 평가 손실/정확도 반환

def fit_model(model, train_loader, val_loader, criterion, optimizer, device, epochs, scheduler=None): # 전체 학습과정을 관리하는 함수
  history = { # 학습 이력 저장용 사전
      &quot;train_loss&quot;: [], # epoch별 학습 손실 기록
      &quot;train_acc&quot; : [], # epoch별 학습 정확도 기록
      &quot;val_loss&quot; : [], # epoch별 검증 손실 기록
      &quot;val_acc&quot; : [], # epoch별 검증 정확도 기록
      &quot;lr&quot; : [] # epoch별 학습률 기록
  }

  best_val_acc = -1.0 # 최고 검증 정확도 초기값
  best_state_dict = None # 최고 성능 가중치 저장 변수

  for epoch in range(epochs): # 1부터 epochs까지 반복
    start_time = time.time() # epoch 시작 시각 기록

    train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) # 학습 1 epoch 수행
    val_loss, val_acc = evaluate(model, val_loader, criterion, device) # 검증 성능 계산

    if scheduler is not None: # 스케줄러 사용 여부 분기
      scheduler.step() # epoch 종료 후 학습률 갱신

    # optimizer가 사용 중인 learning rate를 뽑아오는 것
    # 에폭에 따른 학습률 변화를 보기 위해서!
    current_lr = optimizer.param_groups[0][&quot;lr&quot;] # 현재 학습률 추출

    # 에폭마다 기록
    history[&quot;train_loss&quot;].append(train_loss) # 학습 손실 기록
    history[&quot;train_acc&quot;].append(train_acc) # 학습 정확도 기록
    history[&quot;val_loss&quot;].append(val_loss) # 검증 손실 기록
    history[&quot;val_acc&quot;].append(val_acc) # 검증 정확도 기록
    history[&quot;lr&quot;].append(current_lr) # 학습률 기록

    if val_acc &gt; best_val_acc: # 최고 검증 정확도 갱신 여부 확인
      best_val_acc = val_acc # 최고 정확도 값 갱신
      # deepcopy를 쓰는 이유 -&gt; 그냥 참조만 하게 되면 작업하면서 그 값이 바뀔 수 있으므로
      best_state_dict = copy.deepcopy(model.state_dict()) # 최고 성능 가중치 깊은 복사 저장. state_dict()는 모델의 학습 가능한 파라미터들을 담고 있는 딕셔너리

    elapsed = time.time() - start_time # epoch 소요 시간 계산
    print( # epoch별 핵심 지표 출력 구간
      f&quot;Epoch [{epoch:02d}/{epochs:02d}] | &quot;
      f&quot;Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f} | &quot;
      f&quot;Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.4f} | &quot;
      f&quot;LR: {current_lr:.6f} | &quot;
      f&quot;Time: {elapsed:.1f}s&quot;
    )

  if best_state_dict is not None: # 최고 성능 가중치 존재 여부 확인
    model.load_state_dict(best_state_dict) # 최고 검증 성능 시점 가중치 복원

  history[&quot;best_val_acc&quot;] = best_val_acc # 최고 성능 가중치 존재 여부 확인
  print(f&quot;Best Validation Accuracy : {best_val_acc:.4f}&quot;) # 최고 검증 성능 시점 가중치 복

  return history # 전체 학습 이력 반환</code></pre>
<h1 id="5-성능-평가와-오류-분석">5. 성능 평가와 오류 분석</h1>
<h3 id="accuracy정확도">Accuracy(정확도)</h3>
<p> 정확도란, 단순히 맞춘 개수를 전체 개수로 나눈 것을 의미하며, 전체에 대한 평균이다.</p>
<p> 모델의 성능을 판단할 때 이 정확도와 손실을 가장 많이 확인하지만, 역으로 정확도가 전체 평균이기 때문에 클래스별 성능 차이가 숨겨질 가능성이 존재한다.</p>
<blockquote>
<p>전체 정확도 : 83.89%</p>
<p>cat:        74.30%  ← 가장 낮음
dog:        75.20%
bird:       76.40%
...
ship:       92.20%  ← 가장 높음
automobile: 91.30%</p>
</blockquote>
<p> 예제에서 일부 가져온 이 지표를 확인해보면, 전체 정확도는 83.89%인 반면, cat은 74%, ship은 92%로 두 클래스의 정확도 차이가 18%나 나고 있음을 확인할 수 있고, 단순히 전체 Acc 지표만 확인하면 이 차이를 알 수가 없다는 한계가 존재한다.</p>
<h3 id="confusion-matrix">Confusion Matrix</h3>
<p> Confusion Matrix란, 모델이 각 클래스를 얼마나 맞추고 얼마나 틀렸는지를 한 눈에 확인할 수 있는 표(예측값과 실제값 사이 관계를 행렬 형태로 표현한 것)로, 아래와 같은 형태를 띄고 있다. </p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/862cf943-00f3-45a0-84e8-c7c960c3dc22/image.png" alt="">
S</p>
<p> 행(세로)은 실제 정답을, 열(가로)는 모델이 예측한 값을 각 축으로 사용하며, 이를 종합해 대각선이 정답, 대각선 외의 값이 오분류이다. 이를 기준으로 모델이 어떤 클래스를 가장 잘 맞추고 어떤 클래스를 못 맞추는지 대각선 값이 큰 클래스와 작은 클래스를 확인함으로 알아낼 수 있다. </p>
<p> 대각선을 제외한 값 중 가장 높은 값을 위주로 확인해보면, 어떤 클래스를 모델이 어떤 다른 클래스로 잘못 예측하고 있는 것인지 오분류가 가장 잦은 클래스 또한 확인할 수 있는 것이 Confusion Matrix이다.</p>
<h3 id="클래스별-정확도-확인-및-오분류-사례-분석의-중요성">클래스별 정확도 확인 및 오분류 사례 분석의 중요성</h3>
<p> 위에서 첨부한 이미지를 살펴보면 가장 잘 맞추는 클래스로는 ship / horse / automobile 임을, 가장 못 맞추는 클래스로는 cat / dog / deer이 있음을 확인할 수 있다. 이러한 클래스별 정확도를 확인하는 것은 모델 개선 방향을 알 수 있는 가장 큰 지표 중 하나이다.</p>
<p> 가장 헷갈려 하는 쌍을 보면 cat ↔ dog 쌍이 존재하는데, 두 클래스 모두 털, 귀, 눈 과 같은 유사한 특징을 가지고 있음과 동시에 32x32라는 저해상도 환경이기 때문에 모델이 어려움을 가지고 있음을 예측할 수 있다.</p>
<p> 모델의 성능 개선을 위해선 가장 낮은 클래스부터 집중해서 올리는 것이 가장 효율적이고, 어떤 쌍을 헷갈려 하는지 등을 함께 분석해야만 모델의 개선 방향을 잡을 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/54e3af42-68db-4f88-b2dd-3673974cceb9/image.png" alt=""></p>
<p> 우선 개선 방향을 확인했다면, 다음으론 구체적인 분석을 진행할 수 있다.</p>
<p> 오분류 이미지를 확인해보면, 실제로 사람도 헷갈릴 만한 케이스들 또한 존재한다.</p>
<blockquote>
<p>True: automobile → Pred: truck
True: dog              → Pred: deer<br>True: horse           → Pred: cat<br>True: airplane      → Pred: horse </p>
</blockquote>
<p> 이런 분석이 중요한 이유 중 가장 중요한 것은 모델의 문제인 것인지 데이터의 문제인 것인지를 구분할 수 있다는 것이다. airplane → horse 오분류는 이미지 자체가 특이한 각도라서 데이터 문제에 가깝지만, cat ↔ dog 오분류는 모델이 두 클래스를 구분하는 특징을 충분히 학습하지 못하는 문제임을 확인할 수 있다.</p>
<p> 이를 통해 추가 개선 방향으로, 특정 각도나 클로즈업 이미지에서 오분류가 많다면 해당 유형의 증강을 강화하는 식으로 대응할 수 있다.</p>
<h3 id="train--validation-곡선을-통한-과적합-여부-판단">Train / Validation 곡선을 통한 과적합 여부 판단</h3>
<p>우선 각 모델별 학습 결과를 확인해보면, 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/c597f981-8c2f-40f9-8c83-639e5cb6efaa/image.png" alt=""></p>
<p><strong>BaselineCNN</strong></p>
<blockquote>
<p>epoch 27: train acc 0.8952 / val acc 0.8564
epoch 30: train acc 0.9005 / val acc 0.8424</p>
</blockquote>
<p>epoch 27을 기준으로 이후 train acc는 계속 오르지만, val acc는 오히려 떨어지기 시작한다. 과적합이 시작된 신호로 확인할 수 있는 중요한 지표이기 때문에 Best Validation 가중치 저장이 중요하다는 것을 확인할 수 있고, epoch 27 시점의 가중치를 복원해서 최종 평가에 사용하게 된다.</p>
<p><strong>ImprovedCNN</strong></p>
<blockquote>
<p>train acc와 val acc 간격이 전 구간에서 좁음
val acc가 train acc 보다 높은 구간도 존재</p>
</blockquote>
<p>Dropout과 BatchNorm 덕분에 BaselineCNN보다 과적합이 훨씬 잘 억제되어 있음을 확인할 수 있다.</p>
<p>두 지표를 종합적으로 분석해 정리하면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/4b1b90e0-6620-44a5-b28e-46a89025071f/image.png" alt=""></p>
<h1 id="참고사항">참고사항</h1>
<h3 id="재현성-reproducibility">재현성 (Reproducibility)</h3>
<ul>
<li><strong>재현성 (Reproducibility)</strong> : 동일한 연구나 실험을 다른 조건(다른 연구자, 도구 등)에서 다시 수행했을 때 기존과 동일하거나 유사한 결과가 나오는 능력</li>
<li>실험해서 나온 결과가 똑같이 실험해도(재실험해도) 재현되는 것을 보장하도록 가중치 초기값 설정의 랜덤성을 고정(하나의 값으로 설정)한다. 딥러닝 모델 훈련 과정에서 시드를 고정하지 않으면, 무작위성 때문에 결과를 재현하기 어렵기에 시드를 고정한다. 이렇게 시드를 고정하면 모델이 훈련될 때마다 동일한 초기값이 사용되어 결과 복제 및 비교가 쉬워진다.</li>
</ul>
<pre><code class="language-python"># 재현성 고정을 위한 seed 고정 (재현성 통일용 시드 값)
SEED = 42 # 실험 반복 시 동일 결과 유도용 기준값

random.seed(SEED) # 파이썬 난수 시드 고정
np.random.seed(SEED) # Numpy 난수 시드 고정
torch.manual_seed(SEED) # CPU 텐서 난수 시드 고정
torch.cuda.manual_seed_all(SEED) # GPU 텐서 난수 시드 일괄 고정

# 재현성을 우선시하는 설정 (cuDNN (CUDA DNN GPU 가속화 라이브러리) 동작 방식 통일용 설정)
# 결정론적 연산 우선 설정 - torch.backends.cudnn.deterministic = True
torch.backends.cudnn.deterministic = True # 컨볼루션 연산에 결정론적 알고리즘만 사용
# 입력별 최적 커널 탐색 비활성화 설정 - torch.backends.cudnn.benchmark = False
torch.backends.cudnn.benchmark = False # 현재 환경에 가장 적합한 알고리즘을 선정해 수행하는 설정 끔

# GPU가 있으면 GPU를 사용하고, 없으면 CPU로 자동 전환
# 실행 장치 자동 선택
device = torch.device(&quot;cuda&quot; if torch.cuda.is_available() else &quot;cpu&quot;)
print(&quot;Using device : &quot;, device) # 실제 학습 장치 확인 목적

# 공통 설정 (학습 공통 하이퍼파라미터 영역)
DATA_ROOT = &quot;./data&quot; # 데이터 저장 경로
BATCH_SIZE = 128 # 미니배치 크기
VAL_SIZE = 5000 # 검증 세트 분리 개수
# dataset의 데이터를 gpu로 전송할 때 필요한 과정에 필요한 subprocess 수
# 0 -&gt; 메인 프로세스가 직접 데이터를 읽음 / 2 -&gt; 별도 작업자 두 명이 미리 데이터를 읽음
NUM_WORKERS = 2 if os.name != &quot;nt&quot; else 0 # 운영체제별 DataLoader 병렬 수 설정
# DRAM을 통하지 않고 바로 VRAN으로 데이터를 할당해 데이터 로드 속도 개선
PIN_MEMORY = device.type == &quot;cuda&quot; # CPU 가속용 pin_memory 사용 여부

print(f&quot;BATCH_SIZE : {BATCH_SIZE}&quot;) # 배치 크기 확인 목적
print(f&quot;VAL_SIZE : {VAL_SIZE}&quot;) # 검증 세트 크기 확인 목적
print(f&quot;NUM_WORKERS : {NUM_WORKERS}&quot;) # 워커 수 확인 목적
print(f&quot;PIN_MEMORY : {PIN_MEMORY}&quot;) # 메모리 고정 사용 여부 확인</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Segmentation & Connected Component Analysis]]></title>
            <link>https://velog.io/@inhwaaa_v/Segmentation-Connected-Component-Analysis</link>
            <guid>https://velog.io/@inhwaaa_v/Segmentation-Connected-Component-Analysis</guid>
            <pubDate>Wed, 17 Jun 2026 21:22:28 GMT</pubDate>
            <description><![CDATA[<h1 id="segmentation">Segmentation</h1>
<p> 영상 분할(Segmentation)은 이미지를 여러 개의 영역(Region)으로 나누는 작업을 의미한다. 즉, 이미지 안에서 특정 객체(Object)와 배경(Background)을 구분하거나, 색상, 밝기, 텍스처 등이 유사한 픽셀들을 하나의 영역으로 묶는 과정이다. 이렇게 분할된 영역은 객체 인식, 추적, 압축, 분석 등 다양한 후속 처리에서 활용된다.</p>
<p> 영상 분할의 목적은 이미지 전체를 하나의 덩어리로 보는 것이 아니라, 의미 있거나 유사한 특징을 가진 영역 단위로 나누어 다루는 것이다. 예를 들어 동전 이미지에서는 동전과 배경을 구분할 수 있고, 풍경 이미지에서는 하늘, 나무, 도로처럼 비슷한 색상이나 질감을 가진 영역을 나눌 수 있다.</p>
<p> 분할 기법에는 여러 가지가 있으며, 여기서는 대표적으로 세 가지 방법을 살펴본다. 첫 번째는 <strong>K-means 기반 영상 분할</strong>이다. K-means는 각 픽셀을 색상 또는 특징 벡터로 보고, 비슷한 픽셀끼리 같은 군집으로 묶는 방법이다. 두 번째는 <strong>Graph-based Segmentation</strong>이다. 이 방법은 픽셀을 그래프의 노드로 보고, 픽셀 간 유사도나 가중치를 이용하여 비슷한 픽셀끼리 하나의 영역으로 분할한다. 세 번째는 <strong>Watershed 알고리즘</strong>이다. Watershed는 이미지를 밝기값에 따른 높이 지형처럼 보고, 마커(Marker)를 기준으로 영역을 확장시키면서 서로 다른 영역이 만나는 경계를 찾는 방법이다.</p>
<p> 정리하면, 영상 분할은 이미지에서 의미 있는 영역을 나누기 위한 전처리 또는 분석 과정이며, K-means, Graph-based, Watershed와 같은 다양한 방법을 통해 객체와 배경, 또는 유사한 영역들을 구분할 수 있다.</p>
<h2 id="k-means-군집-기반-영상-분할">K-means 군집 기반 영상 분할</h2>
<p> K-means 알고리즘은 데이터를 K개의 클러스터로 나눈다. 이는 이미지의 각 픽셀을 하나의 데이터로 보고, 픽셀의 색상값을 기준으로 비슷한 픽셀끼리 묶는 방법이다. 컬러 이미지에서는 보통 각 픽셀을 <code>(R, G, B)</code> 또는 <code>(B, G, R)</code> 색상 벡터로 표현한다.</p>
<p> K-means의 목표 함수(오차 제곱합)는 다음과 같다.</p>
<p>!image.png</p>
<ul>
<li><p>$x_i$ : i번째 픽셀의 색상(혹은 특징) 벡터</p>
</li>
<li><p>$μ_k$ : k번째 군집의 중심 (평균)</p>
</li>
<li><p>$w_{ik}$ : i번째 픽셀이 k번째 군집에 속하면 1, 아니면 0</p>
<p>K-means는 먼저 <code>k</code>개의 군집 중심을 정한 뒤, 각 픽셀을 가장 가까운 군집 중심에 할당한다. 그다음 각 군집에 속한 픽셀들의 평균값으로 군집 중심을 다시 계산한다. 이 과정을 반복하면서 픽셀들이 <code>k</code>개의 색상 군집으로 나뉘게 된다. 즉, K-means 영상 분할은 이미지를 <code>k</code>개의 대표 색상으로 단순화하는 과정이라고 볼 수 있다. 원본 이미지의 모든 픽셀은 자신이 속한 군집의 중심 색상으로 바뀌므로, 결과 이미지는 색상이 단순화되고 비슷한 색을 가진 영역끼리 묶인다.</p>
</li>
</ul>
<blockquote>
<p><strong>K-means 알고리즘의 단계</strong></p>
</blockquote>
<ol>
<li>무작위(또는 K-means++ 방식)로 초기 중심 $μ_k$ 설정</li>
<li>각 데이터(픽셀)를 현재 군집 중심 중 가장 가까운 곳에 할당</li>
<li>군집별로 평균(중심)을 다시 계산</li>
<li>수렴할 때까지 2~3단계 반복</li>
</ol>
<p><strong>영상 분할에서의 K-means</strong></p>
<ul>
<li><p>영상의 모든 픽셀 ($x_i$)을 색상 벡터로 간주</p>
</li>
<li><p>K개의 색상 클러스터로 분할</p>
</li>
<li><p>결과적으로 원본 영상이 K개의 대표 색상으로 양자화(Quantization) 됨.</p>
</li>
<li><p>서로 다른 위치지만 색이 유사하면 같은 군집으로 묶이는 것이 특징임. </p>
</li>
<li><ul>
<li>양자화 : 많고 다양한 값을 몇 개의 대표값으로 줄이는 것.<blockquote>
</blockquote>
</li>
</ul>
<p>하지만 K-means는 기본적으로 색상 정보만 보고 분할한다. 따라서 서로 멀리 떨어진 위치에 있어도 색이 비슷하면 같은 군집으로 묶일 수 있고, 반대로 같은 물체라도 조명이나 그림자 때문에 색이 다르면 다른 군집으로 나뉠 수 있다.</p>
<p>이때 군집의 수인 <code>k</code> 값이 증가하면 군집의 개수가 많아진다. 즉, 이미지를 표현하는 대표 색상의 수가 많아지므로 원본 이미지의 색상과 세부 정보가 더 많이 보존된다. 하지만 너무 큰 <code>k</code>를 사용하면 이미지가 지나치게 잘게 나뉘어 과분할이 발생할 수 있고, 작은 색상 차이까지 다른 영역으로 분리될 수 있다.</p>
</li>
</ul>
<table>
<thead>
<tr>
<th>k 값</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>작음</td>
<td>색상이 단순화되고 큰 영역 중심으로 분할됨</td>
</tr>
<tr>
<td>큼</td>
<td>더 많은 색상과 세부 영역이 표현됨</td>
</tr>
<tr>
<td>너무 큼</td>
<td>작은 차이까지 분리되어 과분할 가능성 증가</td>
</tr>
</tbody></table>
<h2 id="graph-based-segmentation">Graph-based Segmentation</h2>
<p> Graph-based Segmentation은 그래프 이론을 기반으로 이미지를 여러 영역으로 나누는 분할 기법이다. 이 방법에서는 이미지를 하나의 그래프로 표현한다. 각 픽셀은 그래프의 노드(Node)가 되고, 서로 인접한 픽셀 사이의 관계는 엣지(Edge)가 된다. 이때 엣지에는 두 픽셀이 얼마나 비슷한지를 나타내는 가중치가 부여된다.</p>
<p> 픽셀 간 유사도는 보통 색상 차이, 밝기 차이, 위치 인접도 등을 기준으로 계산된다. 유사도가 높은 픽셀들은 같은 영역으로 묶이고, 차이가 큰 픽셀들 사이에는 경계가 형성된다. 즉, Graph-based Segmentation은 픽셀 간의 유사도와 경계 강도를 함께 고려하여 이미지를 여러 개의 세그먼트로 나누는 방식이다.</p>
<p> 이 과정에서는 Minimum Spanning Tree나 최소 컷과 같은 그래프 이론의 개념이 사용될 수 있다. 비슷한 픽셀끼리는 연결을 유지하고, 차이가 큰 픽셀 사이의 연결은 끊어 서로 다른 영역으로 분리한다. 이러한 Graph-based Segmentation은 K-means와 달리 단순히 색상만 보는 것이 아니라, 인접한 픽셀 간의 관계를 함께 고려한다. 그래서 이미지 안에서 비슷한 색과 연결성을 가진 영역을 나누는 데 적합하다.</p>
<p>이러한 Graph-based Segmentation는<code>cv.ximgproc.segmentation.createGraphSegmentation</code>를 통해 구현되어 있으며, 주요 파라미터는 다음과 같다.</p>
<ul>
<li><code>sigma</code>는 분할 전에 이미지를 얼마나 부드럽게 만들지 결정한다. 값이 크면 노이즈가 줄어들어 작은 잡음에 의한 분할은 줄어들지만, 세밀한 경계가 흐려질 수 있다.</li>
<li><code>k</code>는 분할 정도를 조절하는 파라미터이다. <code>k</code>가 작으면 픽셀 간 작은 차이도 경계로 판단하기 쉬워서 세그먼트가 많이 생긴다. 반대로 <code>k</code>가 크면 웬만한 차이는 같은 영역으로 묶이기 때문에 분할 수가 줄어든다.</li>
<li><code>min_size</code>는 너무 작은 세그먼트를 제거하기 위한 값이다. 분할 결과에서 <code>min_size</code>보다 작은 영역은 주변의 다른 영역과 합쳐진다. 따라서 작은 잡음 영역을 줄이는 데 도움이 된다.</li>
</ul>
<table>
<thead>
<tr>
<th>파라미터</th>
<th>의미</th>
<th>값이 커지면</th>
</tr>
</thead>
<tbody><tr>
<td><code>sigma</code></td>
<td>가우시안 블러 정도 (노이즈 감소)</td>
<td>노이즈가 줄고 더 부드럽게 분할됨</td>
</tr>
<tr>
<td><code>k</code></td>
<td>픽셀 간 경계를 나누는 유사도 기준</td>
<td>값이 크면 분할이 덜 일어나고 큰 영역으로 묶임</td>
</tr>
<tr>
<td><code>min_size</code></td>
<td>분할된 영역의 최소 픽셀 수</td>
<td>작은 영역이 큰 영역에 병합됨</td>
</tr>
</tbody></table>
<h2 id="watershed-알고리즘">Watershed 알고리즘</h2>
<p> Watershed 알고리즘은 이미지를 지형처럼 생각하는 분할 방법이다. 영상을 지형(높이)으로 보고, 물이 고이는 과정을 시뮬레이션하며, 물이 합류하는 경계선을 찾는 기법이다. 다시 말해, 밝기값이나 gradient 값을 높낮이로 보고, 낮은 지점에서 물이 차오른다고 가정한다. 물이 차오르면서 서로 다른 물웅덩이가 만나게 되는 지점이 경계가 된다. 이 경계를 이용해 객체를 분리한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/9cc700ca-ad76-4f55-8256-f60794c26451/image.png" alt=""></p>
<p> Watershed를 이해하기 위해서는 먼저 Marker의 개념을 알아야 한다. Marker는 Watershed 알고리즘이 영역 확장을 시작하는 기준점이다. 즉, 확실한 물체 내부인 전경(Foreground)과 확실한 배경(Background)을 미리 라벨링한 정보를 의미한다. 예를 들어 동전 이미지에서는 동전 내부가 확실한 전경 marker가 되고, 동전이 아닌 부분은 확실한 배경 marker가 된다. 반면 동전의 경계 근처처럼 전경인지 배경인지 명확하지 않은 부분은 unknown 영역으로 둔다.</p>
<p> Watershed 알고리즘은 이미지를 높낮이가 있는 지형처럼 보고, 각 marker에서 물이 차오르듯이 영역을 확장시킨다. 이때 서로 다른 marker에서 확장된 영역이 만나게 되는 지점이 객체의 경계가 된다. OpenCV에서는 이러한 경계 부분을 <code>-1</code> 라벨로 표시한다.</p>
<p> 따라서 Marker는 Watershed의 결과를 결정하는 중요한 요소이다. Marker가 정확하게 설정되면 객체와 배경의 경계가 잘 분리되지만, Marker가 잘못 설정되면 하나의 객체가 여러 개로 나뉘거나 여러 객체가 하나로 합쳐지는 문제가 발생할 수 있다. 예를 들어 객체 내부 marker가 잘못 잡히면 하나의 객체가 여러 개로 쪼개질 수 있고, 배경 marker가 잘못 잡히면 배경이 객체 안으로 침범할 수 있다. 따라서 Watershed의 성능은 전처리와 marker 품질에 크게 의존한다.</p>
<p> Watershed의 기본 동작 과정은 다음과 같다.</p>
<ol>
<li><p>이미지를 grayscale로 변환한다.</p>
</li>
<li><p>이진화와 morphology 연산을 통해 노이즈를 제거한다.</p>
</li>
<li><p>확실한 배경 영역을 찾는다.</p>
</li>
<li><p>distance transform 등을 이용해 확실한 전경, 즉 객체 내부 영역을 찾는다.</p>
</li>
<li><p>전경 영역에 connected component labeling을 적용해 marker를 만든다.</p>
</li>
<li><p>배경, 전경, 불확실한 영역을 구분한 marker를 입력으로 사용한다.</p>
</li>
<li><p>Watershed를 수행하면 marker들이 주변으로 확장된다.</p>
</li>
<li><p>서로 다른 marker가 만나는 지점은 경계가 되며, 이 경계를 <code>-1</code> 라벨로 표시한다.</p>
<p>Watershed의 수식적 아이디어는 다음과 같다. 이때, 여러 개의 marker가 있고, 각 marker는 어떤 물체 내부나 배경을 대표하는 시작점이다. 이미지 안의 어떤 픽셀 <code>x</code>가 있을 때, 그 픽셀이 어느 marker에 가장 가까운지 보고, 픽셀 x가 가장 가까운 marker의 라벨을 상속받는다. (Voronoi-like 방식) 즉, 각 픽셀이 가장 가까운 marker의 라벨을 따라가고, 서로 다른 marker의 영역이 만나는 지점을 경계로 정한다.</p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/64da9bd9-3fd8-4f6a-b067-bee06d9ffcf4/image.png" alt=""></p>
<h1 id="connected-component-analysis-연결-요소-분석">Connected Component Analysis (연결 요소 분석)</h1>
<h2 id="cca의-개념">CCA의 개념</h2>
<p> Connected Component Analysis, CCA는 이진 이미지(binary image)에서 서로 연결된 전경 픽셀들을 하나의 객체로 묶어 라벨을 부여하는 방법이다. Threshold나 segmentation을 하면 보통 전경 픽셀은 흰색으로, 배경 픽셀은 검은색으로 결과가 나온다. 하지만, 이 상태에서는 그저 흰색 픽셀들이 있을 뿐이지 이 픽셀들이 몇 개의 물체인지, 각 물체가 어디에 있고 얼마나 큰지, 노이즈인지 진짜 객체인지를 구분할 수 없다. 그래서 CCA를 수행해 연결된 전경 픽셀들을 하나의 객체로 묶어 단순한 binary mask를 객체 목록으로 바꾸는 방법을 활용한다.</p>
<p> CCA는 이미지에서 전경 픽셀들을 검사하면서 서로 연결되어 있는 픽셀들을 같은 component로 판단한다. 예를 들어 동전 이미지에서 각 동전이 서로 떨어져 있다면, CCA는 각 동전을 서로 다른 객체로 라벨링할 수 있다. 이렇게 하면 이미지 안에 객체가 몇 개 있는지, 각 객체가 어디에 위치하는지, 크기가 얼마나 되는지 등을 알 수 있다.</p>
<p> 이러한 연결 요소 분석은 보통 아래 흐름으로 사용된다. 이때 중요한 점은 <code>connectedComponentsWithStats()</code>는 이미 이진화된 이미지를 입력으로 받는다는 것이다. 즉, CCA의 성능은 대부분 “얼마나 좋은 binary mask를 만들었는가?”에 의해 결정된다.</p>
<pre><code>입력 이미지
   ↓
Grayscale 변환
   ↓
Threshold / Segmentation으로 binary mask 생성
   ↓
Morphology로 노이즈 정리
   ↓
connectedComponentsWithStats()
   ↓
stats / centroids 기반 후보 분석
   ↓
면적, 크기, 비율 조건으로 filtering
   ↓
최종 객체 후보 표시 또는 마스크 재구성</code></pre><p> 이때, 입력 이미지는 반드시 배경과 전경이 구분된 binary mask 형태여야 한다. 일반적으로 배경은 <code>0</code>, 전경은 <code>255</code> 또는 <code>1</code>로 표현한다. (배경은 0, 0이 아닌 값은 전경으로 해석)</p>
<pre><code class="language-python">binary.dtype == np.uint8
background = 0
foreground = 255</code></pre>
<p> OpenCV에서는 <code>cv.connectedComponents()</code> 또는 <code>cv.connectedComponentsWithStats()</code>를 사용해 CCA를 수행할 수 있다. <code>connectedComponents()</code>는 각 픽셀이 어떤 component에 속하는지를 나타내는 label map을 반환하고, <code>connectedComponentsWithStats()</code>는 여기에 더해 각 component의 위치, 크기, 면적, 중심 좌표까지 함께 반환한다. 이때, num_labels는 라벨의 총 개수이다. 이 라벨의 총 개수에는 배경도 label 하나로 포함된다. 그래서 실제 객체 개수는 보통 <strong>실제 객체 개수 = num_labels - 1</strong>로 계산할 수 있다.</p>
<pre><code>connectedComponents()
→ 객체 개수와 label map만 필요할 때 사용

connectedComponentsWithStats()
→ 객체 개수, label map뿐만 아니라
   각 객체의 위치, 크기, 면적, 중심 좌표까지 필요할 때 사용</code></pre><pre><code class="language-python">num_labels, labels = cv.connectedComponents(binary, connectivity=8)
num_labels, labels, stats, centroids = cv.connectedComponentsWithStats(binary)</code></pre>
<pre><code>num_labels
→ 전체 라벨 개수
→ 배경 label 0도 포함됨
→ 실제 객체 수는 보통 num_labels - 1

labels
→ 입력 이미지와 같은 크기의 라벨 맵
→ 각 픽셀이 어떤 component에 속하는지 저장

stats
→ 각 component의 bounding box와 area 정보
→ x, y, width, height, area 포함

centroids
→ 각 component의 중심 좌표
→ (cx, cy) 형태</code></pre><table>
<thead>
<tr>
<th>정보</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>객체 개수</td>
<td>연결된 component의 개수</td>
</tr>
<tr>
<td>label map</td>
<td>각 픽셀이 어느 객체에 속하는지 나타낸 라벨 이미지</td>
</tr>
<tr>
<td>bounding box</td>
<td>객체를 감싸는 사각형의 위치와 크기</td>
</tr>
<tr>
<td>area</td>
<td>객체를 구성하는 픽셀 수</td>
</tr>
<tr>
<td>centroid</td>
<td>객체의 중심 좌표</td>
</tr>
<tr>
<td>x, y</td>
<td>bounding box의 왼쪽 위 좌표</td>
</tr>
<tr>
<td>width, height</td>
<td>bounding box의 너비와 높이</td>
</tr>
</tbody></table>
<h2 id="connectivity"><code>connectivity</code></h2>
<p> CCA에서 중요한 개념 중 하나는 connectivity이다. Connectivity는 어떤 픽셀들을 서로 연결된 것으로 볼지 정하는 기준이다. <code>4-connectivity</code>는 한 픽셀의 상, 하, 좌, 우 방향만 연결된 이웃으로 본다. 따라서 대각선으로만 맞닿아 있는 픽셀들은 서로 다른 객체로 판단될 수 있다. 반면 <code>8-connectivity</code>는 상, 하, 좌, 우뿐만 아니라 대각선 방향까지 연결된 이웃으로 본다. 따라서 대각선으로 맞닿은 픽셀도 같은 객체로 판단된다.</p>
<blockquote>
<p><code>4-connectivity</code>과 <code>8-connectivity</code> 는 단순한 정의 차이가 아닌, 대각선으로 맞닿아 있는 픽셀 묶음을 <strong>서로 다른 객체로 분리할지, 아니면 하나의 객체로 합칠지를 결정하는 기준</strong>이 된다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/2689ebd2-c009-4a1e-b273-95c1ca81af71/image.png" alt=""></p>
<p> 이를 좌표를 기준으로 정의하면 다음과 같다. 기준 픽셀 P=(x, y)가 있을 때, 다음과 같이 정리할 수 있다. 즉, 8방향 연결성은 4방향 연결성에 대각선 네 방향을 추가한 개념이다. OpenCV의 <code>connectedComponents()</code>와 <code>connectedComponentsWithStats()</code>는 바로 이 기준에 따라 픽셀들을 같은 component로 묶는다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/72886b0c-4325-4722-8bf4-a3594ccd4e6f/image.png" alt=""></p>
<pre><code>N4(P) = {(x-1,y), (x+1,y), (x,y-1), (x,y+1)}
N8(P) = N4(P) ∪ {(x-1,y-1), (x+1,y-1), (x-1,y+1), (x+1,y+1)}</code></pre><p> 또, <code>connectedComponents()</code>와 <code>connectedComponentsWithStats()</code>는 <code>connectivity</code> 인자로 4-way 또는 8-way connectivity를 선택한다. 이때, <code>connectedComponentsWithStats()</code> 는 label 0을 배경으로 포함하므로 실제 전경 객체 수는 일반적으로 num_labels - 1 로 해석한다.</p>
<pre><code>num_labels, labels, stats, centroids = cv.connectedComponentsWithStats(
    binary,
    connectivity=8,   # 또는 4
    ltype=cv.CV_32S
)</code></pre><p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/fbf33c04-fc1a-409e-a39c-abdb5fe73ebb/image.png" alt=""></p>
<p><strong>실무 상황에서의 적용</strong></p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/8d35e8e4-74ae-4d26-8f1c-d99bfcda9a50/image.png" alt=""></p>
<p> 정리하면, <code>4-connectivity</code>는 대각선 접촉을 연결로 보지 않기 때문에 객체를 더 엄격하게 분리한다. 반대로 <code>8-connectivity</code>는 대각선 접촉도 연결로 보기 때문에 더 넓은 범위의 픽셀을 하나의 객체로 묶을 수 있다. 실제 적용에서는 연결성만 단독으로 보지 말고, 전처리 결과와 객체의 형태적 특성을 함께 살펴야 한다. 문서 스캔, OCR, 세포/입자 분석처럼 “살짝 대각선으로 붙은 픽셀을 같은 객체로 볼 것인가?”가 중요한 문제에서는 connectivity 선택이 결과에 직접적인 영향을 줄 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Edge Detection (에지 검출)]]></title>
            <link>https://velog.io/@inhwaaa_v/Edge-Detection-%EC%97%90%EC%A7%80-%EA%B2%80%EC%B6%9C</link>
            <guid>https://velog.io/@inhwaaa_v/Edge-Detection-%EC%97%90%EC%A7%80-%EA%B2%80%EC%B6%9C</guid>
            <pubDate>Wed, 17 Jun 2026 21:18:21 GMT</pubDate>
            <description><![CDATA[<h1 id="edge-detection">Edge Detection</h1>
<h2 id="에지-edge">에지 (Edge)</h2>
<p> 에지란, 이미지에서 픽셀 값(밝기나 색 등)이 급격히 변하는 지점을 의미한다. 예를 들어 물체의 테두리나 강한 명암 대비가 있는 경계 부분 등이 에지에 해당한다. 에지는 물체 인식 및 영상 분할의 기초가 되기 때문에 매우 중요한 정보이다.</p>
<h2 id="1차-미분gradient과-에지-정의">1차 미분(Gradient)과 에지 정의</h2>
<p> 영상에서 에지를 찾을 때 자주 사용하는 방법은 미분 연산이다.  Edge를 픽셀의 밝기가 급격히 변화하는 부분으로 가정하면, 미분(derivative)과 기울기(gradient) 연산을 수행해 픽셀의 밝기 변화율이 높은 부분을 에지로 볼 수 있다. 1차 미분은 픽셀의 변화율(Gradient)을 측정하고, 이 변화가 큰 지점을 에지로 본다.</p>
<ul>
<li><p>Edge가 발생하는 구간에선 이미지의 Intensity가 변화한다.</p>
</li>
<li><p>도함수의 기울기는 Intensity 변화량을 의미하므로 이를 통해 Edge를 검출할 수 있다.</p>
<p>이를 1차원 그래프로 생각하면 더 이해하기 쉽다. 밝기 함수 f(x)에서 상승 에지나 하강 에지가 발생하는 부분은 intensity가 급격히 변하는 구간이다. 이 구간에 대해 1차 미분을 하면 변화량이 크게 나타나고, 미분값의 극값이 발생한다. 따라서 1차 미분에서 큰 값이나 극값이 나타나는 지점은 원본 이미지에서 에지가 존재하는 위치라고 볼 수 있다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/2900d867-e933-4932-a619-6b2dbbf10563/image.png" alt=""></p>
<p> 2차원 영상에서는 밝기 함수 f에 대해 x방향 변화량과 y방향 변화량을 각각 구한다. 이때 x방향 미분값을 $G_x = \frac{∂f}{∂x}$, y 방향 미분값을 $G_y = \frac{∂f}{∂y}$라고 한다. 이후 두 방향의 변화량을 합쳐 그래디언트의 크기 $M = \sqrt{G_x^2 + G_y^2}$를 계산한다. M이 크다는 것은 해당 픽셀 주변에서 밝기 변화가 크다는 뜻이므로, M이 큰 지점을 에지로 판단할 수 있다. 즉, gradient 크기  $M = \sqrt{G_x^2 + G_y^2}$가 어느 임계값보다 큰 지점을 에지로 간주한다. 예를 들어 1차원으로 놓고 본다면, 밝기가 갑자기 10에서 100으로 바뀌는 구간 근처에서 미분값이 크다. 2D 이미지에서는 Sobel, Prewitt, Roberts 등 마스크로 미분을 근사해 $G_x$, $G_y$를 계산한 뒤 에지 여부를 판단한다.</p>
<p> 정리하면, 에지 검출은 <strong>밝기 변화가 큰 부분을 찾는 과정</strong>이고, 이를 위해 x방향과 y방향의 미분값을 계산한 뒤 그래디언트 크기를 이용해 에지 여부를 판단한다.</p>
<h3 id="1차-미분-연산자-예시">1차 미분 연산자 예시</h3>
<h4 id="roberts-마스크">Roberts 마스크</h4>
<p> Roberts Filter는 가장 간단한 2*2 형태의 마스크로, 기준점이 되는 위치의 왼쪽 위에서 기준점의 위치를 빼거나, 오른쪽 위에서 기준점의 위치를 빼도록 설계했다. (즉, 대각선 방향으로 차분을 취한다) 이는 구현이 간단하고, 다른 마스크보다 크기는 작지만 효과적으로 사용할 수 있다는 장점이 있으나 노이즈에 민감하다는 단점이 존재한다.</p>
<p><strong>Roberts X 방향 마스크 (2×2)</strong></p>
<p>$$
\begin{bmatrix}
+1 &amp; 0 \
0 &amp; -1
\end{bmatrix}
$$</p>
<p><strong>Roberts Y 방향 마스크 (2×2)</strong></p>
<p>$$
\begin{bmatrix}
0 &amp; +1 \
-1 &amp; 0
\end{bmatrix}
$$</p>
<h4 id="prewitt-마스크">Prewitt 마스크</h4>
<p> Prewitt은 중앙값이 되는 위치를 기준으로 좌우 또는 상하 픽셀의 값 차이를 계산하도록 설계했다. Prewitt Filter는 Roberts Filter보다 고려하는 픽셀들이 더 많은데, 그렇기 때문에 노이즈에 더 강하다는 장점이 있다. 또, Prewitt은 Sobel과 유사하지만 가중치 없이 단순한 형태를 지니고 있다.</p>
<p><strong>Prewitt X 방향 마스크 (3×3)</strong></p>
<p>$$
\begin{bmatrix}
-1 &amp; 0 &amp; +1 \
-1 &amp; 0 &amp; +1 \ 
-1 &amp; 0 &amp; +1
\end{bmatrix}
$$</p>
<p><strong>Prewitt Y 방향 마스크 (3×3)</strong></p>
<p>$$
\begin{bmatrix}
-1 &amp; -1 &amp; -1 \
0 &amp; 0 &amp; 0 \
+1 &amp; +1 &amp; +1
\end{bmatrix}
$$</p>
<h4 id="sobel-마스크">Sobel 마스크</h4>
<p> Sobel 마스크는 가장 널리 쓰이는 1차 미분 연산자 중 하나다. 가중치가 있는 3×3 마스크로 X방향과 Y방향 차분을 구한다.  이러한 Sobel Filter는 Prewitt Filter와 유사하지만, 중심 픽셀에 가까운 점에서 영향력을 더 높게 고려하도록 설계된 필터이다. 쉽게 말해, 중앙점으로부터 가까울수록 가중치를 더 높게 준다. 가우시안+미분의 형태라고 이해하면 된다.</p>
<p><strong>Sobel X 방향 마스크 (3×3) (세로 방향 경계, vertical edge)</strong></p>
<p>$$
\begin{bmatrix}
-1 &amp; 0 &amp; +1 \
-2 &amp; 0 &amp; +2 \
-1 &amp; 0 &amp; +1
\end{bmatrix}
$$</p>
<p><strong>Sobel Y 방향 마스크 (3×3) (가로 방향 경계, horizontal edge)</strong></p>
<p>$$
\begin{bmatrix}
-1 &amp; -2 &amp; -1 \
0 &amp; 0 &amp; +2 \
+1 &amp; +2 &amp; +1
\end{bmatrix}
$$</p>
<h2 id="2차-미분과-zero-crossing">2차 미분과 Zero Crossing</h2>
<h3 id="2차-미분을-활용한-edge-detection">2차 미분을 활용한 Edge Detection</h3>
<p> 에지를 검출하는 방법에는 1차 미분뿐만 아니라 2차 미분을 사용하는 방법도 있다. 1차 미분이 픽셀 밝기의 변화율을 구하는 것이라면, 2차 미분은 1차 미분 결과를 한 번 더 미분하여 변화율이 다시 어떻게 변하는지 확인하는 방법이다. 2차 미분을 활용한 대표적인 필터가 <strong>Laplacian Filter</strong>이다.</p>
<p> Laplacian은 2차 미분의 합으로 정의된다. 이미지에서는 밝기값을 함수 f라고 볼 수 있는데, x방향으로 두 번 미분한 값과 y방향으로 두 번 미분한 값을 더한 것이 Laplacian이다. 즉, Laplacian Filter는 영상에서 밝기 함수 <code>f</code>에 대해 x방향과 y방향의 2차 미분을 더한 형태로 표현할 수 있다.</p>
<p>$$
\nabla^2 f = \frac{\partial^2 f}{\partial x^2} + \frac{\partial^2 f}{\partial y^2}
$$</p>
<p> 1차 미분은 밝기가 얼마나 변하는지를 보고, 2차 미분은 그 밝기 변화량이 다시 어떻게 변하는가를 본다. 그래서 에지가 있는 부분처럼 밝기가 갑자기 변하는 곳에서는 2차 미분값도 특징적으로 변하게 된다. 이때 중요한 것이 Zero Crossing이다. 이는 2차 미분이 0이 되는 지점(부호가 바뀌는 지점)을 의미하며, 이 지점에서 에지가 나타난다.</p>
<h3 id="zero-crossing">Zero Crossing</h3>
<p> 2차 미분을 이용한 에지 검출에서는 <strong>Zero Crossing</strong>을 중요하게 본다. Zero Crossing이란 2차 미분 결과의 값이 <strong>양수에서 음수로</strong>, 또는 <strong>음수에서 양수로 바뀌는 지점</strong>을 의미한다.</p>
<p> 에지가 있는 부분에서는 intensity가 급격히 변한다. 이때 1차 미분에서는 큰 값이나 극값이 나타나고, 2차 미분에서는 부호가 바뀌는 지점이 발생한다. 따라서 2차 미분 결과에서 부호가 바뀌는 Zero Crossing 지점을 에지로 판단할 수 있다. 즉, 1차 미분은 gradient 값이 큰 지점을 에지로 보고, 2차 미분은 부호가 바뀌는 지점, 즉 Zero Crossing을 에지로 본다.</p>
<blockquote>
<ul>
<li>peak : 이미지 처리에서 1차 미분은 이미지의 밝기 변화(기울기)를 나타내며, 이때 기울기가 극대값에 도달하는 지점(밝기 변화가 큰 지점, 즉 Edge)을 peak라고 한다.</li>
<li>zero crossing : 2차 미분은 이미지의 곡률을 나타내며, 이때 미분값이 양수 &lt;-&gt; 음수로 변화하는 구간을 zero crossing라고 한다. zero crossing이 발생하는 구간 사이 0이 되는 지점이 Edge이므로, 이를 통해 Edge의 위치를 식별할 수 있다.</li>
</ul>
</blockquote>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/e31f676b-5a72-43ea-8061-1d92ee9ac760/image.png" alt=""></p>
<h3 id="laplacian-라플라시안">Laplacian (라플라시안)</h3>
<p> Laplacian을 마스크로 정의하면 중심 픽셀과 주변 픽셀의 차이를 계산하는 구조가 된다. 대표적인 Laplacian 마스크는 다음과 같다.</p>
<p>$$
\begin{bmatrix}
0 &amp; 1 &amp; 0 \
1 &amp; -4 &amp; 1 \
0 &amp; 1 &amp; 0
\end{bmatrix}
$$</p>
<p> 또는 대각선 방향까지 포함하여 다음과 같이 표현하기도 한다.</p>
<p>$$
\begin{bmatrix}
1 &amp; 1 &amp; 1 \
1 &amp; -8 &amp; 1 \
1 &amp; 1 &amp; 1
\end{bmatrix}
$$</p>
<p> 이 필터는 좌우상하 방향이 같은 구조를 가지므로 방향에 크게 의존하지 않는 <strong>동형 필터</strong>, 즉 <strong>isotropic filter</strong>라고도 한다.</p>
<h3 id="log-laplacian-of-gaussian">LoG, Laplacian of Gaussian</h3>
<p> Laplacian Filter는 중심 픽셀과 주변 픽셀의 차이를 계산하기 때문에 밝기 변화가 큰 부분을 잘 강조할 수 있다. 그래서 Sobel이나 Prewitt 같은 1차 미분 필터보다 에지의 변화가 더 뚜렷하게 나타나는 경우가 있다.</p>
<p> 하지만 Laplacian Filter는 2차 미분을 사용하기 때문에 노이즈에 매우 민감하다. 작은 노이즈도 밝기 변화로 인식되어 함께 강조될 수 있기 때문에, 원하지 않는 부분까지 에지처럼 검출될 수 있다. 그래서 Laplacian Filter의 노이즈 민감성을 줄이기 위해 보통 라플라시안을 적용하기 전에 Gaussian Blur를 먼저 적용한다. Gaussian Blur는 영상의 노이즈를 부드럽게 줄여주는 역할을 한다.</p>
<p> 이처럼 먼저 가우시안 필터로 노이즈를 제거한 뒤, 라플라시안 필터를 적용하는 방법을 <strong>LoG, Laplacian of Gaussian</strong>이라고 한다.</p>
<p>과정은 다음과 같다.</p>
<ol>
<li>Gaussian Filter로 영상을 부드럽게 만든다.</li>
<li>Laplacian Filter로 2차 미분을 계산한다.</li>
<li>2차 미분 결과에서 Zero Crossing이 발생하는 지점을 찾는다.</li>
<li>해당 지점을 에지로 판단한다.</li>
</ol>
<p>$$
LoG(x, y) = ∇^2(G_σ(x, y) * f(x, y)) = (∇^2G_σ) * f(x, y)
$$</p>
<h3 id="dog-difference-of-gaussian">DoG (Difference of Gaussian)</h3>
<p> 서로 다른 표준편차 $σ_1$, $σ_2$를 가진 두 가우시안 필터의 결과를 빼면 LoG를 근사할 수 있다. 다시 말해, 서로 다른 표준편차를 가진 두 가우시안 필터를 이미지에 각각 적용한 뒤, 두 결과를 빼는 방법이 DoG이다. 예를 들어 하나는 조금 흐리게 만든 이미지이고, 다른 하나는 더 많이 흐리게 만든 이미지라고 할 때, 이 두 이미지를 빼면 단순히 부드러운 영역은 사라지고, 밝기 변화가 있는 경계 부분이 남게 된다.</p>
<p>$$
DoG(x,y;σ_1,σ_2)=Gσ_1(x,y)∗f(x,y)−Gσ_2(x,y)∗f(x,y)
$$</p>
<p> 이때 중요한 점은 DoG(Difference of Gaussian)가 LoG(Laplacian of Gaussian)를 근사할 수 있다는 것이다. 두 표준편차가 충분히 가깝다면, 가우시안의 2차 미분(LoG) 형태를 테일러 전개로 근사할 수 있다. 쉽게 말해, 가우시안 필터의 표준편차가 조금 변했을 때 생기는 차이가, 가우시안의 2차 미분 형태와 비슷해지기 때문이다. 그래서 DoG는 간단한 연산으로 LoG를 대신하기도 한다.</p>
<blockquote>
<p>DoG는 서로 다른 표준편차를 가진 두 가우시안 필터의 결과를 빼는 방법이다. 두 표준편차가 충분히 가까우면, 두 가우시안 함수의 차이는 테일러 전개에 의해 가우시안 함수가 표준편차 방향으로 변화하는 정도로 근사할 수 있다. 이 변화는 가우시안의 공간적 2차 미분, 즉 Laplacian of Gaussian과 관련이 있으므로 DoG는 LoG와 유사한 형태를 갖는다. 따라서 DoG는 복잡한 2차 미분 연산을 직접 수행하지 않고도, 두 가우시안 블러 결과의 차이를 이용해 LoG를 간단하게 근사할 수 있다.</p>
</blockquote>
<h2 id="canny-에지-검출-이론">Canny 에지 검출 이론</h2>
<p> Canny Edge Detection는 가장 대표적인 에지 검출 방법으로, 1차 미분을 바탕으로 에지를 검출한 뒤 추가 후처리 단계를 적용해 에지를 더 깨끗하게 추출하는 알고리즘이다. 이는 최소 오류율과 검출된 에지의 높은 위치 정확도, 실제 에지에 해당하는 곳의 얇은 에지 두께라는 기준을 충족하도록 설계한 알고리즘이다.</p>
<h3 id="canny-알고리즘의-4단계">Canny 알고리즘의 4단계</h3>
<ol>
<li><p>노이즈 제거 (Noise Reduction). 5×5 가우시안 필터.</p>
<ul>
<li>5*5 크기의 Gaussian Filter를 통해 영상에 존재하는 Noise를 제거한다.</li>
</ul>
</li>
<li><p>그라디언트 계산 (Gradient Computation). Sobel 연산자로 $G_x$, $G_y$ 계산 후, 크기와 방향을 구한다.</p>
<ul>
<li>노이즈가 제거된 영상에 대해 Edge를 검출하는 과정</li>
<li>Sobel Filter와 같은 방식으로 x 방향 필터링, y 방향 필터링을 수행하고 결과값을 합성해 에지의 방향성과 강도를 확인한다.</li>
<li>이때, 경계에서는 주변과 색이 다르기 때문에 미분값이 급속도로 변하게 되므로 이를 통해 경계값 후보군을 선별한다.</li>
</ul>
</li>
<li><p>비최대 억제 (Non-maximum Suppression). 국소 영역에서 에지 강도가 최대값이 아닌 픽셀은 제거한다.</p>
<ul>
<li><p>에지를 얇게 따내기 위해 강도가 크지 않은 부분들을 제거하는 과정</p>
</li>
<li><p>영상 픽셀을 전체 탐색하여 Edge에 해당하지 않는 픽셀 제거한다. 즉, 현재 픽셀을 다른 픽셀과 비교했을 때, 현재 픽셀이 최대값에 해당하는 값이 아니면 제거한다는 것이다.</p>
<p>  !image.png</p>
</li>
</ul>
</li>
<li><p>히스테리시스 임계값 (Hysteresis Theresholding). 두 임계값(minVal, maxVal)을 사용한다. 그라디언트가 maxVal보다 큰 픽셀은 강한 에지. minVal보다 작은 픽셀은 에지가 아님. 중간 구간은 강한 에지와 연결되어 있을 때만 에지로 채택한다.</p>
<ul>
<li>경계선으로 판단된 픽셀이 진짜 경계선이 맞는지 판별하는 과정</li>
<li>최대 임계값과 최소 임계값을 설정해 최대 임계값 이상은 강한 경계선, 최소와 최대 임계값 사이는 약한 경계선으로 설정한다.</li>
<li>약한 경계선이 진짜 경계선인지 확인하기 위해 강한 경계선에서 추적하여 연결이 되어있으면 경계선, 그렇지 않으면 제거하는 방식으로 경계선을 판별한다.</li>
</ul>
<p>결과적으로 미분(그라디언트) 기반 에지 검출을 수행한 뒤, 에지의 두께를 얇게 만들고 노이즈를 제거하는 과정을 거쳐 날카로운 에지를 얻는 것이 캐니 알고리즘이다.</p>
</li>
</ol>
<h3 id="참고--opencv의-cvcanny">참고 : <code>OpenCV의 cv.Canny</code></h3>
<pre><code>dges = cv.Canny(image, minVal, maxVal, apertureSize=3, L2gradient=False)</code></pre><ul>
<li>image : 입력 영상 (그레이스케일)</li>
<li>minVal, maxVal : 히스테리시스 임계값</li>
<li>apertureSize. Sobel : 커널 크기(기본 3)</li>
<li>L2gradient : True이면 $\sqrt{G_x^2 + G_y^2}$ 방식 사용. False이면 $|G_x| + |G_y|$</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Corner Detection & Optical Flow]]></title>
            <link>https://velog.io/@inhwaaa_v/Corner-Detection-Optical-Flow</link>
            <guid>https://velog.io/@inhwaaa_v/Corner-Detection-Optical-Flow</guid>
            <pubDate>Wed, 17 Jun 2026 21:16:03 GMT</pubDate>
            <description><![CDATA[<h1 id="corner-detection">Corner Detection</h1>
<h2 id="코너란">코너란?</h2>
<p> 코너는 단순히 모서리처럼 보이는 점이 아니라, <strong>어떤 작은 패치(patch)를 상하좌우 어느 방향으로 움직여도 원래 패치와 많이 달라지는 위치</strong>를 의미한다. 연속된 프레임에서 같은 점을 다시 찾으려면, 그 점 주변의 작은 이미지 조각이 다른 위치와 잘 구분되어야 하는데, 이를 구분하기 위해 코너를 사용한다. 코너를 사용하면 추적 가능성이 높아진다. 왜냐하면 코너는 두 방향 모두에서 밝기 변화가 크기 때문에, 프레임이 바뀌어도 해당 점의 위치를 비교적 정확하게 다시 찾을 수 있기 때문이다. 반면, 평탄한 영역은 어디로 움직여도 비슷하게 보이고, 에지는 에지 방향으로 이동하면 변화가 작아 위치가 애매해질 수 있다. 따라서 코너는 영상 추적이나 Optical Flow에서 좋은 특징점으로 사용된다.</p>
<p> 작은 패치를 $(u, v)$만큼 이동시켰을 때 원래 패치와 이동된 패치의 차이는 다음과 같다.</p>
<p>$$
E(u, v) = \sum_{x,y∈W} [ I(x + u, y + v) - I(x, y)]^2
$$</p>
<h2 id="평탄-영역flat-region-에지edge-코너corner">평탄 영역(Flat Region), 에지(Edge), 코너(Corner)</h2>
<ul>
<li><p><strong>평탄 영역</strong> : 주변이 거의 같은 밝기라서 어디로 이동해도 비슷해 보인다.</p>
<p>  → 평탄 영역은 픽셀 밝기 변화가 거의 없는 영역이다. 그래디언트의 크기가 거의 0이므로, 작은 패치를 조금 움직여도 차이를 느끼기 어렵다. 따라서 패치를 어느 방향으로 조금 이동해도 원래 패치와 거의 차이가 없다. 즉, x방향, y방향 어느 쪽으로 움직여도 변화가 작기 때문에 특징점으로 사용하기 어렵다.</p>
<p>  → <strong>E(u, v)가 거의 항상 작고, 어디로 움직여도 비슷하다.</strong></p>
</li>
<li><p><strong>에지 영역</strong> : 에지 방향을 따라 움직이면 변화가 거의 없어 위치가 모호하다.</p>
<p>  → 에지는 한 방향으로는 밝기 변화가 크지만, 변화 방향이 거의 하나로 정해져 있다. 에지를 따라가는 방향으로는 변화가 작기 때문이다. 에지와 평행한 방향으로 움직이면 패치가 비슷해 보여 위치가 모호하다. 예를 들어 세로 에지라면, 좌우로 이동하면 밝기가 크게 변하지만 위아래로 이동하면 비슷한 패치가 유지된다. 따라서 에지는 위치를 한 방향으로만 잘 구분할 수 있고, 에지 방향으로는 정확한 위치 추적이 어렵다.</p>
<p>  → <strong>어떤 방향으로는 E(u, v)가 크고, 다른 한 방향으로는 E(u, v)가 작다. 다시 말해, 특정 방향으로 움직일 때만 E(u, v)가 작아 한 방향만 모호하다</strong></p>
</li>
<li><p><strong>코너 영역</strong> : 두 방향 이상으로 밝기 변화가 있으므로 이동 위치를 비교적 명확히 찾을 수 있다.</p>
<p>  → 코너는 서로 다른 두 방향 이상에서 밝기가 함께 변한다. 패치를 x방향으로 이동해도 달라지고, y방향으로 이동해도 달라진다. 따라서 코너는 위치가 명확하고, 이미지 매칭이나 추적에 좋은 특징점이 된다.</p>
<p>  → <strong>대부분의 방향에서 E(u, v)가 커서 위치 구분이 잘 된다.</strong></p>
<p>  <img src="https://velog.velcdn.com/images/inhwaaa_v/post/4ee422c6-7262-4aed-86ae-1c87d69f8932/image.png" alt="">
S</p>
</li>
</ul>
<blockquote>
<p>코너 검출의 목표는 단순히 각진 모양을 찾는 것이 아니라, <strong>두 방향 모두에서 밝기 변화가 충분히 큰 위치를 찾는 것</strong>이다.</p>
</blockquote>
<h2 id="harris를-이해하기-위한-선형대수-기초">Harris를 이해하기 위한 선형대수 기초</h2>
<p> 패치를 어떤 방향으로 움직였을 때 얼마나 달라지는지는 이동 방향 [u, v]와 구조 텐서 M을 이용해 계산할 수 있다.</p>
<ul>
<li><p>u : 패치를 x방향으로 얼마나 움직였는가</p>
</li>
<li><p>v :  패치를 y방향으로 얼마나 움직였는가</p>
</li>
<li><p>E(u, v) : 만큼 움직였을 때 원래 패치와 얼마나 달라지는가</p>
</li>
<li><p>M : 주변 픽셀들의 밝기 변화 정보를 모아 놓은 2*2 행렬</p>
<p>  → “이 위치가 어느 방향으로 잘 구분되는가”를 요약한 표</p>
</li>
</ul>
<p>$$
E(u, v) ≈ \begin{bmatrix}
u &amp; v
\end{bmatrix}
M
\begin{bmatrix}
u \
v
\end{bmatrix}
$$</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/0bde3292-476b-4a95-bb99-7e593a00f34b/image.png" alt=""></p>
<h3 id="trace--대각선-합으로-보는-전체-변화량">Trace : 대각선 합으로 보는 전체 변화량</h3>
<p> 구조 텐서 <code>M</code>은 주변 패치의 밝기 변화 정보를 모아 둔 2×2 행렬이다. 이때, 대략적으로  a는 x방향 변화량, c는 y방향 변화량, b는 x, y 변화가 같이 나타나는 정도로 이해할 수 있다. Trace는 행렬의 대각선 성분을 더한 값으로 다음과 같이 정의할 수 있다. 즉, x방향 밝기 변화와 y방향 밝기 변화를 더한 값이다.  그래서 trace는 <strong>패치 안에서 전체적으로 밝기 변화가 얼마나 큰지</strong>를 나타낸다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/f37a180b-5f85-45a7-bf97-6a6ea1474d23/image.png" alt=""></p>
<p>$$
trace(M) = a + c = ΣwIx² + ΣwIy²
$$</p>
<p> 하지만 trace만으로는 코너를 판단하기 어렵다. Trace는 전체 변화량만 보기 때문이다. 그래서 밝기 변화가 <strong>한 방향에만 몰려 있는지</strong>, 아니면 <strong>두 방향에 골고루 있는지</strong>는 잘 구분하지 못한다. 예를 들어 다음의 경우는 trace가 모두 비슷할 수 있다.</p>
<ul>
<li><p>에지 : 한 방향 변화는 매우 크고, 다른 방향 변화는 거의 없음</p>
<p>  $$
  (λ_1 , λ_2 ) ≈ (20, 0.2)
  $$</p>
</li>
<li><p>코너 : 두 방향 변화가 모두 어느 정도 큼</p>
<p>  $$
  (λ_1 , λ_2 ) ≈ (10, 10)
  $$</p>
</li>
</ul>
<p> 따라서 trace는 밝기 변화가 전체적으로 있는지는 알려 주지만, 그 변화가 한 방향에만 몰려 있는지, 아니면 두 방향에 고르게 존재하는지는 충분히 알려 주지 못한다.</p>
<blockquote>
<p><strong>trace는 벌점 항, 전체 변화량에 대한 벌점이다.</strong> 특히 에지처럼 한 고유값만 매우 큰 경우에는 trace가 커지지만 determinant는 충분히 커지지 않는다. 그래서 Harris 응답이 음수가 되기 쉽다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/6ac6f790-9250-497a-82da-d1d6995e01ff/image.png" alt=""></p>
</blockquote>
<h3 id="determinant--두-방향-정보가-함께-있는지-보는-값">Determinant : 두 방향 정보가 함께 있는지 보는 값</h3>
<p> Determinant는 2*2 행렬에서 다음과 같이 계산한다. 행렬식은 기하학적으로는 면적이 얼마나 유지되거나 커지는지와 관련된다. 어떤 행렬이 평면 위의 작은 정사각형을 납작한 선처럼 찌그러뜨리면 면적은 거의 0이 된다. 이때 determinant도 거의 0이다</p>
<p>$$
det(M) = ac - b²
$$</p>
<p> 구조 텐서에서 이 의미는 매우 중요하다. 변화가 한 방향에만 있으면 정보가 거의 1차원이다. 이 경우 determinant가 작다. 서로 다른 두 방향에서 변화가 있으면 정보가 2차원으로 퍼진다. 이 경우 determinant가 커진다.</p>
<blockquote>
<p>구조 텐서(Structure Tensor) : 이미지 패치 안에서 밝기 변화가 어느 방향으로 얼마나 강하게 일어나는지를 나타내는 행렬. 구조 텐서는 패치 내부의 gradient 정보를 모아서, 해당 영역이 평탄 영역인지, 에지인지, 코너인지를 판단하는 데 사용된다.</p>
</blockquote>
<p> 고유값으로 생각하면 determinant는 다음과 같고, 구조 텐서의 두 고유값 <code>λ1</code>, <code>λ2</code>는 서로 직교하는 두 주요 방향에서의 밝기 변화량을 의미한다. 이때, 둘 중 하나라도 작으면 곱도 작아지고, 두 값이 모두 크면 곱도 커진다. 즉, 두 고유값이 모두 크다는 것은 어느 방향으로 패치를 움직여도 밝기 변화가 크다는 뜻이므로 코너로 볼 수 있다.</p>
<p>$$
det(M) = \lambda_1 \lambda_2
$$</p>
<p> 이것이 코너 검출에서 determinant가 중요한 이유이다. 코너는 한 방향만 강한 점이 아니라, 두 방향에서 모두 구분 가능한 점이어야 한다.</p>
<blockquote>
<p><strong>determinant는 코너 후보를 강하게 만드는 항</strong></p>
</blockquote>
<h3 id="eigenvalue--방향별-밝기-변화-강도">Eigenvalue : 방향별 밝기 변화 강도</h3>
<p> 구조 텐서의 고유값은 “서로 직교하는 두 주요 방향에서 밝기 변화가 얼마나 강한가”를 나타낸다. 행렬 M에 어떤 방향 벡터 를 곱했을 때, 그 벡터의 방향은 그대로이고 길이만 바뀌는 경우가 있다.</p>
<p>$$
Mq = \lambda q
$$</p>
<p> 이때, q는 고유 벡터(특별한 방향) $\lambda$는 고유값(그 방향으로 얼마나 강하게 늘어나는가를 의미한다. 구조텐서에서는 이를 다음과 같이 해석한다. 여기서 중요한 점은 “고유값이 크다”는 말이 단순히 픽셀 값이 밝다는 뜻이 아니라는 것이다. 그 방향으로 조금 움직였을 때 패치 차이가 크게 변한다는 뜻이다.</p>
<ul>
<li>$λ_1$, $λ_2$ 모두 작다 → 평탄 영역</li>
<li>$λ_1$은 크고 $λ_2$는 작다 → 에지</li>
<li>$λ_1$, $λ_2$ 모두 크다 → 코너</li>
</ul>
<h3 id="왜-harris는-고유값을-직접-쓰지-않고-trace와-determinant를-쓸까">왜 Harris는 고유값을 직접 쓰지 않고 trace와 determinant를 쓸까?</h3>
<p> Harris Corner Detector의 응답 함수는 다음과 같다.  여기서 보통 k는 0.04 근처 값을 많이 사용한다. k가 너무 작으면 에지를 코너처럼 남길 수 있고, 너무 크면 실제 코너도 약하게 평가될 수 있다.</p>
<p>$$
R = det(M) - k · trace(M)²
$$</p>
<p> 이를 고유값으로 표현하면 다음과 같다.</p>
<p>$$
R = λ_1λ_2 - k(λ_1 + λ_2)²
$$</p>
<p> 이때, <code>det(M)</code>은 두 고유값의 곱이다. 두 고유값이 모두 클 때 determinant 값이 커진다. 즉, x방향과 y방향 모두에서 밝기 변화가 큰 경우에 큰 값을 가지므로 코너 후보를 강조하는 역할을 한다.</p>
<p> <code>trace(M)</code>은 두 고유값의 합이다. trace 항은 전체 gradient 크기를 나타내며, <code>k · trace(M)²</code> 형태로 빼주기 때문에 에지처럼 한쪽 방향 변화만 큰 경우를 억제하는 역할을 한다. 에지는 한 고유값은 크고 다른 고유값은 작기 때문에 determinant는 상대적으로 작지만 trace는 커질 수 있다. 따라서 trace 항을 빼면 에지의 응답값이 낮아진다.</p>
<blockquote>
<p>Harris는 고유값을 직접 계산하지 않아도, trace, determinant 관계를 이용해서 두 방향 변화가 모두 큰 코너인지 판단할 수 있기 때문에 trace와 determinant를 사용한다. 이때, <strong>좋은 코너는 trace가 어느 정도 크면서, determinant도 충분히 큰 위치</strong>다. <strong>trace만 크면에지일 수 있지만, determinant가 크려면 두 고유값이 모두 커야 하므로 코너일 가능성이 높</strong>다. ****</p>
<ul>
<li><p>determinant 항 → 두 방향 변화가 모두 큰 코너를 강조</p>
</li>
<li><p>trace 항 → 전체 변화량. 한 방향 변화만 큰 에지를 억제</p>
<p> <img src="https://velog.velcdn.com/images/inhwaaa_v/post/1fc2dab6-7e47-4ca7-b188-89ec62e7ee45/image.png" alt=""></p>
</li>
</ul>
</blockquote>
<h2 id="harris-corner-detection">Harris Corner Detection</h2>
<p> Harris와 Stephens가 제안한 Harris Corner Detector의 핵심은 패치를 조금 움직였을 때 밝기 변화가 모든 방향에서 큰 위치를 코너로 본다는 것이다. 이때, W는 작은 윈도우로, w(x, y)는 윈도우 내부 픽셀의 가중치이다. 일반적으로 중심 픽셀에 더 높은 가중치를 주기 위해 Gaussian 가중치를 사용할 수 있다.</p>
<p>$$
E(u, v) = \sum_{x,y∈W} w(x, y) [ I(x + u, y + v) - I(x, y)]^2
$$</p>
<p> 위의 비용 함수는 패치를 (u, v)만큼 움직였을 때 얼마나 달라지는가를 측정한다. 그런데 모든 (u, v)에 대해 직접 이미지를 이동해 비교하면 계산량이 많아진다. 그래서 움직임이 아주 작다고 보고, 밝기 변화를 미분으로 근사한다. 그 결과는 다음의 행렬식으로 쓸 수 있고, 여기서 M은 구조 텐서 또는 second moment matrix라고 부른다. 즉, 구조 텐서는 주변 윈도우 안의 미분 정보를 모아서 이 위치가 어느 방향으로 얼마나 잘 구분되는지를 요약한 행렬이다. 또, 구조 텐서는 2*2의 대칭 행렬인데, 이런 대칭 행렬은 항상 서로 직교하는 두 방향의 고유 벡터와 그 방향에 대응하는 고유값을 가진다.</p>
<p>$$
E(u, v) ≈ \begin{bmatrix}
u &amp; v
\end{bmatrix}
M
\begin{bmatrix}
u \
v
\end{bmatrix}
$$</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/65ebc105-c8ce-4899-bcf8-f2582317295d/image.png" alt=""></p>
<p> 이동 비용을 고유 벡터 방향으로 바라보면 다음처럼 단순해진다. 이때 $\alpha, \beta$는 원래 x, y축이 아니라 구조 텐서가 찾아낸 두 주요 방향으로의 이동량이다.</p>
<p>$$
E(u, v) ≈ \lambda_1\alpha^2 + \lambda_2 \beta^2
$$</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/b12b7757-e8e8-440b-a2b7-3ebff6eca265/image.png" alt=""></p>
<h2 id="harris-알고리즘-절차">Harris 알고리즘 절차</h2>
<ol>
<li>입력 영상을 grayscale로 변환한다.</li>
<li>Sobel 등으로 $I_x, I_y$ 를 계산한다.</li>
<li>$I_x^2, I_y^2, I_xI_y$를 계산한다.</li>
<li>작은 윈도우 안에서 위 값들을 합산하거나 Gaussian smoothing한다.</li>
<li>각 픽셀에서 구조 텐서 M을 만들고 Harris 응답 R을 계산한다.</li>
<li>응답값이 큰 위치만 thresholding한다.</li>
<li>주변 픽셀보다 응답이 큰 지점만 남기는 non-maximum suppression을 적용한다</li>
</ol>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/45ed53ea-4ff4-40b7-8980-92de6330ec4a/image.png" alt=""></p>
<p>실제 응용에서는 보통 다음 처리가 추가된다.</p>
<ul>
<li>Non-maximum suppression : 주변에서 가장 강한 응답만 남긴다.</li>
<li>Sub-pixel refinement : 정수 픽셀 단위보다 더 정밀한 위치로 보정한다.<ul>
<li>코너는 처음에는 정수 픽셀 위치로 검출된다. 하지만 카메라 보정, 정밀 측정, 추적 안정화에서는 정수 픽셀보다 더 정밀한 위치가 필요할 수 있다.</li>
<li>OpenCV의 <code>cv.cornerSubPix()</code> 는 초기 코너 위치를 주변 밝기 패턴을 이용해 sub-pixel 수준으로 보정한다. 하지만, 이는 “아무 점이나 더 정확한 코너로 바꿔주는 함수”가 아니다.</li>
<li>초기 위치가 실제 코너 근처에 있어야 제대로 수렴한다. 초기 검출이 좋지 않거나, 윈도우 안에 충분한 구조가 없으면 보정 결과도 불안정할 수 있다.</li>
</ul>
</li>
<li>ROI 제한 : 관심 영역 안에서만 코너를 찾는다.</li>
</ul>
<h2 id="shi-tomasi--good-features-to-track">Shi-Tomasi : Good Features to Track</h2>
<p> Harris는 좋은 코너를 찾지만, 응답 함수가 k에 의존하고 thresholding과 후처리가 필요하다. Shi-Tomasi 방법은 구조 텐서의 고유값을 더 직접적으로 사용한다. Shi-Tomasi의 응답값은 다음과 같다.</p>
<p>$$
R_{ST} = min(\lambda_1, \lambda_2)
$$</p>
<p> 이는 두 고유값 중 더 작은 값을 기준으로 코너 여부를 판단한다. 두 방향 중 약한 방향의 밝기 변화도 충분히 커야 좋은 특징점으로 보는 것이다. 추적 관점에서는 매우 자연스러운 기준이다. Optical Flow 추적에서는 한 점이 두 방향 모두에서 안정적으로 구분되어야 한다. 만약 한 방향으로만 변화가 크고 다른 방향 변화가 작으면, 그 방향으로는 위치 변화가 모호해 움직임을 정확히 추정하기 어렵기 때문이다.</p>
<p> Shi-Tomasi는 두 고유값 중 작은 값이 충분히 큰 점만 선택하므로, x방향과 y방향 모두에서 밝기 변화가 큰 안정적인 특징점을 고를 수 있다. 이러한 이유에서 Shi-Tomasi Corner Detector가 Harris Detector보다 추적 초기점 선정에 적합하다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/3624f9b2-ebd5-4c33-8fe6-d6aec5dcb0e7/image.png" alt=""></p>
<p> <code>cv.goodFeaturesToTrack()</code> 은 강한 코너를 좌표 목록으로 반환한다. 주요 파라미터는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/a2662df2-5a13-4cbe-9558-9ad2e8fef9a2/image.png" alt=""></p>
<blockquote>
<p> Good Features to Track이라는 이름은 단순히 “코너를 예쁘게 찾는다”는 뜻이 아니다. Lucas-Kanade 추적이 안정적으로 풀리려면 구조 텐서가 잘 조건화되어 있어야 하는데, 이 조건을 최소 고유값으로 판단한다는 점이 핵심이다.</p>
<p> <code>goodFeaturesToTrack()</code> 은 주변에서 가장 강한 코너를 선별하고, minDistance 보다 가까운 중복 코너를 제거한 좌표 목록을 반환한다. 그래서 추적 알고리즘의 초기점으로 바로 사용하기 좋다.</p>
</blockquote>
<h3 id="실무-파라미터-감각">실무 파라미터 감각</h3>
<ul>
<li>코너가 너무 많고 한 영역에 몰리면 minDistance 를 키운다.</li>
<li>코너가 약한 텍스처까지 잡혀 불안정하면 qualityLevel 을 키운다.</li>
<li>코너가 너무 적으면 qualityLevel 을 낮추거나 maxCorners 를 키운다.</li>
<li>큰 물체의 안정적인 추적이 목적이면 minDistance 를 조금 크게 두는 편이 좋다.</li>
<li>작은 구조를 세밀하게 추적해야 한다면 minDistance 를 줄이되, 노이즈에 주의한다.</li>
</ul>
<h1 id="optical-flow">Optical Flow</h1>
<p> 옵티컬 플로우는 연속된 두 프레임 사이에서 영상 내용이 어떻게 이동했는지를 나타내는 2차원 벡터장이다. 한 점이 첫 번째 프레임에서 $(x, y)$에 있었고, 다음 프레임에서 $(x+u, y+v)$로 이동했다면, 그 점의 optical flow는 다음 벡터다.</p>
<p>$$
\begin{bmatrix}
u \
v
\end{bmatrix}
$$</p>
<p> 옵티컬 플로우에는 두 종류가 자주 등장하며, 이번 장의 중심은 <strong>Shi-Tomasi로 좋은 점을 찾고, Lucas-Kanade로 그 점들을 추적하는 sparse optical flow</strong>다.</p>
<table>
<thead>
<tr>
<th>종류</th>
<th>의미</th>
<th>대표 함수</th>
</tr>
</thead>
<tbody><tr>
<td>Sparse optical flow</td>
<td>선택된 특징점 몇 개만 추적</td>
<td><code>cv.calcOpticalFlowPyrLK()</code></td>
</tr>
<tr>
<td>Dense optical flow</td>
<td>거의 모든 픽셀의 움직임 추정</td>
<td><code>cv.calcOpticalFlowFarneback()</code></td>
</tr>
</tbody></table>
<h2 id="밝기-보존-가정과-optical-flow-equation">밝기 보존 가정과 Optical Flow Equation</h2>
<p> 옵티컬 플로우의 기본 가정은 <strong>같은 물체의 같은 지점은 짧은 시간 동안 밝기가 크게 변하지 않는다</strong>는 것이다. 예를 들어, 시간 t에서 (x, y)에 있던 점의 밝기와 시간 t+dt에서 이동한 위치의 밝기는 같다. 이렇게 점은 움직였지만 그 점 자체의 밝기는 그대로라고 가정하는 것이 옵티컬 플로우의 기본 가정이다.</p>
<p>$$
I(x, y, t) = I(x + dx, y + dy, t + dt)
$$</p>
<p> 이 식에 대해 Taylor 전개하고 고차항을 무시한 뒤 양변을 dt로 나누면 다음과 같이 표현할 수 있다.</p>
<p>$$
u = \frac{dx}{dt}
$$</p>
<p>$$
v = \frac{dy}{dt}
$$</p>
<p>$$
I_xu + I_yv + I_t = 0
$$</p>
<p> 하지만, Optical Flow Equation에서 미지수는 u, v 두 개이고 식은 하나 뿐이다. 따라서 한 픽셀만 보고는 움직임을 완전히 결정할 수 없다. 이 문제가 에지에서 특히 심하게 나타난다. 에지 위에서는 에지와 평행한 방향의 움직임이 잘 구분되지 않는다. 이를 aperture problem이라고 부른다.</p>
<p>$$
I_xu + I_yv = -I_t
$$</p>
<p> Lucas-Kanade 방법은 이 문제를 해결하기 위해 <strong>작은 윈도우 안의 픽셀들은 모두 같은 움직임 (u, v)를 가진다</strong>고 가정한다. 그러면 윈도우 안의 여러 픽셀이 각각 식을 하나씩 제공하므로, 여러 개의 식으로 두 미지수를 최소제곱 방식으로 풀 수 있다.</p>
<h2 id="lucas-kanade-optical-flow">Lucas-Kanade Optical Flow</h2>
<p> Lucas-Kanade Optical Flow는 한 픽셀만으로는 움직임을 계산할 수 없다는 문제를 해결하기 위해, 작은 윈도우 안의 픽셀들이 모두 같은 움직임을 가진다고 가정하는 방법이다. Optical Flow Equation은 한 픽셀마다 <code>Ixu + Iyv = -It</code>라는 식을 만들지만, 이 식 하나만으로는 미지수인 <code>u</code>, <code>v</code> 두 개를 모두 구할 수 없다. 따라서 Lucas-Kanade는 한 픽셀만 보지 않고, 주변 윈도우 안의 여러 픽셀을 함께 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/74596846-5679-4159-ab8d-5520d8fd5080/image.png" alt=""></p>
<p> 윈도우 안에 여러 픽셀이 있으면 각 픽셀마다 Optical Flow Equation이 하나씩 만들어진다. 이때 모든 픽셀이 같은 움직임 <code>(u, v)</code>를 가진다고 가정하면, 여러 개의 식을 이용해 두 개의 미지수 <code>u</code>, <code>v</code>를 구할 수 있다. 하지만 실제 영상에서는 노이즈, 조명 변화, 물체의 미세한 변형 등이 존재하기 때문에 모든 식을 완벽하게 만족하는 해가 존재하지 않을 수 있다. 그래서 Lucas-Kanade는 모든 식의 오차가 가장 작아지는 <code>u</code>, <code>v</code>를 최소제곱법으로 계산한다.</p>
<p> 이 과정을 행렬로 표현하면 <code>A[u v]^T = b</code> 형태가 된다. 여기서 <code>A</code>는 윈도우 안 픽셀들의 x방향, y방향 밝기 변화량인 <code>Ix</code>, <code>Iy</code>를 모아 놓은 행렬이고, <code>b</code>는 시간에 따른 밝기 변화량 <code>-It</code>를 모아 놓은 벡터이다. 최소제곱 해는 <code>(A^T A)^-1 A^T b</code> 형태로 계산된다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/22b09018-26a0-45b4-928c-f8a25f548e94/image.png" alt=""></p>
<p> 이때 <code>A^T A</code>는 Harris나 Shi-Tomasi Corner Detector에서 사용한 구조 텐서와 같은 형태를 가진다. 즉, Lucas-Kanade에서도 주변 패치의 밝기 변화가 두 방향 모두에서 충분히 커야 움직임을 안정적으로 계산할 수 있다. 평탄한 영역은 밝기 변화가 거의 없어서 움직임을 구하기 어렵고, 에지는 한 방향으로만 변화가 크기 때문에 에지와 평행한 방향의 움직임을 구분하기 어렵다. 반면 코너는 두 방향 모두에서 밝기 변화가 크기 때문에 <code>u</code>, <code>v</code>를 안정적으로 추정할 수 있다. 따라서 Lucas-Kanade Optical Flow에서는 Harris나 Shi-Tomasi로 검출한 코너점이 좋은 추적 초기점으로 사용된다.</p>
<p> 하지만 기본 Lucas-Kanade 방법은 작은 움직임을 가정한다는 한계가 있다. 프레임 사이에서 물체가 크게 이동하면, 현재 위치 주변의 작은 윈도우만으로는 다음 프레임에서 같은 점을 찾기 어렵다. 이 문제를 해결하기 위해 이미지 피라미드를 사용한다. 이미지 피라미드는 원본 이미지를 여러 단계로 축소한 구조로, 낮은 해상도에서는 큰 움직임도 상대적으로 작게 보인다.</p>
<p> Pyramidal Lucas-Kanade 방식은 먼저 가장 낮은 해상도에서 대략적인 움직임을 추정한 뒤, 점점 높은 해상도로 올라가면서 위치를 세밀하게 보정한다. 낮은 해상도에서는 큰 움직임을 잡고, 높은 해상도에서는 정확한 위치를 조정하는 방식이다. 따라서 이미지 피라미드를 사용하면 기본 Lucas-Kanade보다 큰 움직임을 더 안정적으로 추적할 수 있다. OpenCV의 <code>cv.calcOpticalFlowPyrLK()</code>에서 <code>maxLevel</code>은 사용할 피라미드 단계 수를 조절하는 파라미터하며, <code>maxLevel=0</code>이면 원본 이미지만 사용하고, 값이 커질수록 더 많은 축소 이미지를 사용하여 큰 움직임에 대응할 수 있다. (예를 들어, maxLevel=2이면 원본 포함 3개 레벨을 사용한다.)</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/0e120bd2-a755-4965-a42d-07f817eef437/image.png" alt=""></p>
<blockquote>
<p>이미지 피라미드 방식은 원본 이미지를 여러 단계로 축소한다. 이렇게 하면 가장 작은 이미지에서는 큰 움직임도 상대적으로 작게 보이므로 거친 단계에서 이동을 먼저 추정하고, 점점 원본 해상도로 내려오며 보정할 수 있다.</p>
</blockquote>
<h2 id="sparse-optical-flow와-dense-optical-flow">Sparse Optical Flow와 Dense Optical Flow</h2>
<ul>
<li>Dense Optical Flow의 대표적인 예시인 Farneback 방법은 각 픽셀 주변을 다항식으로 근사하고, 두 프레임 사이의 변화를 이용해 픽셀 단위 flow를 추정한다. 결과는 (height, width, 2) 형태의 배열이며, 마지막 차원의 값은 각각 x방향 이동량과 y방향 이동량이다.</li>
<li>Dense flow는 직관적인 시각화에 좋지만, sparse LK보다 계산량이 크고 파라미터에 따라 결과가 많이 달라질 수 있다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/5b0e3c22-8361-47df-9135-64de4af38f5e/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[YOLO]]></title>
            <link>https://velog.io/@inhwaaa_v/YOLO</link>
            <guid>https://velog.io/@inhwaaa_v/YOLO</guid>
            <pubDate>Wed, 17 Jun 2026 20:53:00 GMT</pubDate>
            <description><![CDATA[<h1 id="1-문제-정의--객체-탐지는-무엇을-푸는-문제인가">1. 문제 정의 : 객체 탐지는 무엇을 푸는 문제인가?</h1>
<ul>
<li>객체 탐지가 분류(classification)이나 분할(segmentation)과 어떻게 다른지 알아보자!</li>
</ul>
<h2 id="11-분류-위치-추정-객체-탐지-분할의-차이">1.1 분류, 위치 추정, 객체 탐지, 분할의 차이</h2>
<ul>
<li><p><strong>Image Classification</strong> : 이미지 전체에 대해 하나의 라벨을 예측하는 것
  (ex) 이 사진은 고양이인가?</p>
</li>
<li><p><strong>Localization</strong> : 이미지에 하나의 주요 객체가 있다고 가정하고, 라벨과 박스를 예측함.
  <strong>→ Single Object를 가정</strong>하므로 하나의 주된 객체에 대해 라벨과 박스를 함께 예측한다.
  (ex) 고양이는 어디에 있는가? / 암 분류 문제에서 <strong>관심 가는 클래스가 하나</strong> 뿐인 경우</p>
</li>
<li><p><strong>Object Detection</strong> : 이미지 안의 <strong>여러 객체</strong>에 대해 각 객체의 <strong>클래스와 위치(박스)</strong>를 동시에 예측하므로 여러 객체의 클래스, 위치, 개수를 동시에 다룬다는 특징이 있다.
  → 출력이 단순한 클래스 하나가 아니라, ${(b_i, c_i, s_i)}^N_{i=1}$의 형태임.
  → 이때, $b_i$는 bounding box, $c_i$는 class probability, $s_i$는 confidence score를 의미함.
  → <strong>bounding box</strong>는 <strong>하나의 객체 전체를 포함하는 가장 작은 직사각형</strong>을 의미함.</p>
<p>  → confidence score는 bounding box에 대한 신뢰도를 의미함. (bounding box에 객체가 있는지 없는지 유무에 관한 확률)</p>
<p>  → 객체 탐지는 <strong>“무엇이 있는가?”</strong>와 <strong>“어디에 있는가?”</strong>를 동시에 푸는 문제이기에 분류보다 출력 구조가 훨씬 복잡한 경향을 지닌다. 단순히 Bounding box만 맞으면 Detection이 아니라, <strong>클래스와 score도 적절해야 한</strong>다.</p>
<p>  → <strong>물체가 하나만 있는 이미지면 Detection과 Localization의 출력 형태가 비슷할 수도 있</strong>지만, 일반적인 객체 탐지는 다중 객체를 전제로 한다는 점에서 차이가 있다. 즉, <strong>Localization = 라벨 + 박스, 객체 1개 가정 / Object Detection = 라벨 + 박스, 객체 여러 개 가정 이라는 차이</strong>가 있다. 따라서 Detection은 여러 객체를 다루는 일반적인 문제이고, Localization은 이를 <strong>단일 객체(single object)</strong> 상황으로 제한한 형태로 볼 수 있다. 또, Localization과는 다르게 Detection은 박스와 라벨 뿐만 아니라 클래스 결정, 중복 제거, confidence score 해석까지 포함하는 보다 복합적인 문제이다.</p>
<p>  (ex) 사진 안에 사람 몇 명이 있고, 각각 어디에 있는가?</p>
</li>
<li><p><strong>Segmentation</strong> : 객체의 윤곽이나 픽셀 단위 마스크까지 예측함.</p>
<p>  → 객체의 외곽 또는 픽셀 수준 마스크까지 다루므로 더 세밀한 출력을 요구한다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/5d966a09-fbf2-4858-9f77-c37e34b115d9/image.png" alt=""></p>
<h2 id="12-객체-탐지의-일반적-입력과-출력">1.2 객체 탐지의 일반적 입력과 출력</h2>
<p> <strong>입력</strong>은 보통 <strong>H×W×3 이미지</strong>이고, <strong>출력은 여러 예측 박스</strong>이며, 각 박스는 보통 다음과 같은 정보를 지닌다.</p>
<ul>
<li>x, y : 박스 중심 좌표</li>
<li>w, h : 박스 너비와 높이</li>
<li>c : 클래스 라벨</li>
<li>s : 신뢰도 (score)</li>
</ul>
<p>$$
b = (x, y, w, h), c, s
$$</p>
<h1 id="2-one-stage-detection과-two-stage-detector-비교">2. One-stage Detection과 Two-stage Detector 비교</h1>
<p> Object Detection은 <strong>Proposal-based model(Two-stage model)</strong>과 <strong>Proposal-free model(One-stage model)</strong>로 구분된다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/b42c73d9-4da2-4fc0-9904-6f7ab47556c0/image.png" alt=""></p>
<h2 id="21-two-stage-detector">2.1 Two-stage Detector</h2>
<p> <strong>Two-stage Detector(=Proposal-based model)</strong>은 후보 영역(Region Proposal)들을 명시적으로 만들고, 2차적으로 후보 박스들을 분류하는 모델을 의미한다. R-CNN 계열 모델(Fast R-CNN, Faster R-CNN)처럼 Region Proposal을 먼저 찾은 후 분류하는 방식을 지니는 모델들이 이에 해당한다.</p>
<p> <strong>Two-stage Detector</strong>은 객체를 탐지하기 위해 먼저 Region Proposal 단계에서 객체가 있을 가능성이 높은 후보 영역을 찾고, 이후 Classification 단계에서 후보 영역을 정제하여 최종적으로 객체를 분류한다.</p>
<p> 하지만, 이러한 <strong>Two-stage Detector</strong>는 일반적으로 높은 정확도를 보인다는 장점이 있으나, 구조가 상대적으로 복잡하고, 속도가 느릴 수 있고, 각 단계가 모듈화되어 end-to-end 학습이 어렵다는 단점이 존재한다.</p>
<blockquote>
<p><strong>Two-stage Detector의 단계</strong></p>
<ol>
<li><strong>후보 영역(region proposals)</strong>을 만든다.</li>
<li>각 후보 영역에 대해 <strong>분류 + 박스 보정</strong>을 수행한다.</li>
</ol>
</blockquote>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/79080b86-2d07-42eb-87da-029ac8eb6850/image.png" alt=""></p>
<h2 id="22-one-stage-detector">2.2 One-stage Detector</h2>
<p> <strong>One-stage Detector(=Proposal-free model)</strong>은 별도의 Region Proposal 과정 없이, 이미지를 한 번만 처리하여 객체를 탐지하는 방식을 의미한다. 즉, Region Proposal 단계를 분리하지 않고 이미지에서 바로 클래스와 박스를 예측하는 것이다. YOLO, SSD, RetinaNet 같은 모델들이 이에 해당한다. 이때, Dectector는 밀집된(dense) 후보 위치들에 대해 한 번에 예측하므로 구조가 단순하고 빠르다는 특징이 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/82bf4286-ef7e-46e6-977f-feb3c4239e03/image.png" alt=""></p>
<blockquote>
<p>쉽게 설명해서, Two-stage Detector는 수상한 곳 몇 군데를 뽑고, 그 부분만 확대하여 자세히 보는 방법인데 반해, One-stage Detector는 사진 전체에 격자를 촘촘히 깔고 모든 칸에서 동시에 “여기 물체 있나? 있으면 뭐고 박스는 어디지?”를 바로 판단하는 것임.</p>
</blockquote>
<h2 id="23-yolo가-중요한-이유">2.3 YOLO가 중요한 이유</h2>
<p> YOLO는 객체 탐지를 단일 회귀(Single Regression) 문제로 바라본 대표 모델로, 원 논문은 객체탐지 파이프라인 전체를 하나의 네트워크로 묶어 end-to-end로 최적화하는 관점을 제시하였다.</p>
<p> 다시 말해, YOLO는 이미지 전체를 입력으로 받아 별도의 proposal 단계 없이, 하나의 네트워크가 bounding box 좌표와 클래스 확률을 직접 한 번에 예측하는 구조를 지닌다.</p>
<ul>
<li><p>Two-stage : “후보를 먼저 뽑고, 그 다음 분류하자” — 후보 영역을 먼저 만들고(proposal), 그 뒤에 각 영역을 분류하고 박스만 보정함. (후보 영역을 먼저 좁힌 뒤 정제)</p>
<p>  → <code>Image -&gt; Proposal -&gt; Classifier/Regressor -&gt; Output</code></p>
</li>
<li><p>YOLO식 One-Stage : “처음부터 박스와 클래스를 한 번에 예측하자” — 명시적인 proposal 단계를 분리하지 않고, 이미지에서 바로 조밀한 위치의 박스와 클래스를 예측함. (전체 위치에 대해 직접 예측하는 Dense Prediction 구조)</p>
<p>  → <strong>“One-stage면 후보 박스가 전혀 없다”라고 이해하면 안 된다.</strong> 실제로는 여러 위치와 스케일에서 박스 후보를 예측하지만, proposal을 별도 모듈로 떼어 놓지 않는다는 뜻에 가깝다.</p>
<p>  → <code>Image -&gt; Dense Prediction -&gt; NMS -&gt; Output</code></p>
</li>
</ul>
<p> YOLO의 출발점은 Proposal을 별도로 두지 않고 바로 예측하자는 철학이며, 이러한 특성으로 인해 빠르다는 장점이 있어 실시간 객체 탐지의 대표 계열이 되었다. 과거에는 One-stage의 정확도가 Two-stage보다 낮은 경향성이 있었으나, 현대의 YOLO 계열은 정확도와 속도를 모두 크게 개선했다. 또한, One-stage는 내부적으로 많은 위치/스케일에서 예측하므로 일종의 Dense Candidates 개념이 남아 있긴 하기에 후보 박스가 전혀 없는 것은 아니다. 하지만, Two-stage처럼 명시적 Proposal 단계가 분리되지는 않았다는 특징이 있다. YOLO가 빠른 이유는 단순히 One-stage여서가 아니라, RPN 같은 별도 proposal 생성 모듈이 따로 분리되어 있지 않고, backbone이 뽑은 feature에서 바로 detection head가 예측하는 구조이기 때문이다. </p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/0cad93e0-ef86-4c6a-901f-0fcd0dc2253c/image.png" alt=""></p>
<h1 id="3-yolo의-핵심-아이디어와-yolov1의-문제-설정">3. YOLO의 핵심 아이디어와 YOLOv1의 문제 설정</h1>
<h2 id="31-yolov1의-대표적-문제-설정">3.1 YOLOv1의 대표적 문제 설정</h2>
<p> YOLOv1은 입력 이미지를 S × S grid로 나누고, 각 grid cell이 자신의 영역에 속한 객체 중심(center)를 담당하게 한다. 원 논문에서 대표적으로 소개되는 설정은 다음과 같다.</p>
<ul>
<li><strong>전체 이미지를 SxS 크기의 grid로 나눠줍니다</strong>. 여기서 객체의 중심이 특정 grid cell에 위치한다면, 해당 grid cell은 그 객체를 탐지하도록 <strong>할당(responsible for)</strong>합니다.</li>
<li>쉽게 말해, <strong>물체의 중심점(center)이 들어 있는 칸이 그 물체를 담당</strong>한다.</li>
<li>모델은 입력 전체를 본 뒤, 특정 위치의 cell이 그 객체의 예측 책임을 지도록 한다. 따라서 grid cell은 이미지를 잘라 독립적으로 처리하는 창(window)이라기 보다, 예측을 배정하는 기준 좌표계에 가깝다. 객체 중심이 어느 cell에 들어가는지가 label assignment의 핵심이 된다.</li>
<li>4행 3열의 grid cell이 왼쪽의 개를 예측하도록 할당되었고, 4행 4열의 grid cell이 오른쪽의 개를 예측하도록 할당 (개가 커서 여러 칸을 덮고 있어도 모든 칸이 다 예측하는 게 아니라 <strong>중심이 들어 있는 칸 하나만 담당</strong>하도록 설정함<strong>)</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/d6720fe3-668d-44b4-b484-61a65434bb69/image.png" alt=""></p>
<p>$$
S = 7, B = 2, C = 20
$$</p>
<p>최종 출력 텐서 크기는 다음과 같다.</p>
<ul>
<li>박스 하나당 (x, y, w, h, confidence) 5개를 예측하므로 B * 5</li>
<li>클래스 정보도 예측하므로 +C</li>
</ul>
<p>$$
S×S×(B×5+C)
$$</p>
<p> 위 설정이면</p>
<p>$$
7×7×(2×5+20) = 7×7×30
$$</p>
<p>이 된다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/d393121c-7276-43b0-93ea-9f5dd519a46b/image.png" alt=""></p>
<h2 id="32-각-grid-cell이-예측하는-것">3.2 각 grid cell이 예측하는 것</h2>
<p> 각 grid cell은 다음을 예측한다.</p>
<ol>
<li><p>B개의 Bounding box</p>
<ol>
<li><p>$b = (x, y, w, h)$</p>
</li>
<li><p>bbox 표기에서의 핵심은 좌상단과 우하단 좌표를 저장하는 것이 아니라, <strong>중심과 크기를 기반으로 위치를 기술한다</strong>는 것이다. YOLO Detection 라벨은 대게 <code>class x_center, y_center, width, height</code>  형식을 따름.</p>
</li>
<li><p>정규화 좌표를 쓰면 이미지 해상도가 달라도 같은 표현을 유지할 수 있고, 추론 결과를 해석할 때는 이 값들이 다시 픽셀 좌표로 복원되어 화면에 그려짐.</p>
</li>
<li><p>이때, YOLO 형식의 기본 detection 라벨은 xyxy가 아니라 <strong>normalized wywh</strong>이다.</p>
<p> <img src="https://velog.velcdn.com/images/inhwaaa_v/post/2df223db-4661-41bc-911b-069f54a586e0/image.png" alt=""></p>
</li>
</ol>
</li>
</ol>
<ol start="2">
<li><p>각 bounding box에 대한 confidence score</p>
<ol>
<li><strong>confidence score</strong>는 해당 bounding box에 객체가 포함되어 있는지 여부와, box가 얼마나 정확하게 ground truth box를 예측했는지를 반영하는 수치임. 다시 말해, 박스 안에서 객체가 존재할 확률과 그 박스가 실제 객체와 얼마나 잘 겹치는지를 함께 반영한 값이다.</li>
<li>$Pr(Object) * IoU(truthpred)$로 정의함.</li>
<li>이때, Pr(Object)는 objectness와 같은 개념(객체 존재 확률)으로,  grid cell이 객체를 담당하면 Pr(Object)=1, 배경만 있으면 Pr(Object)=0임.</li>
<li>만약 grid cell 내에 객체가 존재하지 않는다면 confidence score는 0이며, grid cell 내에 객체가 존재한다면 confidence score는 IoU(예측 박스와 정답 박스의 overlap 정도) 값과 같음.</li>
<li>confidence score는 단순히 객체가 있다/없다가 아니라 객체가 있더라도 그 박스 위치가 정확한지, 박스 위치가 그럴듯해도 실제 객체가 실제로 있는지 없는지 두 가지 측면을 동시에 반영하는 값이다. YOLOv1에서는 박스의 품질을 나타내는 핵심 값이고, 실전에서는 보통 class score와 결합해 최종 score를 만든다.</li>
</ol>
</li>
<li><p>C개의 conditional class probabilities</p>
<ol>
<li><p><strong>Objectness</strong> : 물체인지 아닌지 여부</p>
</li>
<li><p><strong>Class Probability</strong> : 물체라면 어떤 클래스인지 확률</p>
</li>
<li><p>YOLO의 score를 해석할 때는 objectness, class probability, 즉 <strong>클래스를 맞추는 것</strong>과 <strong>그 박스가 실제 객체를 담고 있는가</strong> 라는 문제의 축이 결합되어 최종 순위가 결정된다.
 <img src="https://velog.velcdn.com/images/inhwaaa_v/post/417804e9-5b85-4200-a80a-553349ec21ce/image.png" alt=""></p>
<p>   • Detection score는 단순한 분류 확률이 아니라 박스의 신뢰성과 클래스 정보가 결합된 값이다.
   • class probability가 높아도 objectness가 낮으면 최종 score는 낮아지고, objectness가 높아도 클래스 구분이 불명확하면 특정 클래스의 score는 높지 않다.
   • 실제 후처리에서는 이 score를 기준으로 thresholding과 NMS가 이어진다.
   • 이때, confidence와 class probability를 같은 값으로 생각하면 안 되며, score가 높다는 사실만으로 localization 품질이 자동으로 보장되는 것은 아니다.</p>
<p>   • Detection score는 단순한 분류 확률이 아니라 박스의 신뢰성과 클래스 정보가 결합된 값이다.
   • class probability가 높아도 objectness가 낮으면 최종 score는 낮아지고, objectness가 높아도 클래스 구분이 불명확하면 특정 클래스의 score는 높지 않다.
   • 실제 후처리에서는 이 score를 기준으로 thresholding과 NMS가 이어진다.
   • 이때, confidence와 class probability를 같은 값으로 생각하면 안 되며, score가 높다는 사실만으로 localization 품질이 자동으로 보장되는 것은 아니다.</p>
</li>
</ol>
</li>
</ol>
<p> Bounding box 하나는 보통 다음과 같이 요약한다.</p>
<p>$$
(x, y, w, h, confidence)
$$</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/39ae5554-90f4-4f21-8038-b528c6815443/image.png" alt=""></p>
<h2 id="33-왜-이-formulation이-혁신적이었는가">3.3 왜 이 formulation이 혁신적이었는가</h2>
<p>이전 계열은 “영역 후보 → 분류”의 파이프라인이 강했다. 반면, YOLO는 이미지 전체를 보고 한 번에 예측하기 때문에, 전역 문맥(global context)를 활용하기 쉽고 속도도 매우 빨랐다.</p>
<p> 하지만, YOLOv1은 각 grid cell이 강한 공간 제약을 가지므로, 작은 객체나 촘촘히 모여 있는 객체에 약했다. 원 논문도 localization error가 상대적으로 많음을 언급한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/a354d0f5-9e6f-4161-bbce-7e18d1eaf825/image.png" alt=""></p>
<h2 id="34-nms--non-maximum-suppression">3.4 NMS : Non-Maximum Suppression</h2>
<p> NMS는 객체당 대표 박스 하나만 남기기 위해 사용하는 대표적인 후처리 방식이다. 객체 감지 모델에서 겹치는 Bounding Box를 제거하여 최종 결과를 정리한다. 이러한 NMS은 “최대가 아닌 것을 억제한다”라는 뜻으로, <strong>높은 score의 대표 박스를 하나 남기고 겹치는 중복 박스를 줄이는 알고리즘</strong>이다. 이는 간단하고 효과적이며, 중복 탐지를 크게 줄여준다는 장점이 있으나, 서로 가까이 있는 객체가 많으면 박스를 과하게 제거할 수 있고, 임계값 τ와 confidence threshold에 민감할 수 있다.</p>
<p> 실제 YOLO에서는 <strong>Image → Model Prediction → Score Filtering → NMS → Final Detection</strong>의 흐름으로 진행된다.  NMS는 학습 과정이라기보다는 보통 추론 후처리의 과정이며, IoU threshold를 낮춘다고 해서 무조건 좋은 것이 아니라, 너무 낮으면 서로 다른 객체까지 제거될 수 있다.</p>
<p><strong>NMS 절차</strong></p>
<p> NMS는 <strong>같은 객체 주변에 여러 박스가 생성되었을 때, 대표 박스를 남기고 중복 박스를 제거하는 절차</strong>이다. <strong>score가 가장 높은 박스를 먼저 선택</strong>한 뒤, <strong>IoU가 큰 나머지 박스를 차례로 제거</strong>한다.</p>
<ol>
<li>score가 가장 높은 박스를 선택한다</li>
<li>그 박스와 IoU가 큰 나머지 박스들을 제거한다 <strong>(score가 높은 박스와 겹치는 부분이 많은 박스 제거</strong>)</li>
<li>남은 박스들에 대해 같은 작업을 반복한다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/00907dcb-24ee-4d5e-8b35-63c8d3cb2e54/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/c02e95e9-2ee3-4473-a0f9-daeddf9f3b0d/image.png" alt=""></p>
<h1 id="4-수식과-지표">4. 수식과 지표</h1>
<ul>
<li>IoU, NMS, Precision, Recall, AP, mAP, loss를 연결해 이해한다.</li>
</ul>
<h2 id="41-iou--intersection-over-union">4.1 IoU : Intersection over Union</h2>
<p> IoU는 예측 박스와 정답 박스의 겹침 정도를 나타내는 대표 지표로, 예측 박스와 정답 박스가 얼마나 겹치는지를 정량화하기 위해 다음과 같은 수식을 활용한다.</p>
<ul>
<li>$B_{pred}$ : 예측 박스 / $B_{gt}$  : ground truth 박스 / ∩ : 교집합 영역 / ∪ : 합집합 영역</li>
<li>이러한 IoU가 필요한 이유는, 분류는 맞았더라도 박스가 엉뚱한 위치라면 Detection이 잘 된 것이 아니기에 얼마나 정확히 위치를 맞췄는지 평가하기 위해서이다.</li>
<li>IoU는 Localization 정확도를 정량화하는 가장 기본적인 지표(분류 확률이 아닌 <strong>localization 품질</strong>을 나타내는 값)로, Detection에서 맞았다의 기준은 단순 클래스 일치 뿐만 아니라, 클래스 + IoU 기준 충족이다. 이는 분류 score가 아닌 박스 겹침 정도를 의미하며, <strong>IoU가 높다고 confidence도 반드시 높아지는 것은 아니</strong>다. (IoU와 confidence는 학습된 모델에서는 관련이 있지만, 둘은 개념적으로 다른 값이다.)</li>
<li>IoU를 이해할 때는 숫자보다 <strong>면적의 대비</strong>를 먼저 보는 것이 좋으며, 겹치는 면적이 클수록 IoU는 커지고, 박스가 전혀 겹치지 않으면 IoU는 0이다. 같은 중심을 가지더라도 크기가 크게 다르면 IoU는 생각보다 낮아질 수 있다.</li>
<li><strong>YOLO에서는 어떻게 쓰이는가?</strong><ul>
<li>학습 중 박스 회귀 품질을 반영하는 Loss 설계의 기준</li>
<li>추론 후 NMS에서 중복 박스 제거 판단 기준</li>
<li>평가에서 true positive 판정 기준 (ex. IoU ≥ 0.5)</li>
</ul>
</li>
</ul>
<p>$$
IoU = \frac{Area(B_{pred}∩B_{gt}}{Area(B_{pred}∪B_{gt})}
$$</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/e291f2f0-e1b6-4a8f-8e40-cd5199e803a6/image.png" alt=""></p>
<h2 id="42-precison정밀도과-recall재현율">4.2 Precison(정밀도)과 Recall(재현율)</h2>
<p> Object Detection(객체 검출) 모델의 성능을 측정하기 위해선 Precision(정밀도), Recall(재현율), Average Precision(AP)에 대해 알아야 한다. 객체 검출과 작업에서는 Precision, Recall 등의 개념이 중요한데, 그 이유는 객체 검출 성능이 좋지 않을 때 오검출을 많이 했을 수도 있고, 검출 자체가 잘 안됐을 수도(미검출) 있기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/45c6fa9b-b5c0-47e0-a438-00418a00bcb1/image.png" alt=""></p>
<p><strong>Precision (정밀도)</strong></p>
<ul>
<li><p>모델이 <strong>양성으로 예측</strong>한 것 중에서 <strong>실제로 양성인 비율</strong> (모델이 “있다”고 예측한 것 중 실제로 맞는 비율)</p>
</li>
<li><p>Precision = (True Positives) / (True Positives + False Positives)</p>
</li>
<li><p>Precision ↑ : 모델이 예측한 양성이 대부분 실제 양성 → 오검출 적음</p>
</li>
<li><p><strong>Precision ↓ : 모델이 양성으로 예측한 대부분이 실제로는 음성 → 오검출 다수</strong></p>
<p>  → 거짓 검출(False Alarm)을 얼마나 줄였는지 알 수 있음.</p>
</li>
</ul>
<p><strong>Recall (재현율)</strong></p>
<ul>
<li><p><strong>실제 양성 중</strong>에서 <strong>모델이 정확하게 감지한 비율</strong> (실제로 존재하는 객체 중 모델이 찾아낸 비율)</p>
</li>
<li><p>Recall = (True Positives) / (True Positives + False Negatives)</p>
</li>
<li><p>Recall↑ : 모델이 대부분의 실제 양성을 감지 → 미검출 적음</p>
</li>
<li><p><strong>Recall↓ : 모델이 실제 양성 중 일부를 감지하지 못한 경우 → 미검출 다수</strong></p>
<p>  → 객체를 놓치는 정도를 알 수 있다.</p>
</li>
</ul>
<blockquote>
<p><strong>Precision이 낮다는 것은 오검출이 많다</strong>는 것이고, <strong>Recall이 낮다는 것은 미검출이 많다</strong>는 것이다. 때문에 일반적으로 Confidence threshold에 따라 Precision과 Recall은 변화하며, 두 수치 간의 관계는 반비례이다. Threshold를 낮춰 많이 검출하다 보면 미검출(Recall)은 적어지지만 오검출(Precision)이 많아지고, Threshold를 높여 보수적으로 검출하다보면 놓치는 객체가 많아져 오검출(Precision)은 적어지지만 미검출(Recall)이 많아지기 때문이다..</p>
</blockquote>
<p><strong>Precision이 중요한 경우 (Precision이 낮으면 안되는 경우)</strong></p>
<ul>
<li><strong>오검출이 적</strong>어야 하는 경우</li>
<li>잡초 제거 로봇 : 오검출 시 잡초가 아닌 작물을 뽑기 때문</li>
<li>스팸 메일 필터링 : 중요한 메일을 오판단으로 스팸 메일로 분류하는 경우 크리티컬한 이슈이기 때문</li>
<li>오검출/오판단 시 리소스 사용량이 더 많이 드는 경우</li>
</ul>
<p><strong>Recall이 중요한 경우 (Recall이 낮으면 안되는 경우)</strong></p>
<ul>
<li><strong>미검출이 적</strong>어야 하는 경우</li>
<li>의료 진단 (암 진단) : 암인데 암이 아니라고 판단하는 경우가 가장 크리티컬 하기 때문에 recall이 높아야 함. 암이 아닌데 암이라고 하는경우는 그저 정밀 검사를 해야 하는 귀찮음이 발생하는 정도이지만, 암인데 암이 아니라고 판단하는 경우는 위험하기 때문</li>
<li>자율 주행 사람 검출 : 자율 주행 차량의 경우 사람을 미검출하는 경우 사고로 이어질 수 있음.</li>
<li>미검출 시 리소스 사용량이 더 많이 드는 경우</li>
</ul>
<h2 id="43-ap">4.3 AP</h2>
<ul>
<li><p>AP는 Precision-Recall curve(양성 예측의 Precision과 Recall 간의 관계를 보여주는 곡선) 아래의 면적을 요약한 값으로 볼 수 있으며, 수식으로는 다음과 같다. 이때, $p(r)$은 recall $r$에서의 precision이다.</p>
<p>  → Precision-Recall curve를 보면, Recall이 0~1까지 바뀌는 동안 Precision이 전반적으로 얼마나 높은지 면적으로 볼 수 있음. (여러 Threshold에서의 Precision/Recall 변화를 평균적으로 본 값) 이 면적이 클수록 Recall을 높여가면서도 Precision이 잘 유지된다는 의미임. 따라서 AP가 높을수록 좋은 모델에 해당함.</p>
<p>  $$
  AP = ∫^1_0 p(r)dr
  $$</p>
</li>
<li><p><strong>AP는 모델이 어떤 Recall 값에서 얼마나 좋은 성능을 보이는지를 측정</strong>하며, <strong>여러 Recall 값에 대한 AP를 평균내면 모델의 전반적인 성능을 측정</strong>할 수 있다.</p>
</li>
<li><p>모델의 적절한 Precision-Recall 비율을 설정하기 위해 Score Threshold 를 설정하는 과정이 필요한데, 이 과정 전에 어떤 모델 Weight를 선택할지가 선행되어야 한다. 이 때 일반적으로 AP가 높은 모델을 선택한다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/b39ccdd2-9796-49c6-b2c5-338648ecb587/image.png" alt=""></p>
<p> 이러한 AP를 봐야하는 이유는, Threshold를 바꾸면 Precision과 Recall이 함께 바뀌기 때문이다. 따라서 하나의 지점에서의 Precision, Recall만을 보는 게 아니라 그 변화 전체를 봐야 한다. 일반적으로 Precision과 Recall은 Trade-off 관계이다. 위의 오른쪽 그림은 PR curve이며, 이는 threshold 0.3일 때의 (recall, precision), threshold 0.5일 때의 (recall, precision), threshold 0.7일 때의 (recall, precision)과 같은 점들을 이어서 만든 곡선이다. 즉, Recall이 0~1까지 변하는 동안 Precision이 전반적으로 얼마나 높게 유지되는지를 나타낸다. 그래서 이 면적이 클수록 Recall을 높여도 Precision이 잘 유지되고, Precision이 높을 때 Recall도 잘 유지된다는 것이므로 모델이 전반적으로 균형 있게 잘 검출한다는 의미가 된다.</p>
<p> Detection 성능은 threshold 하나만으로 요약하기 어렵다. 예를 들어 threshold를 0.5로 정했을 때 precision 0.8, recall 0.7이 나왔다고 해도 이는 그 Threshold에서만의 성능일 뿐이다. 실제로 Threshold는 계속 변화시킬 수 있는 값이므로 모델 자체의 성능을 제대로 보려면 Threshold를 움직였을 때 전체적으로 어떤 모습인지를 봐야 한다.</p>
<p> Precision, Recall, AP는 객체 검출 모델의 성능을 종합적으로 평가하기 위해 사용된다. 고정된 Threshold에서 측정되는 Precision과 Recall은 모델의 성능을 특정한 조건에서 평가하는 데 사용되며, AP는 모델의 성능을 다양한 Threshold 값에서 평균화하여 측정한 값이다. </p>
<blockquote>
<p><strong>PR curve를 읽을 때는 precision과 recall이 서로 경쟁 관계에 있다는 점을 먼저 떠올리면 된다.</strong></p>
<ul>
<li>threshold를 높이면 보통 precision은 올라가고 recall은 내려간다.</li>
<li>threshold를 낮추면 더 많은 객체를 잡을 수 있지만 false positive도 늘 수 있다.</li>
<li>AP는 이런 변화 전체를 하나의 면적으로 요약한 값이다.</li>
</ul>
<p><strong>오해하기 쉬운 점</strong></p>
<ul>
<li>precision이 높다고 해서 항상 좋은 모델은 아니다. recall이 지나치게 낮을 수 있다.</li>
<li>AP는 하나의 threshold에서 읽은 값이 아니라, curve 전체의 요약값이다.</li>
</ul>
</blockquote>
<h2 id="44-map">4.4 mAP</h2>
<ul>
<li>다중 클래스 환경에서는 클래스별 AP를 평균낸 것</li>
</ul>
<p>$$
mAP = \frac{1}{C} \sum_{c=1}^C AP_c
$$</p>
<blockquote>
<ul>
<li><strong>AP</strong>: 한 클래스에 대한 평균 정밀도</li>
<li><strong>mAP</strong>: 여러 클래스의 AP를 평균낸 값</li>
</ul>
</blockquote>
<h2 id="map050와-map050--095"><a href="mailto:mAP@0.50">mAP@0.50</a>와 <a href="mailto:mAP@0.50">mAP@0.50</a> : 0.95</h2>
<ul>
<li><p><strong><a href="mailto:mAP@0.50">mAP@0.50</a> (VOC 스타일)</strong> : IoU ≥ 0.5일 때의 평균 AP</p>
<p>  → 예측 박스와 정답 박스의 IoU가 0.50 이상이면 정답이고, 그 기준으로 AP를 구하고, 클래스별 평균을 내면 <a href="mailto:mAP@0.50">mAP@0.50</a> (상대적으로 단순한 AP/mAP 관점)</p>
</li>
<li><p><strong><a href="mailto:mAP@0.50">mAP@0.50</a> : 0.95 (COCO 스타일)</strong> : 0.50, 0.55, …, 0.95 여러 IoU Threshold에서 평균한 값</p>
<p>  → 0.05 간격으로 여러 개의 IoU Threshold를 써서 각각 AP를 구한 뒤, 평균낸 값</p>
<p>  → $\frac{AP@<a href="mailto:0.50+AP@0.55">0.50+AP@0.55</a><a href="mailto:+AP@0.60">+AP@0.60</a>+…<a href="mailto:+AP@0.95">+AP@0.95</a>}{10}$ 을 클래스들에 대해 평균낸 형태</p>
<p>  → 일반적으로 <strong><a href="mailto:mAP@0.50">mAP@0.50</a> : 0.95</strong>가 더 엄격한 지표임. (여러 IoU Threshold와 객체 크기까지 고려하므로)</p>
</li>
</ul>
<blockquote>
<p>**정리</p>
</blockquote>
<ul>
<li>Precision** : “헛검출(오탐, FP)을 얼마나 줄였는가?”</li>
<li><strong>Recall</strong> : “놓친 객체(미탐, FN)를 얼마나 줄였는가?”</li>
<li><strong>AP</strong> : PR Curve 요약</li>
<li><strong>mAP</strong> : 클래스별 평균 성능</li>
<li><a href="mailto:mAP@0.50">mAP@0.50</a>과 <a href="mailto:mAP@0.50">mAP@0.50</a> : 0.90은 엄격도가 다르다.</li>
<li>Precision이 높아도 Recall이 매우 낮을 수 있기에 Precision이 높을 수록 무조건 좋은 모델은 아니다.</li>
<li>mAP50이 높다고 무조건 Localization이 좋은 것은 아니다. 더 엄격한 Localization 평가는 <a href="mailto:mAP@0.50">mAP@0.50</a> : 0.95를 같이 봐야 한다.<blockquote>
</blockquote>
</li>
</ul>
<h1 id="구조-이해">구조 이해</h1>
<ul>
<li>YOLO 계열의 Backbone-Neck-Head 구조와 버전별 발전 맥락 파악</li>
</ul>
<h2 id="51-anchor-box와-anchor-free-경향">5.1 Anchor box와 Anchor-free 경향</h2>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/d54e88e6-94fa-410a-b07f-e9f623c2566d/image.png" alt=""></p>
<p> <strong>Anchor Box(= Template, Prior Box)</strong>는 미리 정해 둔 박스 prior(template) box이다. <strong>Anchor-based 방식</strong>은 미리 정해둔 prior box를 기준으로 오프셋을 예측한다. 모델은 “처음부터 완전히 새로운 박스를 직접 내기”보다 anchor를 기준으로 offset을 예측한다. 즉, 어떤 Feature Map의 한 위치마다 기본 박스들을 여러 개 두고, 박스를 처음부터 완전히 새로 만들기보다 이 Anchor에서 중심을 얼마나 옮길지, 너비와 높이를 얼마나 늘리거나 줄일지를 예측해 offset을 보정하는 방식을 택한다.</p>
<p> YOLOv2는 anchor box를 도입하고, anchor 크기를 hand-crafted가 아니라 clustering 기반으로 정하는 개선을 포함했다. 또한 feature fusion, multi-scale training 등을 통해 original YOLO의 약점을 보완했다.</p>
<p> 직관적으로 보면 anchor box는 다음 문제를 완화한다.</p>
<ul>
<li>다양한 크기/비율의 객체 처리</li>
<li>박스 회귀 안정화</li>
<li>작은 객체와 큰 객체를 다른 prior로 다루기 쉬움</li>
</ul>
<h3 id="anchor-free와의-차이점">Anchor-free와의 차이점</h3>
<p> Anchor-free 방식은 미리 정한 anchor prior에 덜 의존하거나, anchor 없이 center/box를 직접 예측하려는 경향을 말한다. 즉, 미리 정한 박스 템플릿에 덜 의존하거나, 아예 없이 객체 위치를 더 직접적으로 예측하자는 흐름이다. (정답 객체의 center와 box side들과의 거리(width, height)를 구하는 방식) 예를 들어, 객체 중심점이 어디인지 그 중심에서 좌/우/위/아래 경계까지의 거리 예측, 좌상단 우하단 좌표 예측을 통해 박스를 만든다. 기본 박스를 수정하는 대신 물체를 바로 설명해 박스를 만드는 방식이다. 따라서 중심점이나 경계 거리 등 보다 직접적인 표현으로 박스를 예측하는 방식이라고 할 수 있다.</p>
<p> Anchor-free와 Anchor-based 중 절대적으로 하나가 항상 더 좋다고 말하기는 어려우나, 최근 계열은 구조 단순화, 유연성, 성능 향상을 위해 anchor-free 또는 objectness/head 구조의 재설계를 적극적으로 활용하는 흐름을 보인다. (YOLO 계열은 anchor 기반 설계에서 출발해, 최근에는 anchor-free 흐름을 적극적으로 받아들였으며, Ultralytics의 YOLOv8 문서는 YOLOv8의 핵심 특징으로 anchor-free split head를 강조한다.)</p>
<p> Anchor-based와 Anchor-free의 결과물은 모 bounding box지만, 출발점과 label assignment 방식이 다르다. 다시 말해, 결과는 둘 다 Bounding Box이지만, Anchor-based는 기준 박스와의 차이를 학습하고, Anchor-Free는 객체 자체의 위치/크기 표현을 학습한다는 차이가 있다. Anchor-based의 label-assignment는 정답 박스(GT Box)와 각 Anchor의 IoU를 계산해 Positive Anchor를 찾고(IoU 기반 label assignment), Anchor Free는 어떤 위치가 객체 중심인지 예측해 그 중심에서 박스 크기를 예측하거나, 특정 위치 (x, y)를 기반으로 좌우상하 경계까지의 거리를 예측해 박스를 만들어 예측하며, 어떤 위치가 객체 중심/내부/주변에 있느냐에 따라 label-assignment를 한다. (IoU가 아니라 중심 영역, 박스 내부 위치, 거리 기반 규칙 등 공간적 기준에 더 크게 의존)</p>
<blockquote>
<p><strong>요약
-</strong> anchor 설계는 단순한 구현 디테일이 아니라, 모델이 박스를 어떻게 parameterize하고 학습하느냐와 연결된다.</p>
</blockquote>
<ul>
<li><strong>anchor-based는 prior box와의 차이를 학습</strong>한다.</li>
<li><strong>anchor-free는 기준 박스를 덜 의존하고, 중심점 기반 할당과 직접 회귀를 강조</strong>한다. 최근 계열은 복잡한 anchor tuning 부담을 줄이기 위해 anchor-free 방향으로 이동하는 경향이 뚜렷하다.</li>
<li><strong>중심점 기반 할당</strong> : 객체의 중심점을 Positive Sample로 정의하고, 객체의 중심 근처 픽셀은 해당 객체를 학습하고, 배경 근처 픽셀은 배경으로 학습하는 것</li>
<li><strong>직접 회귀</strong> : 특정 픽셀(중심점 근처)에서 해당 객체의 경계 박스까지의 거리(좌, 우, 상, 하, 또는 중심점으로부터의 상대 좌표)를 딥러닝 모델이 직접 회귀(Regression)하여 찾아내는 것</li>
</ul>
<p>**오해하기 쉬운 점</p>
<ul>
<li><strong>anchor-free</strong>라고 해서 박스가 없어지는 것은 아니다.</li>
<li>anchor-based가 항상 구식이고 anchor-free가 항상 우월하다고 단정할 수는 없다. 데이터셋과 head 설계, 학습 전략을 함께 봐야 한다.</li>
</ul>
<blockquote>
<p><strong>Anchor-based</strong>는 사전 정의 다양한 크기의 박스(Anchor)를 활용해 객체 위치를 조정하는 전통적 방식(Faster R-CNN, YOLOv3 등)이다. 반면, <strong>Anchor-free</strong>는 사전 정의된 박스 없이 객체의 중심점, 특징점(Keypoint) 또는 영역을 직접 예측하는 방식(FCOS, CenterNet 등)이다.</p>
</blockquote>
<ul>
<li><strong>Anchor-based</strong> : “기준 박스(anchor)를 놓고 얼마나 수정할지 예측하는 것” / 즉, 미리 준비된 박스 후보(anchor)를 기준으로 조금씩 수정해서 정답 박스를 만드는 방식</li>
<li><strong>Anchor-free</strong> : “객체 중심/경계 정보를 더 직접적으로 예측하는 것” / 즉, 미리 준비된 박스 없이, 물체의 중심이나 경계까지의 거리를 직접 예측해서 박스를 만든다.
이때, Anchor-free도 최종 출력은 여전히 <strong>Bounding Box</strong>이며, Anchor를 없앤다고 무조건 단순하고 좋은 것도 아니라는 것을 알아야 한다. 구현과 학습 안정성은 모델 설계 전체와 함께 봐야한다.<blockquote>
</blockquote>
</li>
</ul>
<h2 id="52-yolo-버전별-발전-흐름--yolov1-v2-v3-v5-v8">5.2 YOLO 버전별 발전 흐름 : YOLOv1, v2, v3, v5, v8</h2>
<h3 id="yolov1">YOLOv1</h3>
<p>YOLOv1은 객체탐지를 <strong>단일 회귀 문제</strong>로 공식화하여, 이미지 전체를 한 번에 입력받고 bounding box와 class probability를 직접 예측했다. 하나의 네트워크로 end-to-end 추론이 가능해 <strong>실시간 탐지</strong>가 가능했지만, <strong>작은 객체 탐지와 localization 정확도</strong>에서는 한계가 있었다.</p>
<ul>
<li>YOLOv1의 가장 중요한 아이디어는, 이전처럼 후보 영역을 먼저 만들고 그걸 다시 분류하는 2-stage 방식이 아니라, <strong>이미지 전체를 한 번 보고 바로 bounding box와 class probability를 예측</strong>하는 것이다.</li>
<li>하지만, YOLOv1은 <strong>박스 위치를 정교하게 맞추는 데는 약하다.</strong> grid 기반으로 이미지를 나눠 예측하는 특성상, 한 칸 안에 여러 작은 객체가 몰려 있으면 잘 처리하지 못하기 때문이다.</li>
<li>localization error가 많고 recall이 낮다</li>
</ul>
<h3 id="yolov2--yolo9000">YOLOv2 / YOLO9000</h3>
<p> YOLO9000 논문은 YOLOv2 개선과 더불어 9000개 이상의 클래스 검출 아이디어를 제시했다. 핵심 포인트는 다음과 같다. (YOLOv2는 anchor 도입과 multi-scale training으로 중요한 전환점을 만들었다.)</p>
<ul>
<li><strong>anchor box 도입</strong><ul>
<li>YOLOv1은 박스를 비교적 직접적으로 예측했는데, YOLOv2는 미리 정해둔 박스 형태(prior)를 기준으로 박스를 맞춰나가는 방식으로 바뀌었다.</li>
</ul>
</li>
<li>cluster 기반 anchor 크기 선택<ul>
<li>anchor를 아무렇게나 정하면 비효율적이기에 <strong>k-means clustering</strong>으로 데이터셋에 맞는 박스 prior 크기를 추출했다. 즉, 데이터셋에 자주 나오는 박스 모양을 통계적으로 뽑아 anchor를 정한 것이다.</li>
</ul>
</li>
<li>fine-grained feature fusion<ul>
<li>fine-grained feature : 세밀한 정보가 많은 feature</li>
<li>earlier layer의 feature를 뒤로 전달하는 구조</li>
<li><strong>passthrough layer</strong>를 넣어서 earlier layer의 fine-grained feature를 뒤쪽 detection 쪽으로 전달해 앞쪽의 세밀한 정보와 뒤쪽의 의미 있는 정보를 함께 활용함.</li>
<li>신경망 내의 중간 단계에서 나온 특징 맵(Feature Map)을 최종 레이어까지 <strong>직접 전달(Pass-through)</strong>하여 연결하는 계층</li>
</ul>
</li>
<li>multi-scale training<ul>
<li>같은 모델을 다양한 입력 크기로 학습해 입력 해상도가 바뀌어도 잘 동작하도록 학습함.</li>
</ul>
</li>
<li>detection + classification joint training (WordTree)<ul>
<li>분류(Classification) 데이터셋과 검출(Detection) 데이터셋을 결합하여 모델을 동시에 훈련</li>
</ul>
</li>
</ul>
<h3 id="yolov3">YOLOv3</h3>
<p>YOLOv3는 여러 작은 설계 변경으로 성능을 높였다. 대표 포인트는 다음과 같다. </p>
<ul>
<li><strong>3개 scale에서 예측</strong> (서로 다른 3개 해상도의 feature map에서 각각 예측)</li>
<li>feature pyramid와 유사한 multi-scale prediction<ul>
<li>서로 다른 해상도의 feature를 계층적으로 활</li>
</ul>
</li>
<li><strong>Darknet-53 backbone</strong></li>
<li>class prediction에서 독립 logistic classifier 사용<ul>
<li>class prediction에서 <strong>softmax를 쓰지 않고 independent logistic classifiers</strong>를 사용하며, class prediction에는 <strong>binary cross-entropy loss</strong>를 쓴다.</li>
</ul>
</li>
</ul>
<p>즉, 작은 객체 처리를 더 강화하고, backbone과 multi-scale 전략을 개선했다.</p>
<h3 id="yolov5">YOLOv5</h3>
<p>YOLOv5는 Ultralytics가 널리 보급시킨 실용적 구현 계열이다. 공식 문서는 YOLOv5에 대해 formal research paper는 없고, 최신 정보는 문서와 저장소를 참고하라고 명시한다.</p>
<ul>
<li>실용성 높은 PyTorch 구현</li>
<li>CSPDarknet 기반 backbone</li>
<li>PANet + SPPF 구조</li>
<li>학습/배포/추론 파이프라인이 매우 편리</li>
</ul>
<p>즉, YOLOv5는 “논문보다는 실무 생태계와 사용성 측면에서 중요성이 매우 큰 버전”이라고 볼 수 있다.</p>
<blockquote>
<p>“YOLOv5가 YOLOv4의 공식 후속 논문인가요?” → 반드시 그렇게 단순화할 수는 없고, Ultralytics 구현 계열로 이해하는 것이 안전하다.</p>
</blockquote>
<h3 id="yolov8">YOLOv8</h3>
<p>Ultralytics 문서는 YOLOv8의 특징으로 다음을 강조한다.</p>
<ul>
<li><p>anchor-free split head</p>
<ul>
<li><strong>anchor-free</strong> : 미리 정한 anchor box에 덜 의존하거나 사용하지 않는 방식</li>
<li><strong>split head</strong> : classification branch와 box regression branch를 더 분리해서 처리하는 설계</li>
</ul>
</li>
<li><p>향상된 backbone / neck</p>
</li>
<li><p>detection뿐 아니라 segmentation, pose, classification 등 다양한 task 지원</p>
</li>
<li><p>accuracy-speed tradeoff 개선</p>
<p>따라서 YOLOv8은 단순히 detector 하나가 아니라, 현대 Ultralytics 비전 프레임워크의 중심 모델로 이해하는 것이 좋다.</p>
</li>
</ul>
<blockquote>
<p>“YOLOv8은 그냥 v5보다 조금 좋아진 버전인가요?” → head 설계 철학과 task 확장성이 더 크다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/a84ca408-e506-4cd1-ad0d-88cba02921b2/image.png" alt=""></p>
<blockquote>
<p><strong>정리</strong>
 YOLO의 발전은 단순히 “버전 번호 증가”가 아니라,</p>
</blockquote>
<ol>
<li><p>예측 방식 개선</p>
</li>
<li><p>backbone 개선</p>
</li>
<li><p>multi-scale 처리 강화</p>
</li>
<li><p>학습/배포 사용성 향상
의 흐름이다.</p>
<p>버전별 흐름을 읽을 때는 각 세대가 해결하려 한 문제를 함께 보는 것이 중요하다.</p>
</li>
<li><p>YOLOv1은 end-to-end one-stage detection의 관점을 정립했다.</p>
</li>
<li><p>YOLOv2는 anchor와 multi-scale training으로 성능과 안정성을 높였다.</p>
</li>
<li><p>YOLOv3는 multi-scale prediction과 Darknet-53으로 작은 객체 대응을 강화했다.</p>
</li>
<li><p>YOLOv5는 실무 친화적인 구현과 사용 편의성 측면에서 큰 영향을 주었다.</p>
</li>
<li><p>YOLOv8은 anchor-free split head와 다양한 task 지원을 전면에 내세운다.</p>
</li>
</ol>
<p><strong>오해하기 쉬운 점</strong></p>
<ul>
<li>최신 버전이라고 해서 항상 모든 상황에서 최선인 것은 아니다.</li>
<li>버전 비교는 단순 accuracy 숫자보다 목표한 문제와 사용 환경을 함께 봐야 한다.</li>
</ul>
<h2 id="53-backbone-neck-head">5.3 Backbone, Neck, Head</h2>
<h3 id="backbone--특징-추출">Backbone (= 특징 추출)</h3>
<p> Backbone은 이미지에서 시각 특징(feature)을 추출하는 부분이다. edge, texture, shape, semantic feature를 점점 추상적으로 추출하고, 다운샘플링을 통해 receptive field를 넓힌다. </p>
<ul>
<li>YOLOv1 : 초기 custom convolutional stack</li>
<li>YOLOv3 : Darknet-53 [3]</li>
<li>YOLOv5 : CSPDarknet 계열 설명이 널리 사용됨</li>
</ul>
<h3 id="neck--특징-융합">Neck (= 특징 융합)</h3>
<p> Neck은 서로 다른 해상도의 feature를 결합한다. (서로 다른 해상도의 특징을 융합한다) 큰 객체는 깊은 레벨 feature가 유리하고, 작은 객체는 상대적으로 고해상도 feature가 중요하기 때문이다.</p>
<ul>
<li>FPN 계열 top-down fusion</li>
<li>PANet 계열 path aggregation</li>
<li>SPP / SPPF 계열 receptive field 확장</li>
<li>YOLOv5 문서는 Neck에서 SPPF와 PANet 사용을 설명한다.</li>
</ul>
<h3 id="head--최종-예측">Head (= 최종 예측)</h3>
<p>Head는 최종적으로 bounding box, class score, objectness / confidence에 해당하는 값(버전에 따라 구조 차이 존재)을 예측한다. (최종 box, class, objectness 관련 예측을 생성한다.) YOLOv8 문서는 anchor-free split head를 강조한다.</p>
<p> YOLO 계열을 이해할 때는 모델을 하나의 CNN 덩어리로 보기보다, 특징 추출–특징 융합–최종 예측의 세 부분으로 나누어 보는 것이 훨씬 유용하다. 이는 Backbone이 표현을 만들고, Neck이 서로 다른 해상도의 feature를 결합하며, Head가 각 위치에서 box/class/objectness 관련 출력을 생성하는 흐름을 지닌다. 이때, Backbone이 약하면 표현력이 부족하고, Neck이 약하면 multi-scale 객체를 잘 다루기 어려우며, Head가 약하면 최종 detection 품질이 떨어진다.</p>
<p> 또한, 작은 객체 검출이나 multi-scale detection을 설명할 때는 Backbone 뿐만 아니라 Neck의 역할을 함께 강조하는 것이 중요하다. 저수준 Feature map은  해상도가 높아 작은 객체 위치를 잘 살리고, 깊은 Feature map은 추상적 의미가 강해 이게 무엇인지를 잘 판단할 수 있다. 이때, Neck은 이 둘을 연결해 작지만 중요한 물체도 놓치지 않게 해주는 역할을 한다. 여러 scale의 feature를 연결하고 융합해주기 때문이다. 다시 말해, 작은 객체 검출 성능이 단순히 여러 scale에서 예측한다는 점만으로 설명하기 어려운 이유는 서로 다른 해상도의 feature를 융합하는 Neck이 있기 때문에 작은 객체와 큰 객체를 모두 더 잘 탐지할 수 있다.</p>
<blockquote>
<ul>
<li>“Backbone이 곧 YOLO 전체인가요?” → 아니다. detector는 backbone만으로 끝나지 않는다. 따라서 Backbone만 좋아도 Detector 전체가 좋아진다고 볼 수는 없다.</li>
<li>“Head는 classifier랑 같은 건가요?” → detection head는 class뿐 아니라 box 관련 예측도 함께 수행하므로 classifier와는 차이가 있다. (Head를 단순 classifier로 이해하면 안 된다. detection head는 box와 score까지 함께 다룬다.)</li>
</ul>
</blockquote>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/d2b540cc-8a52-495b-a403-5d1b1e95a782/image.png" alt=""></p>
<h2 id="54-loss-function-구성-요소">5.4 Loss Function 구성 요소</h2>
<p> YOLO의 loss는 버전에 따라 구현이 달라지지만, 교육적으로는 보통 다음 세 부분으로 나누어 이해하면 좋다.</p>
<p>$$
L = \lambda_{box}L_{box} + \lambda_{obj}L_{obj}+\lambda_{cls}L_{cls}
$$</p>
<ul>
<li>$L_{box}$ : localization loss</li>
<li>$L_{obj}$ : objectness loss</li>
<li>$L_{cls}$ : classification loss</li>
<li>Loss는 결국 <strong>위치, 존재성, 클래스</strong>를 동시에 학습시키는 구조이다.</li>
<li>Classification Loss를 줄인다고 좋은 Detector인 것이 아니라, Localization과 Objectness를 함께 고려해야 한다. (Objectness와 Class Score는 다르다)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/51d0bcfc-8b13-412c-b41f-09dc91873adf/image.png" alt=""></p>
<h3 id="localization-loss">Localization Loss</h3>
<ul>
<li><p>정답 박스와 예측 박스의 위치/크기 차이를 줄이고, 박스 위치와 크기를 정확하게 맞추기 위한 손실</p>
<p>  → 박스 위치와 크기 오차를 줄인다</p>
</li>
<li><p><strong>변수 정의</strong> : 예측 박스 파라미터, 정답 박스 파라미터</p>
</li>
<li><p><strong>필요성</strong> : 클래스만 맞고 박스가 틀리면 Detection 품질이 낮기 때문임. Localization Loss는 박스 회귀의 핵심 손실이며, 작은 위치 오차도 성능에 영향을 줄 수 있다.</p>
<p>  $$
  L_{box} = loss  on (x, y, w, h)
  $$</p>
</li>
</ul>
<h3 id="objectness-loss">Objectness Loss</h3>
<ul>
<li><p>Objectness는 이 위치/박스가 실제 객체를 담고 있는지를 학습한다. 간단한 Binary Cross Entropy 형태 예시에서는 속하는지 아닌지 여부를 이진 분류한다. 즉, Objectness Loss는 객체 존재 여부를 학습하는 손실이다.</p>
<p>  → 객체가 있는 위치와 배경을 구분한다</p>
</li>
<li><p><strong>변수 정의</strong> : y ∈ {0, 1} : 객체 존재 여부, $\hat y$ : 모델이 예측한 objectness score</p>
</li>
<li><p><strong>필요성</strong> : 모델은 이미지 전체에서 수많은 후보를 보고, 그 중 대부분은 배경이다. 따라서 배경과 객체를 구분하는 능력을 위해 Objectness Loss를 활용한다. 이는 실제 YOLO에서 False Positive(오탐)을 줄이는 데 큰 역할을 한다.</p>
<p>  $$
  L_{obj} = - [ylog(\hat y) + (1 - y)log(1 - \hat y)]
  $$</p>
</li>
</ul>
<h3 id="classification-loss">Classification Loss</h3>
<ul>
<li><p>객체가 무엇인지 맞추기 위한 손실로, 박스를 잘 찾았더라도 클래스를 잘못 맞추면 Detection은 실패이기에 Classification Loss를 활용한다. 실제 YOLO에서는 클래스 수, 데이터셋 특성, head 구조에 따라 구체 구현이 달라질 수 있다.</p>
<p>  → 해당 객체의 클래스를 맞추게 한</p>
</li>
<li><p>클래스 분류 손실 예시는 다음과 같이 쓸 수 있으며, multilabel 상황에서는 BCE 계열을 쓸 수 있다.</p>
<p>  $$
  L_{cls} = - \sum_{k=1}^C y_k log (\hat p_k)
  $$</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/aff73563-0407-49c8-9d34-86b575829faf/image.png" alt=""></p>
<h2 id="55-label-format과-dataset-구성-방식">5.5 Label format과 Dataset 구성 방식</h2>
<h3 id="ultralytics-yolo-detection-label-형식">Ultralytics YOLO Detection Label 형식</h3>
<ul>
<li>이미지당 하나의 <code>*.txt</code> 파일을 사용하며, 각 줄은 <code>class x_center, y_center width, height</code> 형식을 따른다. 단, 이미지에 객체가 없으면 txt 파일이 없어도 된다.</li>
<li>이때, <strong>좌표는 정규화된 xywh 형식</strong>, 즉 [0, 1] 범위이다. (bounding box 좌표를 픽셀값 그대로 쓰지 않고, 이미지 크기 대비 비율로 바꾸는 것)<ul>
<li>이미지 크기가 달라도 같은 기준으로 학습할 수 있고, 학습이 더 안정적이기에 이 방법을 활용</li>
<li>multi-scale training에 유리하기도 함. (YOLO는 입력 크기를 바꿔가며 학습하는 경우가 많은데, 좌표가 비율로 표현되면 해상도가 달라져도 같은 객체를 비슷한 방식으로 다룰 수 있음)</li>
</ul>
</li>
<li>중요 포인트는 각 이미지에는 대응하는 txt 라벨이 있어야 하고, 라벨은 normalized xywh 형식을 따라야 하며, data.yaml은 train/val/test 경로와 클래스 이름을 정확히 연결해야 한다는 것이다.</li>
<li>정규화 좌표를 쓰는 이유는 이미지 해상도가 달라도 같은 비율 표현을 예상할 수 있기 때문이다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/af61b99e-11af-4f64-a947-b2b16738b15f/image.png" alt=""></p>
<h3 id="디렉터리-예시">디렉터리 예시</h3>
<pre><code class="language-jsx">my_data/
├── images/
│   ├── train/
│   ├── val/
│   └── test/
├── labels/
│   ├── train/
│   ├── val/
│   └── test/
└── data.yaml</code></pre>
<h3 id="datayaml-예시"><code>data.yaml</code> 예시</h3>
<ul>
<li><code>*.txt</code> 파일을 사용하며, 각 줄은 <code>class x_center, y_center width, height</code> 형식을 따른다 에서 <code>class</code> 와 <code>data.yaml</code>의 names를 매칭해 이름을 읽어옴.</li>
</ul>
<pre><code class="language-jsx">path: /content/my_dataset
train: images/train
val: images/val
test: images/test
names:
  0: person
  1: helmet
  2: vest</code></pre>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/688a3472-1c39-4d76-b4e2-512343cf90a8/image.png" alt=""></p>
<h3 id="좋은-데이터셋의-조건">좋은 데이터셋의 조건</h3>
<ol>
<li>클래스 정의가 명확할 것</li>
<li>annotation consistency가 높을 것</li>
<li>train/val/test 분할이 적절할 것</li>
<li>도메인 편향이 심하지 않을 것</li>
<li>작은 객체, 가림, 다양한 조명 조건을 충분히 포함할 것</li>
</ol>
<h2 id="56-학습-추론-후처리-전체-파이프라인">5.6 학습, 추론, 후처리 전체 파이프라인</h2>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/3b8e2012-c653-4722-8168-3d1b804e2c18/image.png" alt=""></p>
<h3 id="추론-파이프라인">추론 파이프라인</h3>
<ul>
<li>입력 이미지 → 전처리 (resize, normalize 등) → <strong>모델 forward → 박스/클래스/score 후보 생성 → confidence threshold 적용 → NMS</strong> → 최종 박스 출력</li>
</ul>
<h3 id="학습-파이프라인">학습 파이프라인</h3>
<ul>
<li>이미지 + 라벨 로드 → augmentation → forward → box/objectness/class loss 계산 → backpropagation → optimizer step → epoch 반복 → validation</li>
</ul>
<h3 id="validation-해석-핵심">Validation 해석 핵심</h3>
<ul>
<li>박스가 많이 나오는데 틀린 것도 많다 → precision 문제</li>
<li>객체를 자주 놓친다 → recall 문제</li>
<li>박스는 대충 맞는데 localization이 거칠다 → IoU / box regression 문제</li>
<li>특정 클래스만 유독 약하다 → data imbalance 또는 annotation quality 문제</li>
<li>“loss만 보면 충분하지 않나요?” → 아니다. detection은 정성 결과를 반드시 함께 봐야 한다<ul>
<li>정량 지표 + 정성 시각화 결과를 모두 봐야 함.</li>
</ul>
</li>
<li>“mAP만 높으면 배포 가능한가요?” → latency, memory, domain shift, class imbalance도 함께 고려해야 한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[OpenCV]]></title>
            <link>https://velog.io/@inhwaaa_v/OpenCV</link>
            <guid>https://velog.io/@inhwaaa_v/OpenCV</guid>
            <pubDate>Wed, 17 Jun 2026 20:26:11 GMT</pubDate>
            <description><![CDATA[<h1 id="1-opencv-기본-함수">1. OpenCV 기본 함수</h1>
<ul>
<li><code>cv2.imread(filename, flages)</code> : 이미지를 읽어오는 함수<ul>
<li><code>flags</code> : 색상 모드 지정 (ex) <code>cv2.IMREAD_COLOR</code> - 컬러 이미지로 읽기, <code>cv2.IMREAD_GRAYSCALE</code> - 그레이스케일로 읽기, <code>cv2.IMREAD_UNCHANGED</code> - 알파 채널까지 그대로 읽</li>
</ul>
</li>
<li><code>cv2.imshow(winname, mat)</code> : 이미지를 화면에 보여주는 함수<ul>
<li><code>winname (str)</code> : 이미지를 표시할 윈도우 창의 제목</li>
<li><code>mat (numpy.ndarray)</code> : 표시할 이미지 데이터 (보통 imread로 불러온 행렬)</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># 이미지를 컬러로 읽기 (file_name, flags)
img_color = cv2.imread(file_name, cv2.IMREAD_COLOR)

# 읽어온 이미지를 Colab 노트북 창에서 띄우기
cv2_imshow(img_color)</code></pre>
<ul>
<li><code>cv2.imwrite(filename, img)</code> : 이미지를 파일로 저장하는 함수<ul>
<li><code>filename</code> : 저장될 파일 경로</li>
<li><code>img</code> : 저장할 이미지(Mat) 객체</li>
</ul>
</li>
</ul>
<pre><code class="language-python">cv2.imwrite(&quot;saved_image.jpg&quot;, img_color)
print(&quot;이미지가 저장되었습니다 : saved_image.jpg&quot;)</code></pre>
<ul>
<li><code>cv2.cvtColor(src, code)</code> : 이미지의 색상 공간을 변환하는 함수<ul>
<li><code>src</code> : 변환할 원본 이미지</li>
<li><code>code</code> : 변환 방법 코드 (ex) <code>cv2.COLOR_BGR2GRAY</code>, <code>cv2.COLOR_BGR2HSV</code></li>
</ul>
</li>
</ul>
<pre><code class="language-python">img_gray = cv2.cvtColor(img_color, cv2.COLOR_BGR2GRAY)</code></pre>
<ul>
<li><code>cv2.resize(src, dsize)</code> : 이미지 크기를 변경하는 함수<ul>
<li><code>src</code> : 리사이즈할 원본 이미지</li>
<li><code>dsize</code> : (가로, 세로) 순서로 새로운 이미지 크기</li>
<li><code>fx</code>, <code>fy</code> : 비율을 지정해 리사이즈 가능<ul>
<li>리사이즈 알고리즘(interpolation)은 기본적으로 <code>cv2.INTER_LINEAR</code>가 적용됨.</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># 이미지 크기 변경 (50% 축소)
h, w = img_color.shape[:2]
img_resized = cv2.resize(img_color, (w // 2, h // 2))

# 크기 변경된 이미지 출력
cv2_imshow(img_resized)

# 저장
cv2.imwrite(&quot;resized_image.jpg&quot;, img_resized)</code></pre>
<h1 id="2-색상-공간">2. 색상 공간</h1>
<p> OpenCV는 기본적으로 이미지를 BGR(Blue, Green, Red) 순서로 읽으나, 필요에 따라 다른 색상 공간으로 변환할 수 있다.</p>
<h2 id="hsv">HSV</h2>
<p> HSI는 Hue-channel, Saturation-channel, Intensity-channel로 구성된다. 여기서는 색상, 채도, 명도라는 3가지 특성들이 색을 설명하는 데 사용되며, 어떤 컬러 모델을 만들어 낼 때 얼마나 색을 사용했는지 알 필요 없이 단순히 채도와 명도를 통해 색을 조절한다.</p>
<ul>
<li><p>Intensity (Brightness) : 어떤 색상의 밝기 값을 의미하며, 색상 정보는 포함되어 있지 않음.</p>
</li>
<li><p>Hue : 색조 (우세한 파장이 무엇인지에 관한 것)S의 파장이 크다면 clear color</p>
<ul>
<li>I의 파장이 크다면 bright color</li>
</ul>
</li>
<li><p>Saturation : 색상의 상대적인 순도 혹은 백색이 섞인 양. 순도가 높을수록(색이 선명할 수록) 이 값이 커진다.S가 크면 선명한 색상, I가 크면 밝기가 밝은 색상이다.</p>
<p>HSI 모델에서 Hue는 원의 각도, Saturation은 원의 bound, Intensity는 높이를 통해 알 수 있다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/e000f929-49f4-4491-b40a-902ad1a33b46/image.png" alt=""></p>
<p> 이러한 HSV를 사용하는 이유는 이미지에서 색상 정보를 검출하고자 할 때, Hue를 사용하면 되므로 RGB보다 분류가 더 쉽기 때문이다. 아래의 예시를 보면 Hue를 통해 특정 범위의 색상만 마스킹해 추출한다.</p>
<p> 이렇게 색상을 추출할 땐 다음과 같은 주의 사항이 있다.</p>
<ol>
<li>실제 조명 환경이나 이미지 색상에 따라 HSV 범위를 세밀하게 조절해야 한다.</li>
<li>보통 Hue(색상)의 범위를 설정하고 Saturation(채도) 및 (명도) 범위를 적절히 좁히거나 넓혀 원하는 결과를 얻는다.</li>
<li>단순히 한 구간(Hue)만으로는 충분하지 않을 수 있으므로 구간을 모두 고려하는 식으로 범위를 늘리는 경우도 있음.</li>
</ol>
<pre><code class="language-python"># 특정 색상 범위를 HSV로 정의해둔 예시
# 각 색은 HSV에서의 범위를 lower(최소값), upper(최대값)로 저장.
color_ranges = {
    &#39;red&#39;:   {&#39;lower&#39;: (0, 100, 100),   &#39;upper&#39;: (10, 255, 255)},   # 빨간색 범위
    &#39;green&#39;: {&#39;lower&#39;: (40, 100, 100),  &#39;upper&#39;: (70, 255, 255)},   # 초록색 범위
    &#39;blue&#39;:  {&#39;lower&#39;: (100, 100, 100), &#39;upper&#39;: (130, 255, 255)}   # 파란색 범위
}

# 사용자로부터 색상 입력 받기
user_color = &#39;red&#39;

# HSV 이미지 준비
img_hsv = cv2.cvtColor(img_color, cv2.COLOR_BGR2HSV)

# 색상 범위 가져오기
lower_val = color_ranges[user_color][&#39;lower&#39;]
upper_val = color_ranges[user_color][&#39;upper&#39;]

# inRange 함수를 사용해 특정 범위 색상만 추출 (마스크 생성)
mask = cv2.inRange(img_hsv, lower_val, upper_val)

# 마스크를 이용해 원본 이미지에서 특정 색상 영역만 추출
# bitwise_and 연산으로 마스크가 1인 영역만 남김
img_masked = cv2.bitwise_and(img_color, img_color, mask=mask)

# 결과 출력
print(f&quot;선택된 색상 : {user_color}&quot;)
cv2_imshow(mask)
cv2_imshow(img_masked)</code></pre>
<h3 id="opencv와-matplotlib-사이에서-색이-달라-보이는-이유"><strong>OpenCV와 matplotlib 사이에서 색이 달라 보이는 이유</strong></h3>
<ul>
<li>채널 순서가 다르기 때문임.</li>
<li>OpenCV는 이미지를 기본적으로 <strong>BGR 순서</strong>로 읽어오지만, matplotlib은 이미지를 <strong>RGB 순서</strong>로 해석하여 OpenCV에서 읽은 이미지를 그대로 matplotlib에 보여주면, matplotlib는 <strong>BGR 데이터를 RGB</strong>로 해석하여 다른 결과가 나온다.</li>
</ul>
<h3 id="hsv가-특정-색상-추출에-왜-유리한지"><strong>HSV가 특정 색상 추출에 왜 유리한지</strong></h3>
<ul>
<li>HSV는 색상(Hue), 채도(Saturation), 밝기(Value)를 분리해서 표현한다. 따라서 원하는 색상은 Hue 범위로 쉽게 지정할 수 있고, 밝기 변화는 Value로 분리되어 RGB보다 특정 색상 추출이 더 쉽고 직관적이다.</li>
<li><strong>H (Hue)</strong> : 무슨 색인가</li>
<li><strong>S (Saturation)</strong> : 얼마나 선명한가</li>
<li><strong>V (Value)</strong> : 얼마나 밝은가</li>
</ul>
<h1 id="3-이미지-연산">3. 이미지 연산</h1>
<h3 id="기본-함수">기본 함수</h3>
<ul>
<li><code>image.shape</code> : (Height, Width, Channels)</li>
<li><code>image.size</code>  : 전체 픽셀 수 (Height × Width × Channels)</li>
<li><code>image[y, x]</code> :  (y, x) 위치의 픽셀(BGR) 정보를 배열 형태로 얻음.<ul>
<li>OpenCV는 BGR 순서를 기본으로 하므로 <code>image[y, x][0] = Blue</code>, <code>image[y, x][1] = Green</code>, <code>image[y, x][2] = Red</code></li>
</ul>
</li>
</ul>
<pre><code class="language-python"># Colab에 이미지 업로드
uploaded = files.upload()

# 업로드된 첫 번째 이미지 파일 이름 가져오기
filename = list(uploaded.keys())[0]

# 이미지를 BGR 컬러로 읽기
image = cv2.imread(filename, cv2.IMREAD_COLOR)
print(&quot;이미지 로드 완료! 파일명 : &quot;, filename)

# 이미지 기본 정보 확인
print(&quot;이미지 크기 (Height, Width, Channels) : &quot;, image.shape)
print(&quot;이미지 전체 픽셀 수 : &quot;, image.size)

# 특정 픽셀의 BGR 값 확인
y, x = 100, 100 # 예 : (100, 100) 위치
if y &lt; image.shape[0] and x &lt; image.shape[1]:
  px = image[y, x]
  print(f&quot;({y}, {x}) 위치 픽셀의 BGR 값 :&quot;, px)
  print(f&quot;({y}, {x}) 위치 픽셀의 B 값 :&quot;, px[0])
  print(f&quot;({y}, {x}) 위치 픽셀의 G 값 :&quot;, px[1])
  print(f&quot;({y}, {x}) 위치 픽셀의 R 값 :&quot;, px[2])
else:
  print(f&quot;이미지 크기가 100*100보다 작아서 ({y}, {x}) 픽셀이 존재하지 않습니다.&quot;)</code></pre>
<ul>
<li>코드 실행 결과
<img src="https://velog.velcdn.com/images/inhwaaa_v/post/fc0fca8d-4b43-4fca-ad6e-d52104d5565d/image.png" alt=""></li>
</ul>
<h3 id="특정-범위-픽셀-변경---반복문-vs-슬라이싱">특정 범위 픽셀 변경 - 반복문 VS 슬라이싱</h3>
<p> OpenCV 이미지 객체는 numpy 배열과 호환되므로, 2차원(또는 3차원) 슬라이싱을 통해 손쉽게 특정 영역을 변경할 수 있다. 이때, 특정 영역을 변경하는 방법에는 <strong>반복문을 사용해 픽셀을 하나씩 변경하는 방법</strong>과 <strong>슬라이싱을 통해 한 번에 처리하는 방법</strong>이 있다.</p>
<ul>
<li><p><strong>반복문 방식</strong> : <code>for i in range(...): for j in range(...) ...</code></p>
<p>  → <strong>각 픽셀에 직접 접근해 값을 대입</strong>하는 방식으로, 반복문을 통해 하나의 픽셀씩 변경하므로 이미지 크기가 커질수록 상대적으로 속도가 느려진다는 단점이 있다.</p>
</li>
<li><p><strong>슬라이싱 방식</strong> : <code>image[y1:y2, x1:x2] = [B, G, R]</code></p>
<ul>
<li>범위를 지정해 한 번에 값을 대입하는 방식으로, 반복문을 사용하지 않아 빠르게 동작한다는 장점이 있음.</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># 이미지 복사본 생성
image_copy = image.copy()

# (1) 반복문을 이용한 특정 범위 변경
start_time = time.time()
rows = min(100, image_copy.shape[0]) # 이미지가 100보다 작을 수 있으므로
cols = min(100, image_copy.shape[1])

for i in range(rows):
  for j in range(cols):
    image_copy[i, j] = [255, 255, 255] # 흰색으로 변경
end_time = time.time()
print(&quot;-- 반복문 방식 수행 시간 : %.6f seconds --&quot; % (end_time - start_time))

# (2) 슬라이싱을 이용한 특정 범위 변경
image_copy2 = image.copy()
start_time = time.time()
image_copy2[0:rows, 0:cols] = [0, 0, 0] # 검정색으로 변경
end_time = time.time()
print(&quot;-- 슬라이싱 방식 수행 시간 : %.6f seconds --&quot; % (end_time - start_time))

# 결과 이미지 보기
print(&quot;\n[반복문 방식] 상단 100*100 영역을 흰색으로 변경&quot;)
cv2_imshow(image_copy)

print(&quot;\n[슬라이싱 방식] 상단 100*100 영역을 검정색으로 변경&quot;)
cv2_imshow(image_copy2)</code></pre>
<h3 id="roiregion-of-interest-추출-및-복사">ROI(Region of Interest) 추출 및 복사</h3>
<ul>
<li><p>이미지에서 특정 영역(ROI)을 잘라내어(Numpy 슬라이싱 활용) 다른 위치에 복사하는 것</p>
</li>
<li><p>원하는 위치의 일부 영역에만 합성하기 위해 ROI 합성을 활용함.</p>
<p>  <strong>방법</strong></p>
<ol>
<li><code>roi = image[y1:y2, x1:x2]</code> : ROI 영역 추출</li>
<li><code>image_copy[새로운 위치] = roi</code> : 추출된 영역을 다른 위치에 복사</li>
</ol>
</li>
<li><p><strong>워터마크, 로고, 관심 영역 분석 등</strong>에 유용하게 활용</p>
</li>
<li><p>이때, 마스크를 활용하면 원하는 부분만을 처리할 수 있고, 배경을 제거할 수 있고, 정밀한 합성을 할 수 있다는 장점이 있다.</p>
<p>  <strong>방법</strong></p>
<ol>
<li>원본 영상에서 로고를 넣을 위치 설정 (ROI)</li>
<li>로고 영상과 크기를 맞춤</li>
<li>로고에서 Mask 생성</li>
<li>ROI 배경에서 로고가 들어갈 부분을 제거</li>
<li>Mask를 통해 로고 영역만 추출</li>
<li>ROI와 로고를 합성 (비워둔 ROI 부분 로고 부분 결합)</li>
<li>합성 결과를 원본 영상에 다시 삽입</li>
</ol>
</li>
</ul>
<pre><code class="language-python"># ROI 추출 예시 : (y : 20~150, x : 70~200)
image_copy3 = image.copy()

# 안전하게 min() 함수를 사용하여 이미지 범위 내에서만 추출
roi = image_copy3[20:min(150, image_copy3.shape[0]), 70:min(200, image_copy3.shape[1])]

# 잘라낸 ROI 확인
print(&quot;[ROI] 20:150, 70:200 영역&quot;)
cv2_imshow(roi)

# ROI를 다른 위치(0:130, 0:130)에 복사
h_roi, w_roi = roi.shape[:2]
end_y = min(h_roi, image_copy3.shape[0])
end_x = min(w_roi, image_copy3.shape[1])
image_copy3[0:end_y, 0:end_x] = roi

print(&quot;[Modified image] ROI를 좌측 상단(0:130, 0, 130)에 복사&quot;)
cv2_imshow(image_copy3)</code></pre>
<h3 id="픽셀별로-다루기">픽셀별로 다루기</h3>
<p> OpenCV 이미지(3채널)는 BGR 순서로 채널을 저장하며, 특정 채널만 확인하거나 특정 채널만 제거할 수도 있다.</p>
<ul>
<li><code>image[:, :, 0]</code> : Blue 채널</li>
<li><code>image[:, :, 1]</code> : Green 채널</li>
<li><code>image[:, :, 2]</code> : Red 채널</li>
</ul>
<pre><code class="language-python"># (1) Blue 채널만 확인
blue_channel = image[:, :, 0]
print(&quot;[Blue Channel]&quot;)
cv2_imshow(blue_channel)

# (2) 특정 색상 채널 제거 (예 : Red 채널을 0으로)
image_no_red = image.copy()
image_no_red[:, :, 2] = 0 # Red 채널 제거
print(&quot;[Red 채널 제거]&quot;)
cv2_imshow(image_no_red)</code></pre>
<h1 id="4-이미지-변환">4. 이미지 변환</h1>
<h3 id="이미지-크기-조절-resizing">이미지 크기 조절 (Resizing)</h3>
<p> 이미지의 크기를 변경(확대/축소)할 때에는 <code>cv2.resize()</code> 함수를 사용함.</p>
<pre><code class="language-python">resized = cv2.resize(
    src,                # 원본 이미지
    dsize,             # (width, height) - 직접 지정하거나 None으로 둘 수 있음
    fx=1.0,            # 가로 방향 배율
    fy=1.0,            # 세로 방향 배율
    interpolation=cv2.INTER_LINEAR  # 보간법(기본값: INTER_LINEAR)
)</code></pre>
<h3 id="보간법">보간법</h3>
<p> 이미지를 확대/축소할 때 실제 존재하지 않는 픽셀 사이의 값을 채우거나 제거해야 하는데, 이 픽셀을 어떻게 계산해서 값을 구할지 결정하는 방법을 보간법(Interpolation)이라고 한다.</p>
<ul>
<li><p><code>cv2.INTER_NEAREST</code> : 최근접 이웃 보간법 (Nearest Neighbor)</p>
<ul>
<li>새 픽셀 위치가 원본에서 어떤 위치에 대응될 때, 그 주변을 계산하지 않고 <strong>가장 가까운 한 점의 값</strong>을 활용하는</li>
<li>가장 단순하고 빠르지만 이미지 품질이 거칠 수 있고, 계단 현상이 나타날 수 있음.</li>
<li>계단 현상 : 대각선이나 곡선 가장자리가 계단이나 톱니모양으로 보이는 현상</li>
</ul>
</li>
<li><p><code>cv2.INTER_LINEAR</code> : 양선형 보간법 (Bilinear Interpolation)</p>
<ul>
<li>새 픽셀 주변 2*2 이웃 픽셀을 참고해 계산하는 것 (x축, y축 각각 방향으로 선형 보간)</li>
<li>확대, 축소 모두 적절한 품질과 속도 제공 / 계산량이 크지 않음</li>
</ul>
</li>
<li><p><code>cv2.INTER_CUBIC</code> : 4*4 이웃 픽셀을 고려하는 삼차 보간법 (Bicubic Interpolation)</p>
<ul>
<li>확대 시 매우 높은 품질을 제공하나, 계산 비용이 큼.</li>
</ul>
</li>
<li><p><code>cv2.INTER_AREA</code> : 영역 보간법</p>
<ul>
<li><p>주로 축소 시 좋은 결과를 냄. (평균화 과정)</p>
</li>
<li><p>줄어드는 영역 안에 포함되는 원본 픽셀들의 정보를 평균적으로 반영함.</p>
<p>  → 다시 말해, 축소 시 여러 원본 픽셀이 1개의 새 픽셀로 합쳐지는데, 그 영역의 정보를 대표값으로 요약하는 방식으로 보면 됨. (새 픽셀이 원본에서 차지하는 영역 안의 정보들을 평균 냄)</p>
<p>  → 단순히 픽셀 하나만 고르면 어떤 패턴은 사라지고 어떤 줄무늬는 이상하게 남는 등 aliasing 문제가 발생하는데, 영역 보간법은 여러 픽셀을 평균적으로 반영해 이러한 문제를 줄이는 데 유리하다.</p>
</li>
</ul>
</li>
<li><p><code>cv2.INTER_LANCZOS4</code> : Lanczos 보간법</p>
<ul>
<li>8*8 이웃 픽셀을 고려해 고품질 확대/축소 구현</li>
</ul>
<p>가장 단순한 방법은 Nearest로, 이웃 픽셀 정보를 이용하기에 단순하지만 이미지의 연속성이 소실될 수 있다. 양선형 보간법인 LINEAR는 일반적으로 좋은 성능을 내기에 기본값으로 사용되고, 큐빅(삼차 보간법)은 4<em>4 이웃 픽셀을 고려하므로 확대 시 성능이 가장 좋다. AREA는 영역 보간법으로 주로 축소 시에 좋은 결과를 내고, Lanczos는 고품질에 유용하다. *</em>(확대 = CUBIC, 축소 = AREA)**</p>
</li>
</ul>
<h3 id="이미지-위치-변경-translation">이미지 위치 변경 (Translation)</h3>
<ul>
<li>이미지를 평면 상에서 옮기는(이동시키는) 방법으로, 영상의 <strong>모든 픽셀을 동일한 거리만큼 x축, y축 방향으로 이동시키는 변환</strong>임. (영상의 모양이나 방향은 그대로이고 위치만 바뀜)</li>
<li>이를 위해 이동 변환 행렬(Translation Matrix)을 만들어야 함.</li>
<li><strong><code>cv2.warpAffine(src, M, dsize)</code></strong><ul>
<li><code>tx</code> : x축으로 이동하는 픽셀 수 (양수 → 오른쪽 이동, 음수 → 왼쪽 이동)</li>
<li><code>ty</code> : y축으로 이동하는 픽셀 수 (양수 → 아래쪽 이동, 음수 → 위쪽 이동)</li>
<li><code>dsize</code> : 결과 이미지의 크기. 일반적으로 원본과 같은 크기를 지정합니다.</li>
</ul>
</li>
</ul>
<pre><code class="language-python">M = np.float32([
    [1, 0, tx],  # x 방향 이동
    [0, 1, ty]   # y 방향 이동
])
dst = cv2.warpAffine(
    src=image,
    M=M,               # 변환 행렬
    dsize=(width, height)  # 결과 이미지 크기
)</code></pre>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/12881390-2cab-4ba8-854d-c9bc3d21d2d8/image.png" alt=""></p>
<h3 id="이미지-회전-rotation">이미지 회전 (Rotation)</h3>
<ul>
<li>이미지를 특정 각도로 회전시키는 방법</li>
<li>이미지 위치 변경과 마찬가지로 cv2.warpAffine() 함수를 사용하지만, cv2.getRotationMatrix2D()를 이용해 회전 행렬을 먼저 구해야 함.</li>
<li><strong><code>cv2.getRotationMatrix2D(center, angle, scale)</code></strong><ul>
<li><code>center</code> : (cx, cy) 형태의 회전 중심점 좌표</li>
<li><code>angle</code> : 회전 각도 (+일 때 반시계 방향)</li>
<li><code>scale</code> : 회전하면서 이미지 크기를 확대/축소하는 비율 (1.0 → 원본)</li>
</ul>
</li>
</ul>
<pre><code class="language-python">M = cv2.getRotationMatrix2D(
    center,   # 회전 중심 (tuple)
    angle,    # 회전 각도 (도 단위, +값 = 반시계방향)
    scale     # 크기 비율 (1.0이면 원본 크기 유지)
)
rotated = cv2.warpAffine(image, M, (width, height))</code></pre>
<pre><code class="language-python"># 예시 코드
height, width = image.shape[:2] # 이미지의 높이, 너비

# 회전 중심 (이미지의 중앙)
center = (width / 2, height / 2)

# 90도 회전 + 0.5배 스케일 (반시계방향)
M = cv2.getRotationMatrix2D(
    center=center,
    angle=90,
    scale=0.5
)

# 회전 적용
rotated = cv2.warpAffine(
    image, # 원본 이미지
    M, # 회전 행렬
    (width, height) # 출력 이미지 크기
)

print(&quot;[이미지 회전 : 90도 스케일 0.5]&quot;)
cv2_imshow(rotated)</code></pre>
<h1 id="5-산술-연산과-합성">5. 산술 연산과 합성</h1>
<h3 id="cv2add-vs-numpy-"><code>cv2.add()</code> vs Numpy <code>+</code></h3>
<ul>
<li><p>이미지 데이터는 보통 <code>uint8</code>이므로 값의 범위는 주로 0~255이다.</p>
</li>
<li><p><code>cv2.add()</code> : <strong>포화 연산(saturation arithmetic)</strong>을 사용하므로 255를 넘으면 더이상 커지지 않고 값이 255로 고정되는 특성이 있음. (ex) 250 + 10 = 255</p>
<p>  → 영상 픽셀의 범위가 <code>uint8</code> 일 때, 0 ≤ pixel ≤ 255를 유지하도록 함.</p>
</li>
<li><p>Numpy <code>+</code>  : <code>uint8</code> 배열끼리 <code>+</code> 를 하면 overflow가 발생할 수 있고, <code>uint8</code>에서 overflow가 나면 wrap-around가 발생할 수 있음.</p>
<ul>
<li>wrap-around : 값이 표현 가능한 범위를 넘었을 때, <strong>끝에서 다시 처음으로 돌아가는 것처럼 보이는 현상</strong></li>
</ul>
</li>
</ul>
<h3 id="saturation과-overflow의-차이">Saturation과 Overflow의 차이</h3>
<ul>
<li><p>Saturation : 값이 허용 범위를 넘으면 <strong>최댓값 또는 최솟값에 고정</strong>하는 방식</p>
</li>
<li><p>Overflow : 값이 범위를 넘으면 <strong>순환하면서 값이 바뀌는 현상, 즉 경계를 넘으면 다시 돌아감.</strong></p>
<p>  → 255 다음은 다시 0부터 시작하는 것처럼 동작함. (ex) 255 + 1 = 0, 250 + 10 = 4</p>
</li>
</ul>
<h3 id="alpha-blending">Alpha Blending</h3>
<p>Alpha blending은 두 영상을 일정 비율로 섞는 방법이다.</p>
<ul>
<li><code>alpha</code>가 크면 첫 번째 영상이 더 많이 반영되고, <code>alpha</code>가 작으면 두 번째 영상이 더 많이 반영됨.</li>
<li><code>blended = cv2.addWeighted(src1, alpha, src2, beta, gamma)</code><ul>
<li><code>alpha</code> : 첫 번째 이미지의 가중치<ul>
<li>각 영상이 최종 결과에 얼마나 기여하는지 결정하는 가중치</li>
</ul>
</li>
<li><code>beta</code> : 두 번째 이미지의 가중치</li>
<li><code>gamma</code> : 전체 결과에 추가되는 상수 값<ul>
<li>blending 결과 전체에 일정 값을 더해 결과를 더 밝게 만들 수 있음.</li>
</ul>
</li>
</ul>
</li>
<li>보통 alpha + beta = 1로 두면 자연스러운 혼합이 됨.</li>
</ul>
<p>$$
dst = \alpha⋅img1+β⋅img2+γ
$$</p>
<h3 id="mask의-의미">Mask의 의미</h3>
<ul>
<li>어디를 선택할지 를 결정하는 지도(map)</li>
<li>영상에서 <strong>어떤 부분만 선택적으로 처리할지 결정하는 필터 역할</strong>을 하는 배열<ul>
<li><strong>흰색 (255)</strong> : 처리할 영역</li>
<li><strong>검은색 (0)</strong> : 처리하지 않을 영역</li>
</ul>
</li>
<li>특정 영역만 추출 / 배경과 객체 분리 / 부분 합성 / 로고 삽입, 워터마크, 세그멘테이션 결과 시각화 등에 활용됨.</li>
</ul>
<pre><code class="language-python">h, w = image_1_aligned.shape[:2]
mask_circle = np.zeros((h, w), dtype=np.uint8)
center = (w // 2, h // 2)
radius = min(h, w) // 4
cv2.circle(mask_circle, center, radius, 255, -1) # 마스크에 흰색 원 그리기

# 원 안쪽 = 255, 바깥 = 0
mask_circle_inv = cv2.bitwise_not(mask_circle) # 마스크 반전
# img1에서 원 안쪽만 추출 (전경)
fg_circle = cv2.bitwise_and(image_1_aligned, image_1_aligned, mask=mask_circle)
# img2에서 원 바깥쪽만 추출 (배경)
bg_circle = cv2.bitwise_and(image_2_aligned, image_2_aligned, mask=mask_circle_inv)
combined_circle = cv2.add(fg_circle, bg_circle) # 최종 합성

show_images_grid([
    (&quot;원형 마스크&quot;, mask_circle),
    (&quot;mask inverse&quot;, mask_circle_inv),
    (&quot;image_1의 선택 영역&quot;, fg_circle),
    (&quot;image_2의 배경 영역&quot;, bg_circle),
    (&quot;최종 합성&quot;, combined_circle),
], cols=3, figsize=(15, 9))</code></pre>
<h1 id="6-임계점threshold-처리">6. 임계점(Threshold) 처리</h1>
<h3 id="기본-이진화-global-thresholding">기본 이진화 (Global Thresholding)</h3>
<pre><code class="language-python">ret, dst = cv2.threshold(
    src,            # 입력 그레이스케일 이미지 (single-channel 이미지)
    thresh,         # 임계값
    maxval,         # 임계값 이상일 경우 적용할 값
    type            # Threshold 타입, ex) cv2.THRESH_BINARY
)</code></pre>
<p><strong>주요 Threshold 타입</strong></p>
<ul>
<li><code>cv2.THRESH_BINARY</code> : 픽셀값 ≥ 임계값 → maxval, 픽셀값 &lt; 임계값 → 0</li>
<li><code>cv2.THRESH_BINARY_INV</code> : THRESH_BINARY의 반전</li>
<li><code>cv2.THRESH_TRUNC</code> : 픽셀값 &gt; 임계값 → 임계값, 픽셀값 ≤ 임계값 → 픽셀값 그대로</li>
<li><code>cv2.THRESH_TOZERO</code> : 픽셀값 ≥ 임계값 → 픽셀값 그대로, 픽셀값 &lt; 임계값 → 0</li>
<li><code>cv2.THRESH_TOZERO_INV</code> : THRESH_TOZERO의 반대</li>
</ul>
<pre><code class="language-python">if gray_image is not None:
  # 임계값 설정
  threshold_value = 127
  max_value = 255

  # (1) THRESH_BINARY - 픽셀 값이 127보다 크면 255, 작으면 0
  # 픽셀값 &gt;= 임계값 -&gt; maxval, 픽셀값 &lt; 임계값 -&gt; 0
  ret, thres_binary = cv2.threshold(gray_image, threshold_value, max_value, cv2.THRESH_BINARY)
  print(&#39;[THRESH_BINARY]&#39;)
  cv2_imshow(thres_binary)

  # (2) THRESH_BINARY_INV - 픽셀 값이 127보다 크면 0, 작으면 255
  # THRESH_BINARY 반전
  ret, thres_binary_inv = cv2.threshold(gray_image, threshold_value, max_value, cv2.THRESH_BINARY_INV)
  print(&#39;[THRESH_BINARY_INV]&#39;)
  cv2_imshow(thres_binary_inv)

  # (3) THRESH_TRUNC - 픽셀 값이 127보다 크면 127, 이하면 원래 값 유지
  # 픽셀값 &gt; 임계값 -&gt; 임계값, 픽셀값 &lt;= 임계값 -&gt; 픽셀값 그대로
  ret, thres_trunc = cv2.threshold(gray_image, threshold_value, max_value, cv2.THRESH_TRUNC)
  print(&#39;[THRESH_TRUNC]&#39;)
  cv2_imshow(thres_trunc)

  # (4) THRESH_TOZERO - 픽셀 값이 127보다 크면 원래 값 유지, 이하면 0
  # 픽셀값 &gt;= 임계값 -&gt; 픽셀값 그대로, 픽셀값 &lt; 임계값 -&gt; 0
  ret, thres_tozero = cv2.threshold(gray_image, threshold_value, max_value, cv2.THRESH_TOZERO)
  print(&#39;[THRESH_ZERO]&#39;)
  cv2_imshow(thres_tozero)

  # (5) THRESH_TOZERO_INV - 픽셀 값이 127보다 크면 0, 이하면 원래 값 유지
  # THRESH_TOZERO의 반전
  ret, thres_tozero_inv = cv2.threshold(gray_image, threshold_value, max_value, cv2.THRESH_TOZERO_INV)
  print(&#39;[THRESH_TOZERO_INV]&#39;)
  cv2_imshow(thres_tozero_inv)
else:
  print(&quot;gray_image가 None입니다. 확인 후 다시 시도하세요.&quot;)</code></pre>
<h3 id="적응형-임계점adaptive-threshold">적응형 임계점(Adaptive Threshold)</h3>
<p> 글씨나 조명이 고르지 않은 이미지에서는 글로벌한 임계값(고정된 임계값)만으로는 좋은 결과를 얻기 어렵다. 글로벌 임계값은 영상 전체에 하나의 고정된 threshold를 적용한다. 그러나 글씨나 문서 이미지에서 조명이 고르지 않으면 배경과 글씨의 밝기값이 위치에 따라 달라진다. 이 경우 어떤 영역에서는 배경과 글씨가 잘 구분되지만, 다른 영역에서는 배경이 지나치게 어둡거나 글씨가 흐려져 동일한 threshold로는 정확한 분리가 어렵다. 따라서 조명 변화가 큰 영상에서는 글로벌 threshold보다 영역별로 threshold를 다르게 적용하는 adaptive threshold가 더 적절하다. 적응형 임계점 처리를 활용하면 <strong>이미지를 작은 영역(블록)으로 나누어 각 영역에서 동적으로 임계값을 계산</strong>한다.</p>
<ul>
<li><code>src</code> : 입력 영상. 그레이스케일 영상</li>
<li><code>maxValue</code> : 임계값 함수 최댓값. 보통 255.</li>
<li><code>adaptiveMethod</code> : 블록 평균 계산 방법 지정. cv2.ADAPTIVE_THRESH_MEAN_C는 산술 평균, cv2.ADAPTIVE_THRESH_GAUSSIAN_C는 가우시안 가중치 평균</li>
<li><code>thresholdType</code> : cv2.THRESH_BINARY 또는 cv2.THRESH_BINARY_INV 지정</li>
<li><code>block_size</code> : 주변 픽셀의 영역 크기 (반드시 3 이상의 홀수)<ul>
<li>셀 주변 영역의 크기를 설정</li>
<li>임계값 이 클수록 더 넓은 영역을 고려하여 임계값을 결정하므로 크고 불규칙한 조명 변화에 더 잘 대처</li>
<li>임계값이 작을수록 미세한 디테일을 표현하는 데 더 적합</li>
</ul>
</li>
<li><code>C</code> : 실제 계산된 임계값에서 감산할 값 / 블록 내 평균값 또는 블록 내 가중 평균값에서 뺄 값. (x, y) 픽셀의 임계값으로 𝑇(𝑥, 𝑦) = 𝜇(𝑥, 𝑦 )− 𝐶 를 사용<ul>
<li>C 값이 클수록 임계값이 낮아져 더 많은 영역이 흰색(객체)으로 변환</li>
<li>아래 예시에선 block_size가 커지면 검은색 영역이, C 값이 커지면 흰색 영역이 커짐.</li>
<li>임계값 설정 결과를 세밀하게 조정</li>
<li>일반적으로 양수 값이지만 0 또는 음수 값도 가능</li>
<li><code>MEAN_C</code> : blockSize 내의 픽셀 값 평균을 이용해 임계값을 결정</li>
<li><code>GAUSSIAN_C</code> : blockSize 내의 픽셀 값에 가우시안 가중치를 주어 평균을 계산</li>
</ul>
</li>
</ul>
<pre><code class="language-python">dst = cv2.adaptiveThreshold(
    src,                          # 그레이스케일 이미지
    maxValue,                     # 임계값 이상일 때 적용할 값
    adaptiveMethod,               # (cv2.ADAPTIVE_THRESH_MEAN_C or cv2.ADAPTIVE_THRESH_GAUSSIAN_C)
    thresholdType,                # 보통 THRESH_BINARY 또는 THRESH_BINARY_INV
    blockSize,                    # 주변 픽셀을 참고할 영역 크기 (홀수)
    C                             # 평균이나 가우시안으로 계산된 임계값에서 빼줄 상수
)</code></pre>
<h3 id="otsus-binarization">Otsu&#39;s Binarization</h3>
<ul>
<li>오츄 알고리즘은 임계값을 사용자 지정 없이 자동으로 결정할 수 있도록 해준다.</li>
<li>영상의 히스토그램을 보고 <strong>배경과 객체를 가장 잘 구분하는 임계값(threshold)</strong> 을 <strong>자동으로 찾는 방법</strong></li>
<li>영상을 두 클래스(배경/객체)로 나눈다 생각하고, 이 둘이 가장 잘 분리되도록 하는 threshold를 찾음.</li>
<li>즉, 같은 그룹 안에서는 픽셀값이 비슷하고 서로 다른 그룹끼리는 픽셀값 차이가 크게 되도록 임계값을 골라 배경과 객체를 가장 깔끔하게 나눌 수 있는 기준값을 찾음.</li>
<li>다시 말해, 클래스 내 분산(within-class variance)을 최소화하거나, 클래스 간 분산(between-class variance)을 최대화하는 임계값을 선택함.</li>
<li>임계값 파라미터를 0으로 주고, cv2.THRESH_OTSU를 추가로 지정하면 자동으로 임계값을 결정한다.</li>
<li>오츄 알고리즘 적용 전 이미지를 가우시안 블러로 미리 스무딩하면 더 나은 결과가 나올 수 있다.</li>
</ul>
<pre><code class="language-python">ret, dst = cv2.threshold(
    src, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
)</code></pre>
<blockquote>
<ul>
<li>Basic thresholding where you have to manually supply a threshold value, T</li>
<li>Otsu’s thresholding, which automatically determines the threshold value</li>
<li>일반 threshold는 이미지 전체에 대해, adaptive threshold는 block 단위로 적용하므로 성능이 더 좋음. 다만, 후자는 연산량이 더 큼.</li>
</ul>
</blockquote>
<h1 id="7-도형-그리기">7. 도형 그리기</h1>
<p> 각 함수는 <code>img</code> 배열을 직접 수정합니다. 반환값을 다시 변수에 할당해도 되지만, <code>img</code> 자체가 변형됩니다.</p>
<h3 id="직선-line-그리기">직선 (Line) 그리기</h3>
<ul>
<li><code>cv2.line(img, start_point, end_point, color, thickness, lineType)</code><ul>
<li><code>img</code> : 도형을 그릴 대상 이미지 (numpy array)</li>
<li><code>start_point</code> : 시작 좌표 (x, y)</li>
<li><code>end_point</code> : 끝 좌표 (x, y)</li>
<li><code>color</code> : BGR 튜플 (ex) (255, 0, 0)</li>
<li><code>thickness</code> : 선 두께</li>
<li><code>lineType</code> : 선의 형태  (ex. <code>cv2.LINE_8</code>, <code>cv2.LINE_AA</code> 등)<ul>
<li><code>cv2.LINE_4</code>는 4-연결성을 이용하여 선을 그리는 방식으로, 상하좌우 방향 중심으로 픽셀이 연결되어 비교적 거친 선이 나타날 수 있다. <code>cv2.LINE_8</code>은 대각선 방향까지 포함하는 8-연결성을 사용하므로 더 자연스러운 선 표현이 가능하다. <code>cv2.LINE_AA</code>는 안티앨리어싱을 적용하여 경계 픽셀의 밝기를 조절함으로써 계단 현상을 줄이고 가장 부드러운 선을 만든다.</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># 직선 그리기 예제
image_line = np.full((512, 512, 3), 255, np.uint8)  # 흰색 배경
image_line = cv2.line(
    image_line,
    (0, 0),         # 시작 좌표
    (255, 255),     # 끝 좌표
    (255, 0, 0),    # 파란색 (BGR)
    3               # 두께
)
show_image(&quot;Line Drawing&quot;, image_line)</code></pre>
<h3 id="사각형-rectangle-그리기">사각형 (Rectangle) 그리기</h3>
<ul>
<li><code>cv2.rectangle(img, pt1, pt2, color, thickness)</code><ul>
<li><code>pt1</code> : 사각형의 한 꼭짓점 (x, y)</li>
<li><code>pt2</code> : 대각선 반대쪽 꼭짓점 (x, y)</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># 사각형 그리기 예제
image_rect = np.full((512, 512, 3), 255, np.uint8)
image_rect = cv2.rectangle(
    image_rect,
    (20, 20),       # 왼쪽 상단 꼭짓점
    (255, 255),     # 오른쪽 하단 꼭짓점
    (255, 0, 0),    # 파란색
    3               # 선 두께
)
show_image(&quot;Rectangle Drawing&quot;, image_rect)</code></pre>
<h3 id="원-circle-그리기">원 (Circle) 그리기</h3>
<ul>
<li><code>cv2.circle(img, center, radius, color, thickness)</code><ul>
<li><code>center</code> : 원의 중심 좌표 (x, y)</li>
<li><code>radius</code> : 원의 반지름</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># 원 그리기 예제
image_circle = np.full((512, 512, 3), 255, np.uint8)
image_circle = cv2.circle(
    image_circle,
    (256, 256),     # 중심
    80,             # 반지름
    (255, 0, 0),    # 파란색
    3               # 선 두께
)
show_image(&quot;Circle Drawing&quot;, image_circle)</code></pre>
<h3 id="다각형-polygon-그리기">다각형 (Polygon) 그리기</h3>
<ul>
<li><code>cv2.polylines(img, [points], isClosed, color, thickness)</code><ul>
<li><code>points</code> : (N, 1, 2) 형태의 정수 numpy array (다차원 배열)</li>
<li><code>isClosed</code> : 닫힌 도형 여부 (True/False)<ul>
<li><code>isClosed=True</code>로 설정하면 마지막 점과 첫 번째 점이 이어짐.</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># 다각형 그리기 예제
image_poly = np.full((512, 512, 3), 255, np.uint8)

# 점들을 정의 (x, y)
points = np.array([[50, 50], [150, 200], [300, 100], [400, 400]], np.int32)
points = points.reshape((-1, 1, 2))  # (N, 2) -&gt; (N, 1, 2)
image_poly = cv2.polylines(
    image_poly,
    [points],      # 리스트 형태로
    True,          # isClosed=True → 닫힌 도형
    (0, 0, 255),   # 빨간색
    3
)

show_image(&quot;Polygon Drawing&quot;, image_poly)</code></pre>
<h3 id="타원-ellipse-그리기">타원 (Ellipse) 그리기</h3>
<ul>
<li><code>cv2.ellipse(img, center, axes, angle, startAngle, endAngle, color, thickness)</code><ul>
<li><code>center</code> : (x, y)</li>
<li><code>axes</code> : (major_axis_length/2, minor_axis_length/2) 즉 (가로축, 세로축)</li>
<li><code>angle</code> : 타원의 회전 각도 (도 단위)  / 0이면 수평.</li>
<li><code>startAngle</code> , <code>endAngle</code> : 타원의 그릴 시작 각도와 끝 각도 / 0~360 전체 사용하면 완전한 타원</li>
</ul>
</li>
</ul>
<pre><code class="language-python">cv2.ellipse(
    img, center, axes,
    angle,       # 타원(회전각, 도 단위)
    startAngle,  # 시작 각도
    endAngle,    # 끝 각도
    color, thickness
)</code></pre>
<pre><code class="language-python"># 타원 그리기 예제
image_ellipse = np.full((512, 512, 3), 255, np.uint8)

image_ellipse = cv2.ellipse(
    image_ellipse,
    (256, 256),             # 중심
    (100, 50),              # (가로축/2, 세로축/2)
    30,                     # 회전 각도
    0, 360,                 # 시작 각도, 끝 각도
    (0, 255, 0),            # 초록색
    3                       # 선 두께
)

show_image(&quot;Ellipse Drawing&quot;, image_ellipse)</code></pre>
<h3 id="텍스트-text-추가">텍스트 (Text) 추가</h3>
<ul>
<li><code>cv2.putText(img, text, org, fontFace, fontScale, color, thickness)</code><ul>
<li><code>org</code> : 텍스트 시작 좌표 (x, y)</li>
<li><code>fontFace</code> : 글꼴 (예: cv2.FONT_HERSHEY_SIMPLEX)</li>
<li><code>fontScale</code> : 글자 크기 (배율)</li>
</ul>
</li>
</ul>
<pre><code class="language-python">cv2.putText(
    img, text, org,
    fontFace, fontScale,
    color, thickness,
    lineType
)</code></pre>
<pre><code class="language-python"># 텍스트 추가 예제
image_text = np.full((512, 512, 3), 255, np.uint8)

image_text = cv2.putText(
    image_text,
    &#39;Hello World&#39;,         # 텍스트 내용
    (50, 250),             # 시작 위치 (x, y)
    cv2.FONT_HERSHEY_SIMPLEX,   # 폰트
    1,                      # 크기 (배율)
    (255, 0, 0),            # 파란색
    2                       # 두께
)

show_image(&quot;Text Drawing&quot;, image_text)</code></pre>
<h1 id="8-애니메이션을-gif로-저장하기">8. 애니메이션을 GIF로 저장하기</h1>
<h3 id="애니메이션-gif-저장-방법">애니메이션 GIF 저장 방법</h3>
<ol>
<li>여러 프레임을 반복문 등을 통해 생성 후, <code>frames</code> 리스트에 저장</li>
<li><code>imageio.mimsave()</code> 함수를 사용하여 GIF 파일로 저장<ol>
<li><code>imageio.mimsave(&#39;output.gif&#39;, frames, fps=2)</code><ol>
<li><code>fps</code>  : 초당 프레임 수 (2 → 1초에 2프레임, 0.5초 간격)</li>
<li><code>frames</code> : RGB 형식의 numpy 배열로 구성</li>
</ol>
</li>
<li>(ex) imageio.mimsave(&#39;output.gif&#39;, frames, fps=2)</li>
</ol>
</li>
<li>GIF 재생 확인</li>
</ol>
<pre><code class="language-python"># Colab에서 output.gif를 직접 표시
with open(&#39;output.gif&#39;,&#39;rb&#39;) as f:
    display(Image(data=f.read()))</code></pre>
<h1 id="9-contours-처리">9. Contours 처리</h1>
<h3 id="contours">Contours</h3>
<ul>
<li>영상에서 관심있는 객체(물체)의 경계선(외곽)을 따라서 연결된 점들의 집합</li>
<li>Contours를 찾는 것은 이미지에서 물체의 여러 특성을 계산 가능하기 때문에 중요함.</li>
<li>예를 들어, 물체의 위치, 면적, 둘레 길이, 중심점, 모양 확인(분류 - 삼각형, 사각형 ...), 객체 개수 파악, Bounding box 등에 활용 가능함.</li>
</ul>
<h3 id="cv2findcontours"><code>cv2.findContours()</code></h3>
<ul>
<li><code>images</code> : 8비트 단일 채널(Grayscale). 일반적으로 Threshold나 Canny 에지 검출 등으로 전처리가 된 <strong>이진화 이미지</strong></li>
<li><code>mode</code> : 윤곽선을 찾는 방식 (어떤 윤곽선을 어떤 범위까지 찾을 것인가?)<ul>
<li><code>cv2.RETR_EXTERNAL</code> : <strong>가장 바깥쪽 윤곽선</strong>만 검색 (도넛의 경우 바깥 테두리는 찾지만, 안쪽 테두리는 무시) - 보통 물체 개수 셀 때 이용함.</li>
<li><code>cv2.RETR_TREE</code> : <strong>모든 윤곽선</strong>을 검색하고 <strong>계층 구조</strong>를 형성 (바깥, 안쪽, 그 외 다른 경계를 모두 찾음) - 복잡한 형태 분석에 주로 사용함.</li>
</ul>
</li>
<li><code>method</code> : 윤곽선 근사화 방식 (찾은 윤곽선을 어떤 방식으로 얼마나 자세히 저장할 것인가?)<ul>
<li>모든 경계선을 다 저장할 수도 있고, 예를 들면 직선 구간의 경우 중간 점은 생략하고 시작/끝 점만 저장 가능</li>
<li><code>cv2.CHAIN_APPROX_NONE</code> : 윤곽선 위에 있는 모든 픽셀들의 모든 좌표를 저장. 가장 자세히 표현, 요구 메모리/연산량 증가</li>
<li><code>cv2.CHAIN_APPROX_SIMPLE</code> : 직선 구간은 시작점과 끝점만 저장함으로써 메모리 절약</li>
</ul>
</li>
<li><strong><code>contours</code></strong> : 윤곽선의 점들 좌표를 담고 있는 리스트<ul>
<li>리스트의 각 원소가 하나의 윤곽선 (이미지 안에 물체가 1개 있으면 contours도 1개, 여러 개이면 contours도 여러 개)</li>
<li>각 contour는 다시 점 좌표들의 배열</li>
</ul>
</li>
<li><strong><code>hierarchy</code></strong> : 윤곽선의 계층 구조를 담고 있는 배열임.<ul>
<li>예를 들어, 도넛 모양의 경우 아래가 둘 다 존재함.<ul>
<li>바깥 테두리 contour (부모)</li>
<li>안쪽 테두리 contour (자식)</li>
</ul>
</li>
<li>hierarchy는 계층적 관계 표현<ul>
<li>이전/다음 contour index</li>
<li>부모/첫 번째 자식 contour index</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># 이진화된 이미지에서 경계선을 찾아서 contours를 반환 (Binarized image)
contours, hierarchy = cv2.findContours (
  image, # 입력 이미지 (이진화된 형태가 일반적)
  # 어떤 형태의 윤곽선을 찾을 건지
  mode,  # 윤곽선 검색 방법 (cv2.RETR_EXTERNAL, cv2.RETR_TREE 등)
  # 찾은 윤곽선을 어떻게 저장할 것인지
  method # 윤곽선 근사화 방법 (cv2.CHAIN_APPROX_SIMPLE 등)
)</code></pre>
<h3 id="cv2drawcontours"><code>cv2.drawContours()</code></h3>
<pre><code>output = cv2.drawContours(
  image, # 윤곽선을 그릴 대상 이미지 (BGR 컬러)
  contours, # 찾은 윤곽선 리스트
  contouridx, # -1이면 모든 윤곽선을 그림
  color, # BGR
  thickness # 선 두께
)</code></pre><blockquote>
<p><strong>Contour와 Edge의 차이</strong></p>
<ul>
<li>Contour는 객체 중심, 외곽선을 모두 딴 것 (binary object에서 전체 boundary 경계선 따는 거)<ul>
<li>닫힌 형태 / 전처리가 안 된 경우 불완전하게 표현되기도 함.</li>
<li>컬러 이미지 자체를 보면 색상에 따라서 판단 기준이 모호해지므로 Grayscale/Binary 이미지로 변환해서 명확히 봄.</li>
</ul>
</li>
<li>Edge는 밝기 변화가 큰 지점을 찾는 것</li>
</ul>
</blockquote>
<h3 id="사각형-외곽bounding-rectangle">사각형 외곽(Bounding Rectangle)</h3>
<ul>
<li>윤곽선으로부터 가장 작은(Axis-Aligned) 사각형 영역을 구하는 것</li>
<li>Contour로부터 가장 fit한 사각형을 찾는 것</li>
<li>Bounding Rectangle은 contour를 감싸는 가장 작은 직사각형을 구하는데, 이 직사각형은 기울어질 수 없고, 항상 이미지의 x축, y축 방향에 맞춰진 상태여야 함.</li>
<li>객체는 기울어져 있어도 Bounding Box는 기울어지지 않은 형태</li>
<li>기울이지 않는 조건 하에서 contour를 전부 포함하는 최소 직사각형</li>
</ul>
<pre><code class="language-python">+---------+
|   /---- |
|  / obj  |
| /----   |
+---------+

x, y, w, h = cv2.boundingRect(contour)
- (x, y)는 사각형의 왼쪽 위 꼭짓점 좌표
- (w, h)는 사각형 너비와 높이
- 직교된 사각형(Axis-Aligned Bounding Box)이므로 이미지 축에 평행함.</code></pre>
<h3 id="convex-hull">Convex Hull</h3>
<ul>
<li>윤곽선의 점들을 모두 포함하는 블록 다각형(Convex Polygon) 중 가장 작은 것</li>
<li>윤곽선(contour)에 있는 점들을 <strong>전부 감싸는 가장 바깥쪽의 볼록한 껍데기</strong>를 구하는 것<ul>
<li><strong>Contour</strong>: 실제 물체의 경계, 오목한 부분까지 그대로 포함</li>
<li><strong>Convex Hull</strong>: 그 경계를 바깥에서 볼록하게 감싼 형태</li>
</ul>
</li>
<li>유클리드 공간에서 점들의 집합에 속하는 모든 점들을 연결하는 선분을 포함하는 집합</li>
<li>Contour를 모두 감싸는 작은 hull을 계산하는 것</li>
</ul>
<pre><code>hull = cv2.convexHull(contour)
cv2.drawContours(hull, [hull], -1, (0, 0, 255), 2)</code></pre><ul>
<li>hull은 점들의 집합을 반환하며, 이를 drawContours로 시각화할 수 있음.</li>
</ul>
<h3 id="유사-다각형-근사-polygon-approximation">유사 다각형 근사 (Polygon Approximation)</h3>
<ul>
<li>더 적은 점으로 윤곽선 근사화 (윤곽선의 직선 구간이 많은 경우 점을 크게 줄여 효율적 표현 가능)</li>
<li>Contour를 다각형 형태로 근사</li>
</ul>
<pre><code>approx = cv2.approxPolyDP(contour, epsilon, closed)</code></pre><ul>
<li>epsilon : 근사 오차(정밀도) 파라미터. epsilon이 클수록 점이 더 많이 생략</li>
<li>closed : 윤곽선이 닫혀있는지 여부 (True/False)</li>
</ul>
<pre><code class="language-python"># Approximate the contour to a polygon
epsilon = 0.02 * cv2.arcLength(contour, True) # 2% of the contour perimeter
approx = cv2.approxPolyDP(contour, epsilon, True)

print(&quot;Number of points in original contour : &quot;, len(contour))
print(&quot;Number of points in approximated polygon : &quot;, len(approx))

# Draw the approximated polygon in yellow
output_approx = output.copy()
cv2.drawContours(output_approx, [approx], -1, (0, 255, 255), 2)

show_image(&quot;Approximated Polygon&quot;, output_approx)</code></pre>
<h3 id="contours-기본-정보">Contours 기본 정보</h3>
<ul>
<li>윤곽선(Contour)에 대해 다양한 속성 계산 가능<ol>
<li>면적(Area) : <code>cv2.arcLength(contour)</code></li>
<li>둘레(Perimeter) : <code>cv2.arcLength(contour, closed=True)</code></li>
<li>모멘트(Moments) : <code>cv2.moments(contour)</code><ol>
<li>모양의 기하학적 분포를 수치로 표현하는 값</li>
<li>질량 중심(centroid) 등을 구하는데 사용 가능</li>
</ol>
</li>
</ol>
</li>
<li><code>M = cv2.moments(contour)</code>라 하면,<ul>
<li><code>cx = moments[&#39;m10&#39;] / moments[&#39;m00&#39;]</code></li>
<li><code>cy = moments[&#39;m01&#39;] / moments[&#39;m00&#39;]</code></li>
<li>이때 M10, M01이 의미하는 바는?</li>
<li>m10 : x 방향 공간 모멘트 / m01 : y 방향 공간 모멘트 (도형의 픽셀들이 어디에 얼마나 분포하는지)</li>
<li>cv2.moments(contour)에서 반환되는 M10과 M01은 공간 모멘트(Spatial Moments) 중 1차 모멘트들로, 윤곽선(contour)의 위치 특성을 나타내는 픽셀의 합임.</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># Contour basic info
area = cv2.contourArea(contour)
perimeter = cv2.arcLength(contour, True)
moments = cv2.moments(contour)
print(&quot;Contour Area:&quot;, area)
print(&quot;Contour Perimeter:&quot;, perimeter)
print(&quot;Moments:&quot;, moments)
# Calculate centroid using moments
if moments[&#39;m00&#39;] != 0:
    cx = int(moments[&#39;m10&#39;] / moments[&#39;m00&#39;])
    cy = int(moments[&#39;m01&#39;] / moments[&#39;m00&#39;])
    print(f&quot;Centroid: ({cx}, {cy})&quot;)
else:
    print(&quot;Contour area is zero, cannot compute centroid.&quot;)</code></pre>
<h3 id="minarearect">minAreaRect</h3>
<ul>
<li>Contours에서 기울어진 사각형을 구하는 함수</li>
<li>BoundingRect은 축에 평행한 직사각형을 구하지만, minAreaRect은 회전된 직사각형 중 면적이 가장 작은 것을 찾음.</li>
</ul>
<pre><code class="language-python"># rect 는 ( (cx, cy), (width, height), angle ) 형태로 반환
# cv2.boxPoints(rect) 를 이용해 사각형 4점의 좌표를 얻을 수 있음.
rect = cv2.minAreaRect(contour)
box = cv2.boxPoints(rect)
box = np.int0(box)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[KNN, K-Means]]></title>
            <link>https://velog.io/@inhwaaa_v/KNN-K-Means</link>
            <guid>https://velog.io/@inhwaaa_v/KNN-K-Means</guid>
            <pubDate>Thu, 01 Jan 2026 15:28:33 GMT</pubDate>
            <description><![CDATA[<h1 id="knn-알고리즘-k-nearest-neighbor">kNN 알고리즘 (k-Nearest Neighbor)</h1>
<p>&nbsp;kNN(k-Nearest Neighbor) 알고리즘은 k개의 가장 가까운 이웃 데이터를 찾고, 그 이웃들의 레이블을 기반으로 분류/회귀 문제를 해결하는 알고리즘을 의미한다.</p>
<p>&nbsp;kNN 알고리즘은 새 데이터가 입력으로 들어오면, 이 포인트가 어떤 클래스에 속해있는지를 찾는다. 이를 위해 <strong>k개의 이웃을 찾</strong>고, <strong>이웃 중 가장 많은 클래스에 속한 레이블을 해당하는 데이터 포인트에 속하는 클래스라고 지정</strong>한다. 이때, <strong>k 값에 따라 결과가 달라질 수 있</strong>으며, 데이터의 분포와 패턴이 명확할 때 더 잘 작동하는 경향이 있다. k 값은 일반적으로 홀수를 선택한다. (짝수로 선택하게 되면 Voting 시 동점이 발생할 수 있기 때문이다.)</p>
<p>&nbsp;kNN 알고리즘은 데이터 포인트가 가장 많이 속한 클래스로 분류를 수행한다. 따라서 특징 공간에 있는 모든 데이터에 대한 정보가 필요하다. 이러한 점 때문에 모든 학습 데이터를 저장해야 해서 메모리의 양이 크고, 기존 모든 포인트와의 모든 거리를 계산해야 해서 계산 복잡도가 크다는 문제점이 있어 데이터와 클래스가 많을수록 효율성이 낮아진다. 하지만, 직관적이고 이해하기 쉬우며, 사전 학습 시간(학습 시간)이 필요하지 않다는 장점이 있다.</p>
<ul>
<li><p><strong>핵심</strong> : 거리 기반 이웃 찾기, <strong>kNN은 학습하는 알고리즘이 아니다! 거리 기반 계산임.</strong>
<img src="https://velog.velcdn.com/images/inhwaaa_v/post/07dfe52c-7b32-4de8-8a9e-136b8f1cd44e/image.png" alt=""></p>
</li>
<li><p><strong>정의</strong> : 새로운 샘플 x에 대해 학습 데이터의 가장 가까운 k개 이웃의 다수결(분류) 또는 평균(회귀)으로 예측하는 비모수, 메모리 기반(lazy) 알고리즘</p>
<ul>
<li><strong>비모수(non-parameter)</strong> : 모집단이 특정 분포를 따른다는 가정을 하지 않고 통계적 분석을 하는 방법. 다시 말해, 데이터의 분포나 함수 형태에 대해 특정한 가정을 두지 않는 것.</li>
<li>예를 들어 선형 회귀 모델의 경우엔 $y=ax+b$와 같이 미리 정해진 형태를 가정하므로 모수적이다. 하지만, kNN은 모델 파라미터가 고정되어 있지 않고, 어떠한 함수 형태나 파라미터를 학습하지 않으므로 비모수적이라고 할 수 있다.</li>
</ul>
</li>
<li><p><strong>거리 척도</strong> : 기본은 유클리드 거리($ℓ_2$)임. 대안 : 맨해튼($ℓ_1$), 민코프스키($ℓ_p$), 코사인 등.</p>
</li>
</ul>
<p>$$
d(\mathbf{x}, \mathbf{x_i}) = \sqrt{\sum_{j=1}^{d} (x_j - x_{ij})^2}
$$</p>
<ul>
<li><strong>가중 kNN</strong> : <code>weights=&#39;distance&#39;</code>로 가까운 이웃에 더 큰 가중치를 부여하는 것</li>
<li><strong>하이퍼파라미터</strong> : <code>k</code>, <code>metric</code>(거리 측정 방법), <code>weights</code>(가중치 결정 방법, uniform - 동일 가중치, distance - 거리에 반비례하는 가중치), <code>p</code>(민코프스키 차수)<ul>
<li><code>weights</code>를 distance로 부여하면, 거리에 반비례하는 가중치를 부여하므로 예측값은 더 가까운 이웃에 더 큰 영향을 받게 됨. (가까운 거리에 더 큰 가중치를 부여하게 되므로)</li>
<li>Bias-Variance Trade-off : k↓ → 경계 세밀(분산↑, 과적합), k↑ → 경계 매끈 (편향↑, 과소적합)</li>
<li>전처리 : 거리 기반이므로 스케일 영향이 큼 → 표준화/정규화 권장</li>
<li>복잡도 : 학습 비용 작음, 예측 시 $O(n⋅d)$<ul>
<li>대량 데이터면 KD-Tree/볼-트리/근사 최근접 탐색 고려</li>
</ul>
</li>
</ul>
</li>
<li><strong>유클리드 거리</strong> : 각 차원의 차를 제곱해서 사용하는 것으로, n차원에서 두 점 사이의 거리를 구할 경우 다음과 같이 나타냄.</li>
</ul>
<p>$$
\sqrt{(a_1 - b_1)^2 + (a_2 - b_2)^2 + \cdots + (a_n - b_n)^2}
$$</p>
<ul>
<li><strong>맨해튼 거리</strong> : 각 차원의 차를 제곱해서 사용하는 것이 아닌, 절댓값을 바로 합산하는 것을 의미하며, 맨해튼 거리는 항상 유클리드 거리보다 크거나 같다.</li>
</ul>
<p>$$
|a_1 - b_1| + |a_2 - b_2| + \cdots + |a_n - b_n|
$$</p>
<ul>
<li><strong>민코프스키 거리</strong> : 유클리드 거리와 맨해튼 거리를 일반화한 거리로, 차이의 절댓값에 거듭제곱을 취하고 다시 루트를 적용한 형태임.<ul>
<li>p=1일 경우 맨해튼 거리, p=2일 경우 유클리드 거리, p=$\infty$일 경우 체비쇼프 거리와 동일함.</li>
</ul>
</li>
</ul>
<p>$$
(|a_1 - b_1|^p + |a_2 - b_2|^p + \cdots + |a_n - b_n|^p)^{\frac{1}{p}}
$$</p>
<pre><code>knn = KNeighborsClassifier(n_neighbors=6)
knn.fit(X_train, y_train)

y_pred = knn.predict(X_test)
scores = metrics.accuracy_score(y_test, y_pred)</code></pre><pre><code># kNN은 전처리의 영향을 많이 받는다.
# 표준화 전이 표준화 후보다 성능이 더 높음.
# 표준화는 무조건 해야 한다 X, 성능을 보고 무엇(하는 것 or 안 하는 것)이 더 좋은지 판단해야 함.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_score, KFold

# shuffle은 데이터를 섞는 것으로, 학습 시 순서에 의존하는 것을 방지해줌.
cv = KFold(n_splits=5, shuffle=True, random_state=42) # n_splits=5이므로 5-Fold CV
raw_model = KNeighborsClassifier(n_neighbors=5)

# cross_val_score(estimator, X, y, cv=None) : 교차 검증을 통해 점수를 평가하는 교차 평가 함수
score_raw = cross_val_score(raw_model, X, y, cv=cv).mean() # 정규화 전 CV Score

# 파이프라인이란, 전처리 → 학습 → 평가로 이어지는 일련의 과정들을 하나로 묶는 것임.
pipe = Pipeline([(&#39;scaler&#39;, StandardScaler()), (&#39;knn&#39;, KNeighborsClassifier(n_neighbors=5))]) # 정규화 후 knn
score_scaled = cross_val_score(pipe, X, y, cv = cv).mean() # 정규화 후 CV Score

print(&quot;CV 정확도 (무스케일) : &quot;, score_raw)
print(&quot;CV 정확도 (표준화) : &quot;, score_scaled)</code></pre><h2 id="머신러닝-성능-평가">머신러닝 성능 평가</h2>
<h3 id="confusion-matrix-혼동-행렬">Confusion Matrix (혼동 행렬)</h3>
<ul>
<li><strong>혼동 행렬</strong> : 예측값과 실제값 사이 관계를 행렬 형태로 표현한 것</li>
<li>F1 Score는 정밀도와 재현율의 조화평균을 의미하며, 한쪽 클래스에 치우친(클래스 편향이 있을 때) 예측 성능을 과대평가하지 않도록 하여 불균형 데이터에서의 평가 편향을 완화한다. (→ 편향된 모델을 좋은 모델로 평가하지 않는다)</li>
<li>정밀도와 재현율 중 하나라도 작으면, 그 값의 역수가 커져 조화평균(F1)이 크게 감소한다. F1은 Precision과 Recall 중 작은 값에 의해 지배된다.</li>
<li>조화 평균은 역수의 산술평균의 역수를 의미한다. 역수의 차원에서 평균을 구하고, 다시 역수를 취해 원래 차원의 값으로 되돌리는 것이다. $x = \frac {2ab}{a+b}$와 같이 표현한다.</li>
</ul>
<p><strong>결과 해석</strong></p>
<ul>
<li><strong>정확도</strong> : 전체 문제 중에서 정답을 맞춘 비율<ul>
<li>Accuracy = TP + TN / TP + TN + FP + FN</li>
</ul>
</li>
<li><strong>정밀도 (Precision)</strong> : True라고 분류한 것 중 실제로 True인 것의 비율 (Positive 정답률)<ul>
<li>Precision = TP(True Positive) / (TP(True Positive) + FP(False positive))</li>
</ul>
</li>
<li><strong>재현율 (Recall)</strong> : 실제 True인 것 중에서 True라고 예측한 것의 비율<ul>
<li>Recall = TP(True Positive) / (TP(True Positive) + FN(False Negative))</li>
<li>Sensitivity 혹은 Hit Rate라고도 불림.</li>
</ul>
</li>
<li><strong>F1-score</strong> : Precision과 Recall은 상호보완적이기 때문에, Recall을 올리면 Precision이 내려가고, Precision을 올리면 Recall이 내려갈 수 밖에 없다. 이를 보완하기 위해 생겨난 것이 Recall과 Precision의 조화평균인 F1 score이다.<ul>
<li>Precision과 Recall의 조화평균으로 0.0~ 1.0 사이의 값을 가지며, 값이 1에 가까울수록 좋은 모델이다.</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/5fe00924-063e-40fc-abba-8faeebff9f34/image.png" alt=""></p>
<h3 id="분류-리포트">분류 리포트</h3>
<ul>
<li><strong>macro avg</strong> : 각 클래스의 성능 지표를 <strong>동일한 가중치로 평균</strong>한 값<ul>
<li>모든 클래스를 동일하게 중요하게 취급하므로 클래스 불균형이 심하면 낮게 나오는 경향</li>
</ul>
</li>
<li><strong>weighted avg</strong> : 각 클래스 성능을 <strong>해당 클래스 샘플 수(support)로 가중 평균</strong><ul>
<li>데이터 분포를 반영한 평균이므로 클래스 불균형에 둔감함.</li>
</ul>
</li>
</ul>
<pre><code># Confusion Matrix를 판단할 땐, 대각 성분을 봐야 한다.
# Confusion Matrix의 x축은 모델, y축은 정답을 의미함.
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.metrics import ConfusionMatrixDisplay

digits = load_digits() # 데이터 로드
n_samples = len(digits.images) # 데이터 개수
# knn은 이미지 형태 그대로 처리가 불가능함. (거리 계산을 해야 하기 때문)
# 따라서 reshape을 통해 이미지를 1차원 벡터로 펼침.
# shape 출력 결과를 보면 알 수 있듯이, 8*8 이미지 -&gt; 64 길이 벡터로 처리하여 knn이 처리 가능하도록 함
print(digits.images.shape)
data = digits.images.reshape((n_samples, -1))
print(data.shape)

Xd_tr, Xd_te, yd_tr, yd_te = train_test_split(data, digits.target, test_size=0.2, random_state=0) # train, test 데이터 분할
knn_d = KNeighborsClassifier(n_neighbors=6) # knn 알고리즘
knn_d.fit(Xd_tr, yd_tr) # 학습
yd_pred = knn_d.predict(Xd_te) # 예측
print(&quot;Digits 정확도 : &quot;, accuracy_score(yd_te, yd_pred))

# ConfusionMatrixDisplay.from_estimator(추정치, X, y) : 추정치, 데이터, 레이블이 주어졌을 때 혼동 행렬을 그리는 함수
# 실제 레이블과 예측 레이블만이 주어졌을 땐 ConfusionMatrixDisplay.from_predictions(y_true, y_pred)
disp = ConfusionMatrixDisplay.from_estimator(knn_d, Xd_te, yd_te) # knn 예측 결과(추정치) 기반 실제 정답과 비교한 혼동 행렬을 그림
plt.title(&quot;Digits Confusion Matrix (KNN)&quot;)
plt.show()</code></pre><h1 id="k-means-클러스터링">K-Means 클러스터링</h1>
<p>&nbsp;K-Means 클러스터링은 레이블이 없는 데이터를 학습하는 비지도 학습의 가장 대표적인 알고리즘이다. 이는 주어진 n개의 관측 값을 k개의 클러스터로 분할하며, 관측 값들은 거리가 최소인 클러스터로 분류된다.</p>
<p>&nbsp;K-Means 클러스터링 알고리즘은 초기 중심점 위치에 따라 결과가 크게 달라지고, 때로는 도메인 지식에 따라서도 결과가 달라질 수 있다. 따라서 여러 번 중심점(centroid)를 설정해 최적의 값을 찾는데, 이것의 대표적인 방법에는 Elbow method와 Silhouette analysis가 있다.</p>
<p>&nbsp;이는 많은 데이터셋에 대해서도 클러스터링 성능이 좋다는 장점이 있다. 그러나, 클러스터 모양이 복잡하거나 크기가 다르면 제한적인 성능을 보이므로 다른 알고리즘을 고려해야 한다.</p>
<p>&nbsp;K-means 클러스터링은 군집 중심점(centroid)라는 특정한 k개의 임의 지점을 선택해 해당 중심에 가장 가까운 포인트들을 선택하는 군집화 기법으로, 다음과 같은 과정에 의해 수행된다.</p>
<ol>
<li>초기 중심점 설정</li>
<li>중심점을 선택된 포인트들의 평균 지점으로 이동함.</li>
<li>이동된 중심점에서 다시 가까운 포인트를 선택함.</li>
<li>다시 중심점을 평균 지점으로 이동하는 프로세스를 반복적으로 수행함.</li>
</ol>
<ul>
<li><p><strong>정의</strong> : k개의 중심(centroid)를 추정하여 각 샘플을 가장 가까운 중심에 할당하고, 중심을 재계산하는 과정을 반복하여 군집 내 제곱합(SSE)를 최소화함.</p>
</li>
<li><p><strong>목표 함수</strong></p>
<p>  $$
  J = \sum_{i=1}^{k} \sum_{x_j \in C_i} | \mathbf{x}_j - \boldsymbol{\mu}_i |^2 
  $$</p>
</li>
</ul>
<ul>
<li><strong>알고리즘</strong> : 초기 중심 설정 → 할당 단계 (closest centroid) → 업데이트 단계 (평균 재계산) → 수렴 시 종료<ul>
<li><strong>주의</strong> : 초기값 민감성(기본 k-means++), 비구형/밀도 가변 데이터에서 한계 → <strong>대안</strong> : DBSCAN, GMM</li>
</ul>
</li>
<li>kNN 알고리즘은 학습을 하지 않는 알고리즘이지만, K-Means 알고리즘은 학습을 함.</li>
</ul>
<pre><code>import numpy as np
from sklearn.cluster import KMeans

X_simple = np.array([
    [6,3], [11, 15], [17,12], [24,10], [20,25], [22, 30],
    [85,70], [71,81], [60, 79], [56,52], [81, 91], [80, 81]
]) # 데이터

# kMeans 알고리즘, k(중심점)=2
# 두 개의 클러스터 중심점을 반복 계산해 학습
kmeans2 = KMeans(n_clusters=2, random_state=0).fit(X_simple)
print(&quot;중심점 : \n&quot;, kmeans2.cluster_centers_) # 학습 이후 찾은 중심점 좌표 출력

# 원본 데이터 출력
plt.figure()
plt.scatter(X_simple[:, 0], X_simple[:,1]) # 원본 데이터 표시
plt.title(&quot;Raw Points for K-Means&quot;)
plt.xlabel(&#39;x1&#39;)
plt.ylabel(&#39;x2&#39;)
plt.show()

# K-Means 알고리즘 수행 후 결과
plt.figure()
# 각 데이터에 지정한 클러스터에 따라 표시
plt.scatter(X_simple[:, 0], X_simple[:,1], c = kmeans2.labels_)
# centroid 좌표 표시
plt.scatter(kmeans2.cluster_centers_[:, 0], kmeans2.cluster_centers_[:,1], marker=&#39;x&#39;, s=200) 
plt.title(&quot;K-Means Result (k=2)&quot;)
plt.xlabel(&#39;x1&#39;)
plt.ylabel(&#39;x2&#39;)
plt.show()</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[주성분분석과 군집화]]></title>
            <link>https://velog.io/@inhwaaa_v/%EC%A3%BC%EC%84%B1%EB%B6%84%EB%B6%84%EC%84%9D%EA%B3%BC-%EA%B5%B0%EC%A7%91%ED%99%94</link>
            <guid>https://velog.io/@inhwaaa_v/%EC%A3%BC%EC%84%B1%EB%B6%84%EB%B6%84%EC%84%9D%EA%B3%BC-%EA%B5%B0%EC%A7%91%ED%99%94</guid>
            <pubDate>Thu, 01 Jan 2026 15:22:37 GMT</pubDate>
            <description><![CDATA[<h1 id="주성분분석-pca">주성분분석 (PCA)</h1>
<h2 id="차원의-저주">차원의 저주</h2>
<p>&nbsp;차원의 저주(Curse of dimensionality)란, 학습 데이터에 비해 입력 차원의 수가 큰 경우 일정 차원을 기점으로 학습 능력이 급격히 감소하는 현상을 의미한다. 다시 말해, 특징 공간의 차원이 증가하면서 학습 데이터의 수가 특징 공간의 차원의 수보다 적어져 성능이 저하되는 것이다.</p>
<p>&nbsp;차원이 증가할 수록 특징 공간의 부피가 커지고, 개별 차원 내에서의 데이터의 밀도가 희소해지며, 이에 따라 거리 함수가 제대로 작동하지 않고, 계산 비용이 증가하는 등의 문제가 발생하는데, 이러한 문제를 차원의 저주라고 한다.</p>
<p>&nbsp;그러나, 입력 차원 수가 증가한다고 반드시 차원의 저주가 발생하는 것은 아니며, 학습 데이터보다 입력 차원의 수가 많아지는 경우에 차원의 저주 문제가 발생한다. 공간이 희소해짐에 따라, 저차원 데이터에서 패턴을 파악하는 것보다 고차원 데이터에서 패턴을 파악하는데 더 많은 데이터가 필요해지기 때문이다.</p>
<p>&nbsp;이러한 차원의 저주는 차원이 증가함에 따라 동일한 데이터 개수로는 공간을 충분히 채울 수 없게 되어 데이터의 분포가 희소해지기에 발생한다. 입력 데이터의 차원이 증가하면, 특징 공간의 부피가 차원에 따라 기하급수적로 증가하여 데이터 간 거리가 멀어지고, 학습 데이터의 밀도가 낮아진다.</p>
<p>&nbsp;이러한 차원의 저주 문제를 해결하기 위한 이론적인 해결책은 훈련 샘플의 밀도가 충분히 높아질 때까지 데이터를 모아서 훈련 세트의 크기를 키우는 것이다. 그러나, 일정 밀도에 도달하기 위해 필요한 훈련 샘플 수는 차원의 수가 커짐에 따라 기하급수적으로 늘어난다는 문제가 존재한다. 따라서 PCA, SVD 등과 같은 차원 축소 기법들을 통해 학습 결과에 영향을 미치지 않는 불필요한 축을 줄임으로써 차원의 저주를 완화하기도 한다.</p>
<blockquote>
<p>“The curse of dimensionality refers to various phenomena that arise when analyzing and organizing data in high-dimensional spaces (often with hundreds or thousands of dimensions) that do not occur in low-dimensional settings such as the three-dimensional physical space of everyday experience.</p>
</blockquote>
<h2 id="차원-축소-방법">차원 축소 방법</h2>
<h3 id="특징-선택-feature-selection">특징 선택 (Feature Selection)</h3>
<ul>
<li>변수들 중 중요한 변수만 몇 개 고르고 나머지는 버리는 방법으로, 변수 간 중첩이 있는지, 어떤 변수가 중요한 변수인지, 어떤 변수가 종속 변수에 영향을 크게 주는 변수인지를 분석할 필요가 있음.</li>
<li>변수 간의 중첩을 확인하는 방법으로 상관관계를 주로 사용함.</li>
</ul>
<h3 id="특징-추출-feature-extraction">특징 추출 (Feature Extraction)</h3>
<ul>
<li>변수들을 조합해 데이터를 잘 표현하는 주성분을 가진 새로운 변수를 추출하는 방법</li>
<li>(ex) 주성분분석 (PCA, Principal Component Analysis)</li>
</ul>
<h3 id="선형-판별-분석-lda--linear-discrimination-analysis">선형 판별 분석 (LDA : Linear Discrimination Analysis)</h3>
<ul>
<li>학습 과정 중 클래스를 가장 잘 구분하는 축(axis)을 학습하는 방법</li>
</ul>
<h2 id="pca-principal-component-analysis">PCA (Principal Component Analysis)</h2>
<p>&nbsp;PCA는 데이터의 분산을 최대한 보존하면서 서로 직교하는 주성분을 찾아 고차원 공간의 표본들을 저차원 공간으로 변환하는 기법을 의미한다. 이를 위해 데이터를 잘 표현하는 초평면을 정의한 뒤, 데이터를 이 초평면에 projection하여 분산이 최대로 보존되는 축을 선택한다.</p>
<p>&nbsp;다시 말해, PCA는 다변량 데이터의 차원을 축소하면서 정보 손실을 최소화하는 방법으로, 데이터의 분산을 최대한 보존하는 새로운 축(주성분)을 찾아 원래 데이터를 이 주성분에 투영함으로써 차원을 축소하는 방법이다. 이를 통해 데이터의 중요한 정보를 유지하면서 차원을 줄이고, 시각화 및 기계 학습 알고리즘의 성능을 향상시킬 수 있다.</p>
<p>&nbsp;이러한 PCA의 과정은 다음과 같다.</p>
<ol>
<li><strong>데이터 전처리</strong> : 데이터를 표준화(평균 0, 표준편차 1)하거나 정규화(최소값 0, 최댓값 1)하여 스케일 조정</li>
<li><strong>공분산 행렬 계산</strong> : 데이터의 공분산 행렬을 계산합니다. 공분산 행렬은 변수 간의 선형 관계를 나타내며, 이를 통해 데이터의 분포와 구조를 파악할 수 있습니다.</li>
<li><strong>고윳값 및 고유벡터 계산</strong> : 공분산 행렬의 고윳값과 고유벡터를 계산합니다. 고윳값은 데이터의 분산을 나타내고, 고유벡터는 주성분의 방향을 나타냅니다. (입력 데이터들의 공분산 행렬에 대한 고유값 분해)<ol>
<li>고유벡터 : 주성분 벡터. 데이터 분포에서 분산이 큰 방향.</li>
<li>고유값 : 분산의 크기</li>
</ol>
</li>
<li><strong>주성분 선택</strong> : 고유값이 큰 순서대로 주성분을 선택합니다. 주성분 개수를 선택하는 방법으로, 누적 설명 분산 비율(cumulative explained variance ratio)을 활용할 수 있습니다. 누적 설명 분산 비율이 일정 수준(예: 95% 또는 99%) 이상인 주성분까지 선택하여 차원 축소를 수행합니다.</li>
<li><strong>주성분으로 원본 데이터 변환</strong> : 선택된 주성분(고유 벡터)을 이용하여 원본 데이터를 변환합니다. 이 과정에서 원본 데이터의 차원이 축소되며, 새로운 주성분 축에 투영된 데이터를 얻게 됩니다.</li>
</ol>
<h3 id="주성분을-찾는-방법">주성분을 찾는 방법</h3>
<p>&nbsp;주성분을 찾기 위해선 특이값 분해(Singular Value Decomposition, SVD)를 사용한다. 이는 정방행렬이 아닌 경우(비정방행렬)에 대해서도 행렬 분해를 수행하기 위해 활용하는 방법이다. 이를 위해 임의의 행렬을 $A=UΣV^T$의 형태 행렬로 분해한다.</p>
<ul>
<li>$A=UΣV^T$<ul>
<li>$A$ : 𝑚 × 𝑛 matrix (주어진 𝑚 × 𝑛 행렬)</li>
<li>$U$ : 𝑚 × 𝑚 orthogonal matrix (직교행렬)</li>
<li>$Σ$ : 𝑚 × 𝑛 diagonal matrix (대각행렬)</li>
<li>$V$ : 𝑛 × 𝑛 orthogonal matrix (직교행렬)</li>
</ul>
</li>
</ul>
<p>&nbsp;이때, 고유값은 행렬 A가 직사각행렬이면 입출력 차원이 달라 고유값을 직접 정의할 수 없다. 따라서 길이(스케일 크기)만 추출하기 위해 $AA^T$, $A^TA$의 결과가 항상 정방 행렬이고 대칭행렬인 점을 활용한다.</p>
<ul>
<li><p>$U$ (왼쪽 특이행렬) :  $AA^T$의 고유 벡터를 열에 배치한 n × n 행렬</p>
</li>
<li><p>$Σ$  (특이값 리스트) : $AA^T$의 고유값의 제곱근을 대각선에 배치한 대각 행렬 (n × m 행렬)</p>
</li>
<li><p>$V^T$ (특이행렬) : $A^TA$의 고유 벡터를 열에 배치한 m × m 행렬</p>
<p>  $$
  C = \frac{1}{n} X^T X = V \left( \frac{\Sigma^2}{n} \right) V^T ;\Rightarrow; \text{PCA 고유벡터} = V, \quad \text{고유값} = \frac{\Sigma^2}{n}
  $$</p>
</li>
</ul>
<h3 id="고유값-분해-eigen-decomposition"><strong>고유값 분해 (Eigen-Decomposition)</strong></h3>
<p>&nbsp;고유값 분해 (Eigen-Decomposition)란 행렬을 고유값과 고유 벡터를 통해 분해하는 기법으로, 어떤 정방행렬 A를 $A = QΛQ^{-1}$의 형태로 쪼개는 것을 의미한다. 이때, Q는 A의 고유벡터를 열에 배치한 행렬, Λ는 고유값을 대각선에 배치한 대각행렬을 의미한다.</p>
<p>&nbsp;이렇게 고유값 분해를 수행하면 행렬을 그 행렬만의 좌표계(고유 벡터)로 바꾸어 단순 대각 형태로 만들어 준다. 이때, 고유값이 크다는 것은 그 방향에 데이터의 정보량이 많다는 의미이기에 정보량이 큰 데이터만 남기고 차원을 축소할 수 있기 때문에 PCA에서 주로 활용된다.</p>
<ul>
<li>대각화 가능하다 = 행렬 A를 고유 벡터와 고유값으로 분해할 수 있다</li>
<li>다시 말해, 고유값 분해가 가능하고, $A = QΛQ^{-1}$의 형태로 표현이 가능하다는 것</li>
</ul>
<p>&nbsp;고유값 분해를 해주면, 대각행렬 $Λ$가 단순 스케일 행렬임을 볼 수 있다. (이는 표준 기저에서 봤을 땐 복잡한 변환이었는데, 좌표계를 고유벡터 기준으로 바꿨더니 A가 단순히 각 좌표 성분을 $\lambda_i$배 해주는 행렬로 보이더라! 라는 느낌으로 이해하면 된다.)</p>
<p>&nbsp;$Q$의 열벡터들을 고유벡터로 구성한 이유는 결국엔 이 벡터들이 만드는 좌표계가 고유기저이기 때문이고, 결국 고유값 분해는 이 고유 기저를 기준으로 행렬을 분해하는 것을 의미한다고 볼 수 있다.</p>
<p><strong>고유값 분해 예시</strong></p>
<ul>
<li><strong>고유값</strong> : $det(A - \lambda I) = 0$</li>
<li><strong>고유벡터</strong> : $(A - \lambda I) V = 0$</li>
<li><strong>고유값들의 합</strong> : 대각 요소들의 합</li>
<li><strong>고유값들의 곱</strong> : 행렬식</li>
<li><strong>정규직교벡터</strong> : 서로 수직이고, 각각 길이가 1인 벡터들<ul>
<li>$Q^TQ = 1, Q^{-1} = Q^T$</li>
</ul>
</li>
</ul>
<h1 id="군집화-clustering">군집화 (Clustering)</h1>
<p>&nbsp;군집화는 유사한 속성들을 갖는 관측치들을 묶어 전체 데이터를 몇 개의 군집(cluster)로 나누는 것을 의미한다. 다시 말해, 어떠한 레이블 없이 데이터 내에서 거리가 가까운 것들끼리 각 군집들로 분류하여 데이터 내에 숨어있는 패턴 혹은 그룹을 파악해 서로 묶는 것이다. 이때, 군집(cluster)은 비슷한 특성을 가진 데이터의 집합을 의미한다. 군집화는 군집 내 응집도와 군집 간 분리도를 최대화하는 것을 목적으로 하며, 이러한 군집화 알고리즘에는 K-Means Clustering, Mean Shift, Gaussian Mixture Model, DBSCAN, Agglomerative Clustering 등이 있다.</p>
<h2 id="거리-척도">거리 척도</h2>
<h3 id="유클리드-거리">유클리드 거리</h3>
<ul>
<li>각 차원의 차를 제곱해서 사용하는 것으로, n차원에서 두 점 사이의 거리를 구할 경우 다음과 같이 나타냄.</li>
</ul>
<p>$$
d_E(x, y) = \left( \sum_{i=1}^{n} |x_i - y_i|^2 \right)^{\frac{1}{2}} = \sqrt{ \sum_{i=1}^{n} |x_i - y_i|^2 }
$$</p>
<h3 id="맨하탄-거리">맨하탄 거리</h3>
<ul>
<li>각 차원의 차를 제곱해서 사용하는 것이 아닌, 절댓값을 바로 합산하는 것을 의미하며, 맨해튼 거리는 항상 유클리드 거리보다 크거나 같다.</li>
</ul>
<p>$$
d_M(x, y) = \sum_{i=1}^{n} |x_i - y_i|
$$</p>
<h3 id="마할라노비스-거리">마할라노비스 거리</h3>
<ul>
<li>변수들의 공분산(상관관계)을 고려해 거리를 측정하는 방식</li>
<li>$S^{-1}$ : covariance matrix</li>
</ul>
<p>$$
d_{\text{Mahalanobis}}(X,Y) = \sqrt{(X - Y)^T S^{-1} (X - Y)}
$$</p>
<p><strong>공분산 행렬 (Covariance Matrix)</strong></p>
<ul>
<li>각 확률 변수 사이의 공분산을 모두 구해 행렬화한 것</li>
<li>대각선에는 각 변수의 분산, 그 외 위치에는 변수 쌍들의 공분산이 표현됨.</li>
</ul>
<h2 id="k-means-알고리즘">K-means 알고리즘</h2>
<p>&nbsp;<strong>K-Means</strong> 클러스터링은 클러스터링에서 가장 일반적으로 사용되는 알고리즘으로, 군집 중심점(centroid)이라는 특정한 임의의 지점을 선택해 해당 중심에 가장 <strong>가까운</strong> 포인트들을 선택하는 군집화 기법이다. K-Means이므로 K개의 centroid를 지정할 수 있고, 이때 가장 가까운 포인트를 선택한다는 점에서 K-Means는 <strong>거리 기반 군집화 방법</strong>임을 알 수 있다.</p>
<p>&nbsp;이처럼 K-means 클러스터링은 군집 중심점(centroid)라는 특정한 k개의 임의 지점을 선택해 해당 중심에 가장 가까운 포인트들을 선택하는 군집화 기법으로, 다음과 같은 과정에 의해 수행된다.</p>
<ol>
<li>초기 중심점 설정</li>
<li>중심점을 선택된 포인트들의 평균 지점으로 이동함.</li>
<li>이동된 중심점에서 다시 가까운 포인트를 선택함.</li>
<li>다시 중심점을 평균 지점으로 이동하는 프로세스를 반복적으로 수행함.</li>
<li>즉, 초기 중심 설정 → 할당 단계 (closest centroid) → 업데이트 단계 (평균 재계산) → 수렴 시 종료의 과정을 반복함.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/cfeb3645-3244-4eff-b7ef-4780bf61d040/image.png" alt=""></p>
<h3 id="inertia-sse">inertia (=SSE)</h3>
<ul>
<li>Centroid와 Sample들 사이의 거리 제곱 합</li>
<li>K-Means 알고리즘은 k개의 중심(centroid)를 추정하여 각 샘플을 가장 가까운 중심에 할당하고, 중심을 재계산하는 과정을 반복하여 군집 내 제곱합(SSE)를 최소화함.</li>
</ul>
<p><strong>참고 - Rule of Thumb</strong></p>
<ul>
<li><p>일상적인 상황에서 간단하게 적용할 수 있는 규칙이나 원리</p>
</li>
<li><p>k : cluster의 개수이고, N : sample 개수일 때,</p>
<p>  $$
  k = \sqrt{\frac{N}{2}}
  $$</p>
</li>
</ul>
<h3 id="silhouette-score">Silhouette Score</h3>
<ul>
<li>최적의 cluster 개수를 찾는 방법</li>
<li>클러스터링의 품질을 정량적으로 계산해 주는 방법으로, 데이터의 응집도를 나타내는 값인 $a^{(i)}$, 클러스터 간 분리도를 나타내는 값인 $b^{(i)}$를 통해 실루엣 계수 $s^{(i)}$를 계산함.<ul>
<li>$a_i$ : i번째 샘플이 속한 클러스터 내부의 평균 거리</li>
<li>$b_i$ : i번째 샘플과 가장 가까운 클러스터 샘플들과의 평균 거리</li>
</ul>
</li>
</ul>
<p>$$
s^{(i)} = \frac{b^{(i)} - a^{(i)}}{\max { a^{(i)}, b^{(i)} }}
$$</p>
<ul>
<li>실루엣 계수는 클러스터 내 데이터 포인트 간의 거리는 가깝고, 클러스터 간 거리는 멀면 높은 값을 지니며, -1~+1 사이의 값을 지님.</li>
<li>클러스터 개수가 최적화되어 있다면 분리도의 값은 커지고, 응집도의 값은 작아지기에 실루엣 계수는 1에 가까워짐. 결국 실루엣 계수가 1에 가까울 수록 클러스터 개수가 최적화된 것임.</li>
</ul>
<h2 id="mean-shift-알고리즘">Mean-Shift 알고리즘</h2>
<p>&nbsp;<strong>평균 이동(Mean Shift)</strong>은 K-means와 유사하게 중심을 군집의 중심으로 지속적으로 움직이면서 군집화를 수행한다. 그러나 K-means 방법이 중심에 소속된 데이터의 평균 거리 중심으로 이동을 한다면, Mean Shift는 중심을 데이터가 모여 있는 <strong>밀도가 가장 높은 곳</strong>으로 이동시킨다는 점에서 그 차이가 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/a0998a84-d177-440b-a0bb-4d0491c518b7/image.png" alt=""></p>
<p>&nbsp;Mean Shift는 특정 대역폭(bandwidth;region of interest)을 가지고 최초의 확률 밀도 중심 내에서 데이터의 <strong>확률 밀도</strong>가 더 높은 곳으로 중심을 이동한다. (centroid-based algorithm라고 한다)</p>
<p>&nbsp;Mean Shift는 데이터 간의 거리가 아닌 데이터의 분포도를 이용해 군집 중심점을 잡는다. 군집 중심점은 데이터 포인트가 모여 있는 곳이라는 아이디어에 착안한 것이며, 이를 위해 <strong>확률 밀도 함수를 이용</strong>한다. 일반적으로 <strong>확률 밀도 함수를 찾기 위해 KDE(Kernel Density Estimation)를 이용</strong>한다. Mean Shift 알고리즘은 <strong>임의의 포인트에서 시작해 데이터 내의 확률 밀도 peak 포인트를 찾을 때까지 KDE를 반복적으로 적용하며 군집화를 수행</strong>한다.</p>
<p>&nbsp;Mean Shift 알고리즘은 K-Means와 다르게 군집의 개수를 지정할 필요가 없다. 다만 Bandwidth의 크기에 따라 알고리즘 자체에서 군집의 개수를 최적화하므로, Bandwidth의 최적화가 필요하다</p>
<p>&nbsp;또한, Mean Shift 알고리즘은 K-평균과 달리 초기 중심점을 무작위로 선택하는 방식이 아니므로 초기값 설정에 따른 결과의 변화가 거의 없고, 초기화 과정에서 모든 데이터 포인트를 잠재적인 클러스터 중심으로 설정한다. 따라서 K-Means와 달리 랜덤 초기화에 의존하지 않으며, 이로 인해 실행할 때마다 결과가 항상 동일하다는 장점이 존재한다. (모든 데이터 포인트에서 각각이 밀도 높은 방향으로 올라가다 보면 자연스럽게 같은 peak로 수렴하는 시작점들이 생김 → 그때 같은 peak에 도착한 시작점들을 하나의 cluster로 묶음. 따라서 Mean Shift의 클러스터 개수 = 데이터 밀도 분포의 피크(Mode)의 개수)</p>
<p><strong>정리</strong></p>
<ul>
<li><strong>Mean Shift 알고리즘</strong> : 주어진 Bandwidth 내에서 KDE(Kernel Density Estimation)를 이용해 데이터의 밀도 분포를 추정하고, 밀도가 높은 방향으로 중심점을 반복적으로 이동시키면서 군집화를 수행하는 모델</li>
<li>각 샘플은 주변 데이터의 밀도가 가장 높은 방향으로 이동하게 되며, 이러한 이동 과정이 수렴하게 되면 그 점은 하나의 클러스터 중심이 된다. 즉, Mean Shift는 데이터 밀도 기반으로 군집 중심을 자동 탐색하는 알고리즘이다.</li>
<li>중심을 군집의 중심으로 지속적으로 움직이면서 군집화를 수행한다는 점이 K-Means 알고리즘과 유사하나, K-Means 알고리즘은 중심에 소속된 데이터의 평균 거리 중심으로 이동하는 데 반해, Mean-Shift 알고리즘은 중심을 데이터가 많이 모여 있는, 밀도가 가장 높은 곳으로 이동한다.</li>
<li>즉, Mean-Shift 알고리즘은 데이터의 분포도를 통해 군집 중심점을 찾는 알고리즘으로, <strong>가장 집중적으로 데이터가 모여있어 확률 밀도 함수가 피크인 점을 군집 중심점으로 선정</strong>하며, 일반적으로 <strong>주어진 모델의 확률 밀도 함수를 찾기 위해 KDE(Kernel Density Estimation)를 이용</strong>한다.</li>
<li>Mean Shift 알고리즘에서의 <strong>Bandwidth</strong>는 <strong>각 데이터 포인트 주변에서 밀도를 계산할 때 고려할 이웃 데이터의 반경</strong>을 정의하며. <strong>Bandwidth 크기에 따라 군집 수가 결정</strong>된다. 이때,  탐색 반경이 너무 크면 정확한 중심 위치를 찾을 수 없고, 너무 작으면 local minimum에 빠지기 쉽다.</li>
</ul>
<h3 id="kde-kernel-density-estimation">KDE (Kernel Density Estimation)</h3>
<ul>
<li>커널(kernel) 함수를 통해 어떤 변수의 확률 밀도 함수를 추정하는 대표적인 방법</li>
<li>KDE는 확률 변수의 확률 밀도 함수를 사용하여 각 반복에서 데이터 밀도가 높은 영역을 식별한다.</li>
<li>개별 데이터 포인트에 커널 함수(Gaussian 등)를 적용한 값들을 모두 합한 후 평균을 구하여 확률 밀도 함수를 추정하는 방식</li>
<li>다시 말해, KDE는 각 데이터 포인트에 Gaussian 같은 커널을 올리고, 모든 커널을 합쳐 확률 밀도 함수를 만든 뒤 Mean Shift가 이 density에서 가장 높은 지점을 찾도록 돕는 방법</li>
</ul>
<h1 id="코드">코드</h1>
<ul>
<li><strong>PCA 수행</strong> : <code>class PCA(n_components=None, svd_solver=&#39;auto&#39;)</code><ul>
<li><strong>유지할 주성분의 수 (또는 비율) :</strong> <code>n_components</code><ul>
<li><strong>수 지정</strong> : <code>PCA(n_components=2)</code></li>
<li><strong>비율 지정</strong> : <code>PCA(n_components=0.95)</code></li>
</ul>
</li>
<li><strong>특이값 분해 수행 방식 결정 :</strong> <code>svd_solver:{‘auto’, ‘full’, ‘arpack’, ‘randomized’}</code><ul>
<li>PCA에서 계산 시 어떤 방식으로 특이값 분해를 수행할지 결정하는 옵션</li>
<li>전체 데이터를 사용할 건지, 일부 성분을 근사적으로 뽑아 빠르게 계산할 건지 결정<ul>
<li><code>full</code> : full SVD(특이 분해) 실행</li>
<li><code>arpack</code> : n_components로 잘린 SVD 실행</li>
<li><code>randomized</code> : Halko 등의 방법으로 무작위 SVD 실행</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><strong>KMeans 수행</strong> : <code>class KMeans(n_clusters=8, init=&#39;k-means++’, n_init=10, max_iter=300, random_state=None, algorithm=&#39;auto&#39;)</code><ul>
<li><code>n_clusters</code> : cluster의 개수</li>
<li><code>init</code> : {&#39;k-means++&#39;, &#39;random’}<ul>
<li>k-means++ : 수렴 속도를 높이기 위한 스마트한 방식으로, 첫 번째 중심은 아무거나 선택하고, 나머지 중심들은 <strong>이미 선택된 중심들과 최대한 멀리</strong> 있도록 선택함.</li>
</ul>
</li>
<li><code>n_init</code> : 다른 centroid로 시도할 횟수</li>
<li><code>algorithm</code> : {“auto”, “full”, “elkan”}<ul>
<li><code>full</code> : classical EM-style algorithm<ul>
<li>EM(Expectation Maximization) 알고리즘</li>
</ul>
</li>
<li><code>elkan</code> : ****삼각부등식으로 거리 계산을 줄이는 최적화</li>
<li><code>auto</code> : dense면 elkan, sparse면 full</li>
</ul>
</li>
</ul>
</li>
<li><strong>실루엣 점수 계산</strong> : <code>silhouette_score(X, labels, metric= &#39;euclidean&#39;)</code></li>
<li><strong>Mean-Shift 수행</strong> : <code>class MeanShift(bandwidth=None, seeds=None, n_jobs=None, max_iter=300)</code><ul>
<li><code>bandwidth</code> : float value used in the RBF kernel</li>
<li><code>seeds</code> : used to initialize kernels</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[앙상블과 랜덤 포레스트]]></title>
            <link>https://velog.io/@inhwaaa_v/%EC%95%99%EC%83%81%EB%B8%94%EA%B3%BC-%EB%9E%9C%EB%8D%A4-%ED%8F%AC%EB%A0%88%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@inhwaaa_v/%EC%95%99%EC%83%81%EB%B8%94%EA%B3%BC-%EB%9E%9C%EB%8D%A4-%ED%8F%AC%EB%A0%88%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Thu, 01 Jan 2026 15:18:26 GMT</pubDate>
            <description><![CDATA[<h1 id="앙상블-ensemble-learning">앙상블 (Ensemble Learning)</h1>
<p>&nbsp;앙상블 기법이란, 여러 개 분류기를 생성하고, 그 예측을 결합함으로써 보다 정확한 예측을 도출하는 기법으로, Strong Classifier를 사용하는 대신 Weak Classifier를 조합하여 더 정확한 예측을 수행한다.</p>
<p>&nbsp;이는 단일 모델의 예측을 평가하는 것보다 더 많은 계산이 필요하므로 더 많은 연산 능력을 활용해 더 좋은 예측력을 가지게 된다.</p>
<h2 id="앙상블-기법의-유형">앙상블 기법의 유형</h2>
<ul>
<li>보팅 (Voting) : 여러 개 분류기가 투표를 통해 최종 예측 결과를 결정하는 방식</li>
<li>배깅 (Bagging : Bootstrap Aggregating) : 데이터 샘플링을 통해 모델을 학습시키고 결과를 집계하는 방식</li>
<li>부스팅 (Boosting) : 여러 개 분류기가 순차적으로 학습을 수행하는 방식 (다음 분류기에게 가중치 부스팅)</li>
<li>스태킹 (Stacking : Stacked Generalization) : 여러 개 분류기 결과를 취합하는 마지막 예측기(블렌더 또는 메타 학습기)를 학습하는 방법</li>
</ul>
<h2 id="앙상블-기법의-특징">앙상블 기법의 특징</h2>
<ul>
<li>앙상블 모델은 Heterogeneous하고 Independent한 특성을 지니고, 낮은 상관관계를 지녀야 성능이 좋아짐. (즉, 모델의 종류나 학습 방식이 서로 다르고, 독립적이어야 함.)</li>
<li>이러한 특성을 지녀야 서로 다른 종류의 오차를 생성할 가능성이 높고, 높은 정확도를 얻을 수 있으며, 성능을 분산시키므로 과적합 감소 효과를 얻을 수 있음.</li>
<li>앙상블이 잘 작동하려면 모델들이 같은 실수를 반복하지 않는 것이 중요한데, 모든 모델이 같은 방향으로 틀리면 앙상블 효과가 떨어짐. (하지만, 모델들이 서로 다른 관점에서 판단하면 투표나 평균을 취했을 때 앙상블 효과가 높아지므로 정확도가 높아질 가능성이 높음.)</li>
<li>즉, 오차가 서로 상관되지 않는 것이 중요함.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/fc39cb6f-1937-4d64-b591-422590998982/image.png" alt=""></p>
<h2 id="보팅-voting">보팅 (Voting)</h2>
<p>&nbsp;<strong>Voting은 서로 다른 알고리즘을 가진 분류기 중 투표를 통해 최종 예측 결과를 결정하는 방식</strong>으로, 최종 결과 선정 방식에 따라 Hard Voting과 Soft Voting으로 분류된다.</p>
<h3 id="하드-보팅-hard-voting">하드 보팅 (Hard Voting)</h3>
<ul>
<li>분류기 각각의 예측 결과를 활용하는 방법으로, <strong>다수의 분류기가 예측한 결과 값을 최종 결과로 선정</strong>하므로 직접 투표에 해당함.</li>
<li>다시 말해, 각각의 분류기 결과값 중 가장 많은 것을 따르는 방식임.</li>
<li>확률의 신뢰도가 낮거나 모델들이 확률 출력을 제공하지 않는 경우 사용됨.</li>
</ul>
<h3 id="소프트-보팅-soft-voting">소프트 보팅 (Soft Voting)</h3>
<ul>
<li>예측 결과의 확률값을 활용하는 방법으로, 모든 분류기가 예측한 레이블 값의 결정 확률 평균을 계산해 가장 확률이 높은 레이블 값을 최종 결과로 선정하므로 간접 투표에 해당함.</li>
<li>분류기의 확률을 더하고 각각 평균을 내서 <strong>확률이 제일 높은 값을 선정</strong>하는 방식임.</li>
<li>확률 예측이 가능한 모델들(즉, 확률의 신뢰도가 높음)이고 모델의 성능 차이가 큰 경우 사용됨.</li>
<li>하드 보팅에 비해 성능이 좋은 편에 속함.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/37a594d4-aa2c-4f57-96ab-6d9b9831bbdd/image.png" alt=""></p>
<pre><code>from sklearn.tree import DecisionTreeClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.ensemble import VotingClassifier
from sklearn.metrics import accuracy_score

X_train, X_test, y_train, y_test = train_test_split(X, y) # 데이터 분할

dt_clf = DecisionTreeClassifier(max_depth=4) # Decision Tree 객체 생성 (트리 최대 깊이 = 4)
knn_clf = KNeighborsClassifier(n_neighbors=7) # KNN 분류기 생성 (예측 시 참고할 주변 데이터 포인트 개수 = 7)
svm_clf = SVC(gamma=0.1, probability=True) # SVC 객체 생성 (probability=True이므로 클래스 확률 출력 제공)

models = [(&#39;dt&#39;, dt_clf), (&#39;knn&#39;, knn_clf), (&#39;svc&#39;, svm_clf)] # 모델 정의
voting_clf = VotingClassifier(estimators=models, voting=&#39;soft&#39;, weights=[2, 1, 2]) # 소프트 보팅 객체 생성 (weight는 각 모델에 대한 가중치 비율/중요도를 지정함.)
voting_clf.fit(X_train, y_train)

# 여러 분류기를 한 번에 학습시키고 성능 비교
for clf in (dt_clf, knn_clf, svm_clf, voting_clf):
    clf.fit(X_train, y_train) # 학습
    y_pred = clf.predict(X_test) # 예측
    print(clf.__class__.__name__, accuracy_score(y_test, y_pred)) # 성능 측정 (정확도)</code></pre><h2 id="배깅-bagging-boostrap-aggregating">배깅 (Bagging, Boostrap Aggregating)</h2>
<p>&nbsp;배깅이란, <strong>부트스트랩으로 생성된 여러 데이터셋을 통해 Weak Learner를 훈련시키고, 그 결과를 Voting하여 최종 예측</strong>을 만드는 것을 의미한다.</p>
<p>&nbsp;다시 말해, 데이터 샘플링(Bootstrap)을 통해 모델을 학습시키고, 결과를 집계(Aggregating)하는 방법으로, <strong>모두 같은 유형의 알고리즘 기반 분류기를 활용</strong>한다. 즉, 원본 데이터를 여러 번 랜덤 샘플링 해서 각기 다른 학습 데이터를 만들고, <strong>동일한 알고리즘의 여러 모델을 학습</strong>시킨 뒤 예측을 평균 또는 투표로 결합하는 방법을 배깅이라고 한다.</p>
<ul>
<li><strong>부트스트랩(bootstrap)</strong> : 통계학에서 사용하는 용어로, 무작위 샘플링을 통해 새로운 데이터셋을 생성하는 것을 의미함.
  (ex) 한 식자재 마트에 들어오는 상추의 신선도를 알기 위해 마트에 입고되는 모든 상추 중 임의로 100개를 뽑아 상추의 신선도 평균을 구하는 것</li>
<li>부트스트랩은 raw data의 분포를 추정할 때 사용할 수 있음.
  예를 들어, 측정된 데이터 중에서 중복을 허용한 복원 추출로 n개를 뽑고, 뽑은 n개의 평균을 구하는 것을 m번 반복하여 모으게 되면 평균에 대한 분포를 구할 수 있게 되고, 이로부터 샘플 평균에 대한 신뢰 구간을 추정할 수 있게 됨. 이를 통해 데이터가 부족해도 여러 데이터셋을 가진 것처럼 모델을 반복 학습하는 효과를 낼 수 있음.</li>
<li>즉, <strong>배깅(Bagging)은 부트스트랩(bootstrap)을 집계(Aggregating)하여</strong> 학습 데이터가 충분하지 않더라도 충분한 학습효과를 주어 높은 bias의 underfitting 문제나, 높은 variance로 인한 overfitting 문제를 해결하는데 도움을 줌.</li>
<li>일반적으로 배깅에선 데이터 분할 시 중복을 허용하는 방식을 활용하며, 만약 중복을 허용하지 않는다면 그 방식을 페이스팅(Pasting)이라고 한다.</li>
<li>집계 방법에는 <strong>다수결 투표 방식으로 결과를 집계하는 Categorical Data 방법</strong>과 <strong>평균값을 집계하는 Continuous Data</strong>가 있음.</li>
<li>대표적인 Bagging 알고리즘에는 랜덤 포레스트(Random Forest)가 있음.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/8cf15527-2557-47a8-9309-60f22befb905/image.png" alt=""></p>
<pre><code>from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier

dt_clf = DecisionTreeClassifier() # 결정 트리 객체 생성
# 배깅 객체 생성, 결정 트리 모델을 기본으로 사용하며, 내부적으로는 이를 500번 복제하여 사용
# 다시 말해, Decision Tree 500개를 랜덤하게 학습시키고, 그 결과를 투표해서 최종 예측하는 배깅 앙상블 모델임.
# 각 트리는 서로 다른 부트스트랩 샘플로 학습되며, 원본 데이터에서 중복 허용하여 랜덤하게 뽑은 데이터로 각 트리를 학습함.
# 트리마다 조금씩 다른 데이터 -&gt; 서로 다른 결정 경계 -&gt; 오차 상관 감소 -&gt; 앙상블 효과 증가
bag_clf = BaggingClassifier(dt_clf, n_estimators=500)
bag_clf.fit(X_train, y_train) # 학습

# 단일 Decision Tree 모델과 배깅 분류기 성능 차이 비교
# 배깅은 여러 트리의 결과를 활용하므로 분산을 줄이고 일반화 성능을 향상시킴.
for clf in (dt_clf, bag_clf):
    clf.fit(X_train, y_train) # 학습
    y_pred = clf.predict(X_test) # 예측
    print(clf.__class__.__name__, accuracy_score(y_test, y_pred)) # 성능 측정 (정확도)</code></pre><pre><code>from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score

X, y = make_moons(n_samples=500, noise=0.30, random_state=42) # 랜덤 데이터 500개 생성
X_train, X_test, y_train, y_test = train_test_split(X, y) # 데이터 분할

# 대표적인 Bagging 알고리즘 - 랜덤 포레스트(Random Forest)
clf = RandomForestClassifier(n_estimators=500) # 랜덤 포레스트 객체 생성
clf.fit(X_train, y_train) # 학습

y_pred = clf.predict(X_test) # 예측
print(f&quot;{clf.__class__.__name__} : {accuracy_score(y_test, y_pred):.4f}&quot;) # 성능 측정 (정확도)</code></pre><h2 id="부스팅-boosting">부스팅 (Boosting)</h2>
<p>&nbsp;<strong>순차적으로 모델을 학습시키고, 이전 모델이 잘못 예측한 부분을 다음 모델이 개선할 수 있도록 하는 방법</strong>으로, <strong>각 모델의 예측 결과를 가중 평균하여 최종 예측</strong>을 만들어 낸다.</p>
<p>&nbsp;다시 말해, 이전 분류기가 틀린 예측을 한 데이터에 대해 올바른 예측이 가능하도록 다음 분류기에게 가중치를 부여하면서 학습과 예측을 진행해 계속해서 가중치를 부스팅하며 학습을 진행한다.</p>
<p>&nbsp;이는 배깅과 유사한 매커니즘을 지니나, <strong>배깅과는 다르게 순차적으로 진행</strong>된다는 차이가 있다. 앞선 Bagging의 경우 각각의 분류기들이 학습시 상호영향을 주지 않은 상황에서(독립적) 학습이 끝난 다음 결과를 종합하는 기법이라면, Boosting은 이전 분류기의 학습 결과를 토대로 다음 분류기의 학습 데이터의 샘플 가중치를 조정해 학습을 진행하는 방법이므로 먼저 생성된 모델을 꾸준히 개선해 나가는 방식으로 학습이 진행된다.</p>
<p>&nbsp;따라서 오차에 대해 높은 가중치를 부여하므로 높은 정확도를 나타내고, 배깅에 비해 성능이 좋으나, 속도가 느리고 과적합 가능성이 있으며, 이상치에 취약할 수 있다. 이러한 Boosting 기법을 활용한 <strong>대표적인 알고리즘은 XGBoost, AdaBoost, GBM, LightBoost 등</strong>이 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/d7573726-fb47-450c-91e2-d5b329c07300/image.png" alt=""></p>
<h3 id="adaboost">AdaBoost</h3>
<ul>
<li>가장 초기의 부스팅 알고리즘 중 하나로, 약한 학습기(weak learners)를 순차적으로 추가하여 학습시키고, 이전 학습기에서 잘못 분류된 샘플에 더 많은 가중치를 부여하여 이후 학습기가 이를 더 잘 맞추도록 함. (오차 보정을 위해 데이터들에 가중치를 부여하며 동작함)</li>
<li>단순한 모델을 결합해 높은 예측 성능을 내는 방법</li>
</ul>
<pre><code>from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.ensemble import AdaBoostClassifier
from sklearn.metrics import accuracy_score

X, y = make_moons(n_samples=500, noise=0.30, random_state=42) # 랜덤 데이터 500개 생성
X_train, X_test, y_train, y_test = train_test_split(X, y) # 데이터 분할

# AdaBoost = Adaptive Boosting
# 앞에서 틀린 것을 뒤에서 계속 보완해 나가면서 강한 분류기를 만들어 가는 부스팅 알고리즘으로,
# 약한 분류기 여러 개를 순차적으로 학습시키면서 못 맞춘 샘플에 점점 더 가중치를 많이 줘서
# 어려운 샘플에 집중할 수 있도록 하는 방식임.
# 즉, 데이터에 대한 가중치를 적응적으로 바꾸면서 학습해 나가는 방식임.
clf = AdaBoostClassifier(n_estimators=500, learning_rate=0.1) # AdaBoost 객체 생성
clf.fit(X_train, y_train) # 학습

y_pred = clf.predict(X_test) # 예측
print(f&quot;{clf.__class__.__name__} : {accuracy_score(y_test, y_pred):.4f}&quot;) # 성능 측정 (정확도)</code></pre><h3 id="gradient-boosting-machine-gbm">Gradient Boosting Machine (GBM)</h3>
<ul>
<li>Gradient Descent를 사용하여 loss function이 줄어드는 방향으로 weak learner들을 반복적으로 결합함으로써 성능을 향상시키는 boosting 알고리즘</li>
<li>이전 예측기가 만든 잔여 오차(Residual Error)를 예측하는 새로운 예측기를 만들고 학습시키며, 이전 모델의 잔차를 통해 약한 학습기를 강화함.</li>
<li>즉, 순차적으로 모델을 학습시키면서 각 단계에서 이전 모델의 오차를 줄이는 방향으로 새로운 모델을 추가하는 방식임.</li>
<li>계산 시간이 오래 걸릴 수 있고, 과적합 문제가 발생할 수 있음.</li>
<li>가중치 업데이트<ul>
<li>AdaBoost : 가중치를 단순히 증가 또는 감소</li>
<li>GBM : 경사하강법</li>
</ul>
</li>
</ul>
<pre><code>from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score

X, y = make_moons(n_samples=500, noise=0.30, random_state=42) # 랜덤 데이터 500개 생성
X_train, X_test, y_train, y_test = train_test_split(X, y) # 데이터 분할

# GBM은 오차(잔차/Residual)를 줄이기 위해 이전 모델이 틀린 부분을 점점 보완하면서 모델을 쌓아가는 부스팅 알고리즘
# 잔차 기반 학습을 진행하는 알고리즘임.
clf = GradientBoostingClassifier(n_estimators=500, learning_rate=0.1) # GBM 객체 생성
clf.fit(X_train, y_train) # 학습

y_pred = clf.predict(X_test) # 예측
print(f&quot;{clf.__class__.__name__} : {accuracy_score(y_test, y_pred):.4f}&quot;) # 성능 측정 (정확도)</code></pre><h2 id="스태킹-stacking">스태킹 (Stacking)</h2>
<p>&nbsp;스태킹은 <strong>여러 개의 서로 다른 모델(베이스 모델)을 학습시키고, 그 예측 결과를 조합해 최종 예측을 만드는 방식</strong>이며, 이 방법의 핵심 요소는 <strong>Meta Learner</strong>이다.</p>
<p>&nbsp;Stacking은 여러 모델의 예측값을 새로운 입력으로 사용해서 또 다른 모델(Meta Learner)을 학습시키는 앙상블 방법이다. 개별 모델이 예측한 데이터를 다시 Meta dataset으로 사용해 학습한다는 컨셉의 접근법으로, Stacking을 위해선 개별 모델들(Base Learner)과 최종 모델(Meta Learner)이 필요하다.</p>
<p>&nbsp;이때, 스태킹에선 여러 예측기에서 각각 다른 값을 예측하면, 마지막 예측기에서 이 예측을 입력으로 받아 최종 예측을 수행한다.</p>
<p>&nbsp;스태킹의 장점은 다양한 모델의 강점을 활용하여 전체적인 성능을 향상시킬 수 있다는 것이며, 스테이지 0에서 k-폴드 교차 검증을 사용함으로써 과적합을 방지하고 견고한 성능을 보장할 수 있다.</p>
<h3 id="스테이지-0-base-model">스테이지 0 (Base Model)</h3>
<ul>
<li>여러 개의 서로 다른 모델(베이스 모델)을 k-fold Cross Validation을 통해 학습시킴.</li>
<li>각 폴드에서 베이스 모델은 검증 세트에 대한 예측을 수행함.</li>
<li>이러한 예측 결과를 모아 새로운 데이터셋을 만들고, 이러한 데이터셋은 메타 모델이 베이스 모델이 학습한 데이터를 직접 보지 않도록 하여 과적합 방지에 도움을 줌.</li>
</ul>
<h3 id="스테이지-1-meta-learner">스테이지 1 (Meta Learner)</h3>
<ul>
<li>베이스 모델의 예측 결과로 구성된 새로운 데이터셋을 통해 메타 모델을 학습시킴.</li>
<li>메타 모델은 이 예측 결과를 조합해 최종 출력을 만듦.</li>
</ul>
<h2 id="blending">Blending</h2>
<ul>
<li>Stacking에서 k-fold 교차 검증을 생략하고, hold out 방식을 사용하는 방식</li>
<li>Stacking에서 사용하는 k-fold 교차 검증을 생략하고, <strong>데이터를 훈련-검증-테스트 세트로 나누어 각 ML 모델을 한 번씩 훈련시킨 후, 예측된 값을 사용해 메타 모델을 학습</strong>하는 방식 (즉 데이터를 Train / Hold-out / Test로 나누고 훈련된 모델을 Hold-out set에 대해 예측하여 Meta Feature를 생성함. 이후 이 Meta Feature를 입력으로 하는 메타 모델을 학습해 최종적으로 Test set에 대해 예측하여 결과를 생성함.)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/5fadbc94-b3f7-413a-9c70-c12c03497868/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[서포트 벡터 머신과 결정 트리]]></title>
            <link>https://velog.io/@inhwaaa_v/SVM</link>
            <guid>https://velog.io/@inhwaaa_v/SVM</guid>
            <pubDate>Thu, 01 Jan 2026 15:08:10 GMT</pubDate>
            <description><![CDATA[<h1 id="svm-support-vector-machine">SVM (Support Vector Machine)</h1>
<p> 서포트 벡터머신은 선형/비선형 분류, 회귀, 이상치 탐색 등 복잡한 분류 문제에 적합한 다목적 기계학습 모델로, 중간 이하 크기의 데이터셋에 적합한 방법이다. 이는 분류를 위한 최적의 결정 경계를 찾는 것을 목표로 하며, 최적의 결정 경계라는 것은 다수의 결정 경계 후보들 중에 최대 마진을 갖는 결정 경계를 의미한다.</p>
<p> 이때, 서포트 벡터(support vectors)는 마진 결정에 영향을 끼치는 샘플 데이터로, 결정 경계와 가장 가까운 데이터 포인트를 의미한다. 마진(margin)은 결정 경계와 서포트 벡터 사이의 거리(minus-plane과 plus-plane 사이의 거리)로, 결정 경계와 서포트 벡터 사이에 직교하는 수직 선분을 그려 거리가 같아지는 지점을 의미한다.</p>
<p> SVM은 데이터를 가장 잘 구분하는 결정 경계선을 찾아 분류하는 지도 학습 알고리즘으로, 다수의 결정 경계 후보 중 최대 마진을 지니는 결정 경계를 찾는 것을 목적으로 한다.</p>
<ul>
<li><strong>plus-plane</strong> : $w^Tx + b = 1$</li>
<li><strong>minus-plane</strong> : $w^Tx + b = -1$</li>
<li><strong>decision boundary</strong> : $w^Tx + b = 0$
<img src="https://velog.velcdn.com/images/inhwaaa_v/post/2d7118b0-b8c0-4461-8435-2b2bde779cb5/image.png" alt=""></li>
</ul>
<blockquote>
<p><strong>결정 경계(Decision Boundary)</strong>란, 머신러닝의 특징 공간 안에서 분류 모델이 각기 다른 클래스(범주)의 데이터를 나누는 기준이 되는 선, 면, 혹은 초평면을 의미한다. (샘플들의 카테고리를 구분할 수 있는 초평면) 쉽게 말해, 서로 다른 클래스에 속하는 데이터들을 구분하기 위한 경계라고 할 수 있다.</p>
<ul>
<li>N차원 공간상의 결정 경계 차원은 N-1이다. (2차원 - 직선, 3차원 - 평면, 3차원 이상 - 초평면 등)</li>
<li>원점에서 시작하는 벡터 w와 직교하고, 거리가 b인 직선의 방정식 ($w^Tx + b = 0$)</li>
<li>결정 경계는 입력 공간을 둘로 나누는 하이퍼플레인이며, 하이퍼플레인은 <strong>N차원 입력이라면 N-1차원의 표면</strong>이 됨. 그 경계는 보통 <strong>$w^T x + b = 0$</strong>으로 나타나며, 이는 <strong>벡터 w에 직교하는 하이퍼플레인</strong>임.</li>
</ul>
</blockquote>
<blockquote>
<p>참고 )</p>
<ul>
<li>최적의 결정 경계선을 찾기 위해선 독립 변수가 k개일 때, 최소 k+1개의 서포트 벡터가 필요하다.</li>
</ul>
</blockquote>
<h2 id="라그랑주-승수법-lagrange-multiplier-method">라그랑주 승수법 (Lagrange multiplier method)</h2>
<p> 라그랑주 승수법은 제약이 있는 최적화 문제를 푸는 방법을 의미한다. 즉, 목적 함수와 제약사항이 존재할 때, 제약사항을 목적 함수로 옮겨줌으로써 제약이 없는 단순한 문제로 바꿔주는 방법이다. 이를 위해 모든 제약식에 라그랑주 승수(Lagrange Multiplier) λ를 곱하고 등식 제약이 있는 문제를 제약이 없는 문제로 바꾸어 문제를 해결한다.</p>
<p> 예를 들어 $\frac{1}{2(w*x - y)^2}$가 목적 함수이고, 이 함수를 minimize해야 최적화되는 것이며, w가 무조건 0 ~ 0.3 사이여야 한다고 했을 때, 0 &lt;= w &lt;= 0.3이라는 조건은 제약사항이다.</p>
<p> 제약사항은 equality constraints, inequality constraints로 나눌 수 있으며, equality constraints는 h(x) = 0으로 나타낼 수 있는 constraint(h(x))를, inequality constraints는 h(x) &lt;= 0을 만족하는 것을 의미한다.</p>
<p> 제약사항을 만족시키는 목적 함수 값을 구하기 위해선 목적 함수와 제약사항 함수가 만나는 지점을 찾아야 하는데, 이를 위해 제약사항 수식도 목적 함수에 녹여 하나의 식으로 표현하는 것이 좋다. (-&gt; 라그랑주 승수법)</p>
<p> <img src="https://velog.velcdn.com/images/inhwaaa_v/post/8fa9615a-7193-4d14-aa47-695198cae42b/image.png" alt=""></p>
<ul>
<li>목적 함수 f(x, y, z, ...)와 제약 조건 g(x, y, z, ...) = 0이 있는 경우 Lagrangian은 다음과 같이 정의됨.<ul>
<li>L(x, y, z, …, λ) = f(x, y, z, …) + λ⋅g(x, y, z,…)</li>
<li>f(x, y, z, …) : 목적 함수</li>
<li>g(x, y, z, …) = 0 : 제약 조건</li>
<li>λ : 라그랑주 승수</li>
</ul>
</li>
<li>제약사항을 포함한 새로운 함수를 만들고, 모든 변수에 대해 미분하고 이 값을 전부 0과 동치시키는 연립 방정식을 풀고 최종적인 x, y 결과를 얻는 방식 = 라그랑주 승수법</li>
<li>SVM은 부등식 제약이 있는 최적화 문제임.</li>
</ul>
<h3 id="kkt-condition"><strong>KKT Condition</strong></h3>
<p> 부등식 제약이 있는 최적화 문제에서 라그랑주 승수법을 적용하기 위해 만족해야 하는 조건</p>
<ol>
<li>라그랑주 함수 L에 대해 모든 독립 변수 $x_i$에 대한 미분 값이 0이다. (Stationarity)</li>
<li>모든 라그랑주 승수 $λ_i$와 제약 조건 부등식의 곱은 0이다. (Complementary Slackness)</li>
<li>모든 라그랑주 승수는 0보다 크거나 같아야 한다. (Dual Feasibility)</li>
</ol>
<h2 id="마진과-조건부-최적화-문제">마진과 조건부 최적화 문제</h2>
<p> 마진은 결정 경계와 가장 가까운 점(서포트 벡터) 사이의 수직 거리(직교 거리)를 의미한다. 이때, 결정 경계의 수식은 $w^Tx + b = 0$이므로 직교 방향이 w의 방향이며, 서포트 벡터는 w 방향으로 ±1만큼 떨어져 있다.</p>
<p> <img src="https://velog.velcdn.com/images/inhwaaa_v/post/199fe0da-ed93-4b6a-8efe-25acf85050c1/image.png" alt=""></p>
<p> SVM의 목적은 이러한 마진을 최대화하는 결정 경계를 찾는 것이다. 따라서 다음과 같은 최적화 문제로 변환할 수 있다. 원래 SVM의 제약 조건은 $w^Tx_i + b ≥ 1$과 $w^Tx_i + b &lt; 1$ 두 가지이다. 이 두 가지를 한 번에 표현하기 위해선 $y_i ∈ {+1, -1}$이라는 성질을 이용할 수 있다. 따라서 두 조건을 포함하여 $y_i(w^Tx_i + b) ≥ 1$로 표현할 수도 있다. 그리고 이를 $y_i(w^Tx_i + b -1 ) ≥ 0$로 변형할 수 있다.</p>
<p> <img src="https://velog.velcdn.com/images/inhwaaa_v/post/3119126b-999a-410d-8f31-3f1fea89f199/image.png" alt=""></p>
<h2 id="hinge-손실-함수">hinge 손실 함수</h2>
<p> hinge 손실 함수는 SVM에서 주로 사용되는 손실 함수로, 다음과 같은 수식으로 표현한다.</p>
<p>$$
Loss=max(0,1−(y′∗y))
$$</p>
<p> hinge 손실은 예측이 잘못된 경우에만 패널티를 부여하며, 올바르게 분류된 경우 손실 값이 0이 된다. 분류가 잘 되는 margin 바깥 부분의 관측값이라면 손실을 무시(loss = 0)하고, 분류가 잘 되지 않는 margin 내의 관측값이라면 손실이 증가하도록 유도한다.</p>
<p>$$
L(w) = \frac{1}{2} | w |^2 + C \sum_{i} \max(0,, 1 - y_i (w^\top x_i) )
$$</p>
<h2 id="소프트-마진soft-margin과-하드-마진-hard-margin"><strong>소프트 마진(Soft Margin)과 하드 마진 (Hard Margin)</strong></h2>
<p> 결정 경계선을 나눌 때, 이상치를 허용하지 않을 경우 과적합 문제가 발생할 수 있다. 따라서 두 범주를 정확하게 나누지는 않지만 마진을 최대화하여 과적합을 방지하는 것을 소프트 마진, 이상치를 허용하지 않는 것을 하드 마진이라고 한다.</p>
<p> 하드 마진의 경우엔 마진이 매우 작아지고, 개별적인 학습 데이터들을 모두 다 놓치지 않으려 하기에 오버피팅 문제가 발생할 수 있다. 반면, 소프트 마진의 경우엔 기준을 너그럽게 잡으므로 어느 정도 이상치를 마진 안에 포함하도록 기준을 설정할 수 있어 오버피팅을 방지할 수 있다는 장점이 있으나, 너무 많은 이상치를 허용할 경우 언더피팅 문제가 발생할 수 있다.</p>
<p> 소프트 마진과 하드 마진을 조정해 주는 매개변수로 C와 Gamma가 사용되며, C는 마진 강화 정도(오류 허용 정도의 역수)를, Gamma는 커널 계수, 즉 얼마나 weight를 주는지를 의미한다. C 값과 Gamma 값은 작을수록 좋다. 그 이유는 C 값이 작으면 오류의 허용 범위가 커져 이상치에 덜 민감해지고, Gamma 값이 작으면 결정 경계가 유연해지는 경향성이 생긴다.</p>
<p> Gamma는 하나의 관측치가 영향력을 행사하는 거리를 조정해 주는 매개변수로, 작은 Gamma 값은 넓은 영향력을 의미하여 더 부드럽고 넓은 결정 경계선을 만들어 내고, 반대의 경우엔 좁은 영향력을 지니며, 더 민감한 결정 경계선을 형성하는데, 이 경우에는 과적합이 발생할 수 있다.</p>
<h2 id="커널-트릭">커널 트릭</h2>
<p> 현실 세계의 데이터는 대부분 Feature가 여러 개이고, 선형적으로만 데이터가 존재하지 않는다. 따라서 SVM으로 Non-Linear Data를 분류하기 위해서는 커널을 통해 차원을 변경해야 한다. 따라서 SVM은 커널 함수(ex. Linear Kernel, Polynomial Kernal 등)를 통해 데이터를 고차원 공간으로 매핑해 선형적으로 구분할 수 있게 만든다. 이는 데이터를 고차원 공간으로 직접적으로 매핑시키는 것이 아니다. 커널을 통해 복잡한 저차원 데이터를 선형 분리가 가능한 고차원으로 매핑시켜 처리하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/80750406-8b51-4c6f-a2e4-a643b758a6bb/image.png" alt=""></p>
<blockquote>
<p><strong>다항 회귀 (Polynomial Regression)</strong> : 비선형 데이터를 학습하기 위해 각 특성의 거듭제곱을 새로운 특성으로 추가하고, 해당 데이터셋에 대해 선형 모델을 학습시키는 방법으로, 각 특성을 주어진 Degree에 따라 제곱해 새로운 특성으로 추가한다.</p>
</blockquote>
<ul>
<li><code>PolynomialFeature(degree=2)</code></li>
<li>하지만, 이런 Feature를 추가하는 방식은 모델 성능에 영향을 미칠 수 있다.  (모델이 느려짐)</li>
<li>이를 해결하기 위해 커널 트릭을 사용한다. 커널 트릭은 높은 차수의 특성을 추가한 것과 같은 효과를 가지면서도 실제로 특성을 추가하지 않는다.<blockquote>
</blockquote>
</li>
</ul>
<h3 id="rbf-커널">RBF 커널</h3>
<ul>
<li>RBF Function은 기존 벡터와 입력 벡터의 유사도를 측정하는 함수로, 가우시안 함수 형태를 지닌다.</li>
</ul>
<p>$$
K(x,z)=exp(−β∥x−z∥2)
$$</p>
<ul>
<li>데이터를 고차원으로 보낼 때, 특정 점들과 얼마나 가까운지(유사한지)를 새로운 특징으로 추가하는 방법</li>
<li>RBF 커널은 모든 데이터 점 x에 대해 기준점(랜드마크) z₁, z₂, z₃…와의 거리 유사도를 Feature로 추가함.</li>
<li>즉, 고차원 벡터 자체가 아닌 고차원 벡터들 간의 dot product만을 계산하여 고차원으로 매핑하는 함수를 직접 만들지 않아도 그 효과를 사용할 수 있게 해준다.</li>
</ul>
<p><strong>정리</strong></p>
<ul>
<li>커널(K)만 있으면 고차원 feature φ(x)의 내적을 직접 계산할 필요 없음</li>
<li>RBF 커널은 &quot;가우시안 거리 기반 유사도&quot;</li>
<li>랜드마크들과의 거리 → 새로운 feature가 되어 고차원으로 올라감</li>
<li>그 공간에서는 직선으로 분리 가능</li>
<li>SVM은 결국 $K(x_i, x_j)$만 이용해 분류함</li>
<li>즉, Feature를 추가하긴 하는데 고차원 함수 계산이 아니라 유사도 계산을 통한 비선형성 추가</li>
</ul>
<h1 id="결정-트리-decision-tree">결정 트리 (Decision Tree)</h1>
<p> 결정 트리란, 일련의 분류 규칙을 통해 데이터를 분류/회귀하는 지도학습 모델 중 하나로, 결과 모델이 트리 구조를 지녀 Decison Tree라는 이름을 지닌다. 아래의 그림처럼 특정 기준(질문)에 따라 데이터를 구분하는 모델을 의사 결정 트리 모델이라고 하며, 한 번의 분기마다 변수 영역을 두 개로 구분한다.</p>
<p> 결정 트리에서 질문이나 정답을 노드(Node)라고 하고, 맨 처음 분류 기준을 Root Node, 중간 분류 기준을 Intermediate Node, 맨 마지막 노드를 Terminal Node 혹은 Leaf Node라고 한다. 이러한 결정 트리의 기본 아이디어는 Leaf Node가 가장 섞이지 않은 상태로 분류되는 것, 즉 복잡성(Entropy)이 낮아지는 방향으로 만드는 것이다</p>
<p> <img src="https://velog.velcdn.com/images/inhwaaa_v/post/151d7b2c-0c1c-4832-a3fc-4362183b7916/image.png" alt=""></p>
<h2 id="불순도-impurity">불순도 (Impurity)</h2>
<p> 불순도(Impurity)란 해당 범주 안에 서로 다른 데이터가 얼마나 섞여 있는지 복잡성을 의미하며, 다양한 개체들이 섞여 있을수록 불순도가 높아진다.</p>
<p> 결정 트리에서 분기 기준을 설정할 땐 현재 노드의 불순도에 비해 자식 노드의 불순도가 감소되도록 설정해야 하며, 현재 노드의 불순도와 자식 노드 불순도의 차이를 Information Gain(정보 이득)이라고 한다.</p>
<p> 이러한 불순도를 수치적으로 나타내기 위한 함수에는 지니 계수(Gini)와 엔트로피(Entropy)가 있다.</p>
<ul>
<li><p><strong>지니 계수(Gini)</strong> : 데이터의 혼합 정도(불순도)를 정량화하는 지표</p>
<ul>
<li>0에서 0.5 사이의 값을 지니며, 값이 낮을수록 데이터의 순도가 낮음을 나타냄.</li>
<li>지니 계수가 0일 경우, 모든 데이터가 동일한 클래스에 속해 매우 순수한 상태임을 나타내고, 0.5에 가까운 값은 데이터가 여러 클래스에 혼합되어 높은 불순도를 지닌다는 것을 의미함.</li>
<li>지니 계수는 주로 의사결정트리에서 노드의 분할 기준으로 사용되며, 각 노드를 가능한 한 순수하게 만들기 위해 Gini 계수를 최소화하는 방향으로 학습이 이루어짐.
<img src="https://velog.velcdn.com/images/inhwaaa_v/post/2edd80f4-3d76-4926-8e68-fbd00c4e9886/image.png" alt=""></li>
</ul>
</li>
<li><p><strong>엔트로피 (Entropy)</strong> : 정보 이론에서 유래한 개념으로, 불확실성의 정도를 정령화하는 척도</p>
<ul>
<li>엔트로피는 데이터의 혼합 정도, 즉 데이터의 무질서성을 표현하며, 의사결정트리에서 정보 이득(특정 속성을 기준으로 데이터를 분할했을 때 전체 데이터의 불확실성이 얼마나 감소했는지를 측정하는 지표)을 계산하는데 사용됨. 의사결정트리에서는 분기 시 이런 불순도 값이 줄어드는 방향으로 트리를 형성해야 함.</li>
<li>엔트로피 값은 0에서 $log_2(C)$ 사이의 값을 지니며, 클래스가 두 개인 경우 최댓값은 1임.</li>
<li><strong>엔트로피가 0이라는 것은 모든 데이터가 동일한 클래스에 속해 불확실성이 없음</strong>을 의미하며, <strong>엔트로피가 높아질수록 데이터의 혼합 정도가 증가하고, 불확실성도 커짐.</strong></li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/af348feb-cd36-456d-8939-942fbb40f990/image.png" alt=""></p>
<h2 id="정보-이득-information-gain">정보 이득 (Information Gain)</h2>
<p> 정보 이득이란, 분기 이전의 불순도와 분기 이후의 불순도 차이(Parent Node와 Child Node의 불순도 차이)를 의미한다. 예를 들어, 불순도가 1인 상태에서 0.7인 상태로 바뀌었다면 정보 이득은 0.3이다.</p>
<h2 id="결정-트리-구성-단계">결정 트리 구성 단계</h2>
<ol>
<li>Root Node의 불순도 계산</li>
<li>나머지 속성에 대해 분할 후 Child Node의 불순도 계산</li>
<li>각 속성에 대한 Information Gain 계산 후 Information Gain(Root노드와 자식노드의 불순도 차이)이 최대가 되는 분기 조건을 찾아 분기</li>
<li>모든 Leaf Node의 불순도가 0이 될때까지 2,3을 반복 수행한다.</li>
</ol>
<h2 id="가지치기-pruning">가지치기 (pruning)</h2>
<p> 가지치기란, 결정 트리의 특정 노드 및 하부 트리를 제거해 일반화 성능을 높이는 것을 의미한다. 쉽게 말해, 데이터로부터 생성된 복잡한 트리 구조를 단순화하기 위해 가지를 많이 내려 생성된 나무로부터 몇 개의 가지를 쳐내 트리 구조를 단순화하는 것이다. 이러한 가지치기는 사전 가지치기와 사후 가지치기로 나눌 수 있다.</p>
<p> 사전 가지치기는 의사결정트리의 분류 정지 조건을 사전에 설정해 분할을 멈추는 것을 의미하며, 트리 생성 시 트리의 깊이나 사용되는 Feature 개수, Leaf Node Sample 수 등과 같이 관련 하이퍼파라미터를 직접 설정하여 수행한다.</p>
<p> 사후 가지치기는 Full Tree를 생성한 후 모델에 대한 해석과 평가가 완화되는 방향으로 가지를 결합하는 것을 의미한다. 대표적인 사후 가지치기 방법에는 Cost Complexity Pruning이 있으며, 이는 오차 제곱합(SSE)이 최소가 되는 트리를 찾는 방법이다.</p>
<p> 아래의 식에서 오른쪽 두 번째 항은 일종이 패널티로써, 나무의 크기가 클수록 그 값이 커진다. 여기서 $\alpha$는 complexity 파라미터로, 이 값이 클수록 가지치기하는 노드의 수가 많아져 모델이 단순해지고, 작을 수록 복잡한 데이터를 표현할 수 있는 모델이 된다.</p>
<p>$$
Cost(T) = ERR(T) + \alpha L(T)
$$</p>
<ul>
<li>ERR(T) : 검증 데이터에 대한 오분류율</li>
<li>L(T) : Leaf Node 개수 (구조 복잡성)</li>
</ul>
<h2 id="결정-트리-단점">결정 트리 단점</h2>
<ul>
<li>과적합으로 알고리즘 성능이 떨어질 수 있다.<ul>
<li>이를 극복하기 위해서 트리의 크기를 사전에 제한하는 튜닝이 필요하다.</li>
</ul>
</li>
<li>한 번에 하나의 변수만 고려하므로 변수 간 상호작용을 파악하기가 어렵다.</li>
<li>약간의 차이에 따라(레코드의 개수의 약간의 차이) 트리의 모양이 많이 달라질 수 있다.<ul>
<li>두 변수가 비슷한 수준의 정보력을 갖는다고 했을 때, 약간의 차이에 의해 다른 변수가 선택되면 이 후의 트리 구성이 크게 달라질 수 있다.</li>
</ul>
</li>
<li>계단 모양의 결정 경계를 만들기에 학습 데이터의 회전에 민감하다.</li>
<li><strong>이같은 문제를 극복하기 위해 등장한 모델이 바로 랜덤 포레스트이다.</strong><ul>
<li>같은 데이터에 대해 결정 트리를 여러 개 만들어 그 결과를 종합해 예측 성능을 높이는 기법</li>
</ul>
</li>
</ul>
<hr>
<h1 id="코드">코드</h1>
<ul>
<li><strong>SVC</strong> : <code>SVC(*, C=1.0, kernel=&#39;rbf&#39;, degree=3, gamma=&#39;scale’, coef0=0.0, shrinking=True, probability=False, tol=0.001, cache_size=200, class_weight=None, verbose=False, max_iter=-1, decision_function_shape=&#39;ovr’, break_ties=False, random_state=None)</code><ul>
<li><code>kernel</code> : 커널 유형 지정<ul>
<li>{‘linear’, ‘poly’, ‘rbf’, ‘sigmoid’, ‘precomputed’}</li>
</ul>
</li>
<li><code>C</code> : 오류 허용 정도의 역수로 작을 수록 오류를 많이 허용함. (이상치 덜 민감)</li>
<li><code>gamma</code> : Kernel 계수 (커널의 유연성 : 계수로 조정)<ul>
<li>for ‘rbf’, ‘poly’, ‘sigmoid’</li>
</ul>
</li>
</ul>
</li>
<li><strong>Decision Tree 시각화</strong> : <code>plot_tree(decision_tree, max_depth=None, feature_names=None, class_names=None, filled=False, proportion=False, rounded=False, precision=3, fontsize=None)</code><ul>
<li><code>max_depth</code> : if None, the tree is fully generated.</li>
<li><code>feature_names</code> : list of strings</li>
<li><code>class_names</code> : list of str or bool</li>
<li><code>proportion</code> : True로 지정하면, 값 대신 비율로 표현</li>
<li><code>precision</code> : number of digits of precision</li>
<li><code>filled</code>, <code>rounded</code>, <code>fontsize</code> : 노드의 모양과 폰트크기 설정</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Eigen-Decomposition, PCA, SVD]]></title>
            <link>https://velog.io/@inhwaaa_v/Eigen-Decomposition-PCA-SVD</link>
            <guid>https://velog.io/@inhwaaa_v/Eigen-Decomposition-PCA-SVD</guid>
            <pubDate>Sun, 19 Oct 2025 18:38:20 GMT</pubDate>
            <description><![CDATA[<h1 id="벡터와-행렬">벡터와 행렬</h1>
<h2 id="벡터와-행렬의-개념">벡터와 행렬의 개념</h2>
<ul>
<li><p><strong>벡터</strong> : 방향과 크기를 갖는 대상</p>
</li>
<li><p><strong>행렬</strong> : 여러 벡터를 열로 담은 구조</p>
<ul>
<li>행렬은 <strong>교환법칙이 성립하지 않</strong>는다. ($AB ≠ BA$)</li>
<li>행렬은 <strong>분배법칙과 결합법칙이 성립</strong>한다.</li>
<li>$A(B + C) = AB + AC$ (분배법칙), $A(BC) = (AB)C$ (결합법칙)</li>
<li><strong>행렬의 곱셈을 위해선 행렬 A의 열과 행렬 B의 행의 개수가 같아야 한다.</strong></li>
</ul>
</li>
<li><p><strong>텐서</strong> : 3차원 이상의 구조를 갖는 배열</p>
</li>
<li><p><strong>설계 행렬 (Design Matrix)</strong> : 학습 데이터를 담은 행렬 (n samples * d features)</p>
</li>
<li><p>전치 $A^T$, 역행렬 $A^{-1}$, 단위 행렬 $I$</p>
</li>
<li><p>행렬식 $det(A)$ : 선형 변환의 부피 스케일링 (부호는 방향성)</p>
</li>
<li><p><strong>특수한 행렬들</strong></p>
<p>  <img src="https://velog.velcdn.com/images/inhwaaa_v/post/0bbba6bd-1ad8-48fd-a0c3-cef0aadb988c/image.png" alt=""></p>
</li>
</ul>
<h2 id="역행렬-inverse-matrix">역행렬 (Inverse Matrix)</h2>
<p>&nbsp;행렬 A와 곱하면 단위 행렬 I가 나오는 행렬 $A^{-1}$를 역행렬이라고 하며, 이는 <strong>정방행렬(행과 열의 수가 같은 행렬)에 대해서만 정의</strong>되고, 역행렬이 없으면 특이행렬(Singular Matrix)이라고 한다.</p>
<p>$$
\mathbf{A} = \begin{bmatrix}a &amp; b \c &amp; d\end{bmatrix}, \quad\mathbf{A}^{-1} = \frac{1}{ad - bc}\begin{bmatrix}d &amp; -b \-c &amp; a\end{bmatrix}
$$</p>
<p>&nbsp;역행렬은 <strong>곱했을 때 항등 행렬을 만들어 내는 성질</strong>을 지닌다.</p>
<p>$$
AA^{-1} = A^{-1}A = I
$$</p>
<pre><code class="language-python">A = np.array([[1., 2., 3.,],
             [3., 4., 5.],
             [7., 8., 10.]])

print(&quot;A = \n&quot;, A) # 기본 행렬 출력
print(&quot;A^T = \n&quot;, A.T) # 전치된 행렬 출력
print(&quot;det(A) = &quot;, npl.det(A)) # 행렬식 계산
print(&quot;A inverse (via scipy) : \n&quot;, sl.inv(A)) # 역행렬 계산
# 역행렬은 곱했을 때 항등행렬을 만들어 내는 성질을 지님. (AA^{-1} = A^{-1}A = I)
print(&quot;A * A^{-1} = l : \n&quot;, A @ sl.inv(A))</code></pre>
<h2 id="전치행렬-transpose-matrix">전치행렬 (Transpose Matrix)</h2>
<p>&nbsp;전치행렬이란, 행렬의 행과 열을 교환한 행렬을 의미한다.</p>
<p>$$
\mathbf{A} =\begin{bmatrix}1 &amp; 2 &amp; 3 \4 &amp; 5 &amp; 6 \7 &amp; 8 &amp; 9\end{bmatrix}\Rightarrow\mathbf{A}^{\mathrm{T}} =\begin{bmatrix}1 &amp; 4 &amp; 7 \2 &amp; 5 &amp; 8 \3 &amp; 6 &amp; 9\end{bmatrix}
$$</p>
<p>$$
\mathbf{B} =\begin{bmatrix}x &amp; y \z &amp; w\end{bmatrix}\Rightarrow\mathbf{B}^{\mathrm{T}} =\begin{bmatrix}x &amp; z \y &amp; w\end{bmatrix}
$$</p>
<p>$$
\mathbf{C} =\begin{bmatrix}1 &amp; 1 &amp; 1 &amp; 1 \-3 &amp; 5 &amp; -2 &amp; 7\end{bmatrix}\Rightarrow\mathbf{C}^{\mathrm{T}} =\begin{bmatrix}1 &amp; -3 \1 &amp; 5 \1 &amp; -2 \1 &amp; 7\end{bmatrix}
$$</p>
<h2 id="행렬식determinant과-행렬식의-기하학">행렬식(Determinant)과 행렬식의 기하학</h2>
<p>&nbsp;행렬식이란, 행렬 공간을 얼마나 늘이거나 줄이는지, 선형 변환의 부피 스케일링을 의미하는 값이다. 다시 말해, 선형 변환의 부피 변화율을 나타내는 값이다.</p>
<p>$$
A =\begin{bmatrix}a &amp; b \c &amp; d\end{bmatrix}
$$</p>
<p>$$
\text{det}(A) = ad - bc
$$</p>
<p>&nbsp;행렬식은 어떤 행렬의 역행렬 존재 여부에 대한 판별값이 되기도 하며, 행렬식의 값이 0이면 역행렬이 존재하지 않는다. 행렬식은 정방 행렬에 대해서만 정의되며, 기하학적으로는 2차원에서는 2개의 행 백터가 이루는 평행사변형의 넓이를, 3차원에서는 3개의 행 벡터가 이루는 평행 사각 기둥의 부피를 의미한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/41c62169-65dc-4146-98ca-86b064159703/image.png" alt=""></p>
<h3 id="예시-코드">예시 코드</h3>
<p>&nbsp;$A = [a_1, a_2]$ (열벡터 $a_1, a_2$) 일 때, $|det(A)|$는 $a_1, a_2$가 만드는 평행사변형의 면적이다.</p>
<pre><code class="language-python"># 선형 변환 결과 확인하는 코드
# 행렬식 det(A)가 선형 변환의 부피 스케일링 의미하는 거니까
# 결국엔 det(A)만큼 선형변환 적용한 결과를 시각화한 그래프 !
# 그래프 Before, After 보면 각 단위 면적이 3배 커진 걸 확인 가능함.
A2 = np.array([[2., 1.,],
               [1., 2.,]])

print(&quot;det(A2) =&quot;, npl.det(A2)) # A2 행렬식 계산

# 격자와 변환된 격자 시각화
# (11, 11, 2) -&gt; (121, 2)
# np.stack() -&gt; 새로운 차원(축) 추가해 배열 합침
# np.meshgrid() -&gt; 2차원 평면 위에 격자의 좌표망 만듦
grid = np.stack(np.meshgrid(np.linspace(-1, 1, 11), np.linspace(-1, 1, 11)), axis=-1).reshape(-1, 2)
grid_T = grid @ A2.T # 모든 격자점에 선형 변환 적용 (행렬곱)

plt.figure(figsize=(5,5))
plt.scatter(grid[:,0], grid[:,1], s=10) # 원래 점 표시 / s = 점 크기
plt.scatter(grid_T[:,0], grid_T[:,1], s=10, marker=&#39;x&#39;) # 선형 변환 적용된 점 표시
plt.axis(&#39;equal&#39;); plt.title(&quot;Before (•) / After (x) by A2&quot;) # plt.axis(&#39;equal&#39;); x, y축 스케일 맞춤
plt.show()</code></pre>
<h1 id="고유값고유벡터">고유값/고유벡터</h1>
<h2 id="고유값고유벡터--정의와-직관">고유값/고유벡터 : 정의와 직관</h2>
<p>&nbsp;$n<em>n$ 정방 행렬 A에 대해 $Av = \lambda v$를 만족하는 $v ≠ 0$를 A의 고유 벡터, $\lambda$를 고유값이라고 한다. 다시 말해, *</em>고유 벡터<strong>는 행렬 A를 선형 변환했을 때, 선형 변환 A에 의한 변환 결과가 자기 자신의 상수배가 되는 0이 아닌 벡터를 의미하고, **고유값</strong>은 그 상수배 값(고유벡터의 변화되는 스케일 정도)를 의미한다. 이때, A의 고유 벡터는 A에 선형 변환을 취해도 방향이 변하지 않고, 크기만 변한다.</p>
<p>&nbsp;대칭행렬은 $A = A^T$이므로 항상 실수 고유값을 지니고, 고유벡터를 만드는 행렬 A가 대칭행렬일 때 서로 다른 고유값을 가지는 고유벡터는 서로 직교한다. (모든 대칭행렬의 고유벡터는 서로 직교(orthogonal)한다)</p>
<p>&nbsp;여기서 직교는 고유벡터의 값이 서로 수직이어서 두 벡터의 내적 결과가 0이 나온다는 것을 의미한다.</p>
<ul>
<li><p><strong>키워드 : 불변 방향, 크기 스케일링, 대칭행렬, 직교</strong></p>
</li>
<li><p><strong>고유값</strong> : $det(A - \lambda I) = 0$.</p>
<p>  $$
  \begin{vmatrix}a - \lambda &amp; b \c &amp; d - \lambda\end{vmatrix}= \lambda^2 - (a + d)\lambda + (ad - bc) = 0
  $$</p>
</li>
<li><p><strong>고유벡터</strong> : $(A - \lambda I) V = 0$</p>
</li>
</ul>
<h3 id="예시-코드---고유값-분해와-av--λv-검증">예시 코드 - 고유값 분해와 Av = λv 검증</h3>
<pre><code class="language-python">A = np.array([[4., 2.],
              [3., 5.]])
vals, vecs = npl.eig(A) # 고유값 분해
print(&quot;A = \n&quot;, A) # A 행렬 출력
print(&quot;eigenvalues =&quot;, vals) # A의 고유값
print(&quot;eigenvectors (columns) = \n&quot;, vecs) # A의 고유벡터

# 검증 Av = λv
for i in range(len(vals)):
  v = vecs[:, i] # i번째 고유 벡터
  Iv = vals[i] * v # i번째 고유값 * 고유벡터 (λv)
  Av = A @ v # Av 계산
  # Av - λv = 0이 성립하면 Av = λv인 것.
  print(f&quot;i = {i} : ||Av - λv|| = &quot; , npl.norm(Av - Iv))</code></pre>
<h3 id="예시-코드---2d-변환에서의-시각적-의미">예시 코드 - 2D 변환에서의 시각적 의미</h3>
<p>&nbsp;임의의 A가 평면 전체의 방향을 뒤섞어도 고유 벡터의 방향은 그대로 남는다.</p>
<pre><code class="language-python">A = np.array([[2.,1.],
              [0.5,1.5]])

vals, vecs = npl.eig(A) # 고유값 고유벡터 계산
# (289, 2)
grid = np.stack(np.meshgrid(np.linspace(-2,2,17), np.linspace(-2,2,17)), axis=-1).reshape(-1, 2)
grid_T = grid @ A.T # 모든 격자점에 선형 변환 적용 (행렬곱)

plt.figure(figsize=(5,5))
plt.scatter(grid[:,0], grid[:,1], s=8)
plt.scatter(grid_T[:,0], grid_T[:,1], s=8, marker=&#39;x&#39;)
for i in range(2):
  v = vecs[:,i].real # i번째 고유벡터 실수부 (대칭행렬이 아니라 고유벡터가 복소수일 수 있어서)
  # 벡터 크기를 1로 정규화
  v = v/npl.norm(v) # np.linalg.norm(v) : 벡터 v의 길이 계산
  t = np.linspace(-2,2,100)[:,None] # -2 ~ 2까지 100개 점 만들고, (100, 1) 형태로 만듦
  line = t*v[None,:] # (100, 1) (1, 2) -&gt; (100, 2) / 고유벡터 방향으로 -2~2배까지 점 찍음
  plt.plot(line[:,0], line[:,1])
plt.axis(&#39;equal&#39;); plt.title(&quot;Eigen-directions are invariant (lines)&quot;)
plt.show()</code></pre>
<h2 id="고유-벡터의-직교">고유 벡터의 직교</h2>
<p>&nbsp;대칭 행렬의 고유 벡터 집합은 서로 직교하나, 비대칭 행렬에서는 일반적으로 고유벡터가 직교하지 않는다.</p>
<ul>
<li>고유벡터들이 직교하면 $Q^TQ = I$가 성립한다.</li>
</ul>
<pre><code class="language-python">import numpy as np
import numpy.linalg as npl

# (1) 대칭행렬 : 고유벡터 직교성 확인
S = np.array([[2., -1., 0.],
              [-1., 2., -1.],
              [0., -1., 2.]])

valsS, vecsS = npl.eigh(S) # 대칭전용 -&gt; 실수 / 직교 보장 (QR) / 다시 말해, npl.eigh(S)는 고유값과 고유벡터가 실수이고 고유벡터들이 직교하도록 보장함
# 고유벡터들이 직교하면 Q^TQ = I가 성립함.
print(&quot;Symmetric eigenvectors orthogonality (Q^T Q ≈ I) : \n&quot;, vecsS.T @ vecsS)

# (2) 비대칭행렬 : 일반적으로 직교 아님 (Q^T Q ≠ I)
# -&gt; 다르게 표현하면 Off-diagonal 값이 0이 아님 (직교하지 않음)
B = np.array([[1., 2.],
              [0., 3.]])

valsB, vecsB = npl.eig(B)
print(&quot;Nonsymmetric eigenvectors dot products : \n&quot;, vecsB.T @ vecsB)</code></pre>
<h2 id="파워-반복power-iteration과-rayleigh-quotient">파워 반복(Power Iteration)과 Rayleigh Quotient</h2>
<p>&nbsp;Power Iteration이란, 행렬의 가장 큰 고유값과 그에 대응하는 고유벡터를 근사적으로 찾는 알고리즘이다. (행렬을 계속 곱하다 보면 가장 큰 고유값과 고유벡터만 살아남는다는 것)</p>
<p>&nbsp;고유값이 크다 = 정보량이 많다(해당 방향이 데이터의 분산이 크다)는 의미이며, PCA에서는 고유값이 큰 고유벡터만 남기고 지우는 방식으로 차원 축소한다. 따라서 고유값이 큰 순서대로 몇 개의 고유벡터만 남기고 작은 건 버려야 하는데, 고유값이 큰 걸 찾을 수 있는 알고리즘 중 하나가 Power Iteration이다.</p>
<p>&nbsp;Rayleigh Quotient이란, 벡터 x를 A로 변환했을 때 방향/크기의 비율을 측정하는 것이다. 따라서 x가 고유벡터라면, Rayleigh Quotient를 통해 고유값을 계산할 수 있다.</p>
<p>$$
x_{k+1} \leftarrow \frac{A x_k}{| A x_k |} \quad \text{→ 지배적 고유벡터로 수렴.}
$$</p>
<p>$$
R(x) = \frac{x^{\mathrm{T}} A x}{x^{\mathrm{T}} x}\quad \text{→ } x가 \text{ 고유벡터일 때 } R(x) = \lambda.
$$</p>
<pre><code class="language-python">def power_iteration(A, num_iter=30):
  x = rng.normal(size=(A.shape[0], )) # 행렬 A 크기에 맞춰 초기 벡터 난수 생성
  x = x / npl.norm(x) # 정규화
  history = [] # Rayleigh quotient 기록하는 리스트
  # 행렬(A)을 계속 곱하다 보면 가장 큰 고유값과 고유벡터만 살아남는다 -&gt; power_iteration
  # Av = λv이므로 계속 곱하면 그 벡터의 방향은 변하지 않고 크기만 고유값만큼 커지므로
  # 계속 곱하면 가장 큰 고유값을 가진 방향으로 수렴함.
  for _ in range(num_iter):
    x = A @ x # Ax
    x = x / npl.norm(x)
    r = (x @ (A @ x)) / (x @ x) # Rayleigh quotient 계산
    history.append(r)
  return x, np.array(history) # 가장 큰 고유값에 대응하는 고유벡터로 추정된 벡터 return

A = np.array([[4., 2., 0.],
              [2., 3., 1.],
              [0., 1., 2.]])

x_star, rq_hist = power_iteration(A, num_iter=25) # Power Iteration 실행
vals, vecs = npl.eigh(A) # 고유값, 고유벡터 구하기
idx = np.argmax(vals) # 가장 큰 고유값 인덱스 추출
print(&quot;Power-iter dominant eigenvalue (approx) = &quot;, rq_hist[-1]) # 마지막 반복에서의 Rayleigh Quotient (행렬 A의 가장 큰 고유값)
print(&quot;True dominant eigenvalue = &quot;, vals[idx])

plt.figure()
plt.plot(rq_hist)
plt.xlabel(&quot;iteration&quot;); plt.ylabel(&quot;Rayleigh quotient&quot;)
plt.title(&quot;Convergence of Rayleigh quotient to dominant λ&quot;)
plt.show()</code></pre>
<h1 id="고유값-분해-eigen-decomposition">고유값 분해 (Eigen-Decomposition)</h1>
<p>&nbsp;고유값 분해 (Eigen-Decomposition)란, 행렬을 고유값과 고유벡터를 통해 분해하는 기법으로, 어떤 정방행렬 A를 $A = QΛQ^{-1}$의 형태로 쪼개는 것을 의미한다. 이때, Q는 A의 고유벡터를 열에 배치한 행렬, Λ는 고유값을 대각선에 배치한 대각행렬을 의미한다.</p>
<p>&nbsp;이렇게 고유값 분해를 수행하면 행렬을 그 행렬만의 좌표계(고유 벡터)로 바꾸어 단순 대각 형태로 만들어 주며, 이는 PCA에서 주로 활용된다. 고유값이 크다는 것은 그 방향에 데이의 정보량이 많다는 의미이기에 정보량이 큰 데이터만 남기고 차원을 축소할 수 있기 때문이다.</p>
<ul>
<li>대각화 가능하다 = 행렬 A를 고유벡터와 고유값으로 분해할 수 있다</li>
<li>다시 말해, 고유값 분해가 가능하고, $A = QΛQ^{-1}$의 형태로 표현이 가능하다는 것을 의미함.</li>
</ul>
<p>&nbsp;고유값 분해를 해주면, 대각행렬 Λ가 단순 스케일 행렬임을 볼 수 있다. (이는 표준 기저에서 봤을 땐 복잡한 변환이었는데, 좌표계를 고유벡터 기준으로 바꿨더니 A가 단순히 각 좌표 성분을 $\lambda_i$배 해주는 행렬로 보이더라! 라는 느낌으로 이해하면 된다.)</p>
<p>&nbsp;Q의 열벡터들을 고유벡터로 구성한 이유는 결국엔 이 벡터들이 만드는 좌표계가 고유기저이기 때문이고, 결국 고유값 분해는 이 고유 기저를 기준으로 행렬을 분해하는 것을 의미한다고 볼 수 있다.</p>
<pre><code class="language-python"># 일반 행렬의 고유값 분해
A = np.array([[4.,2.],
              [3.,5.]])

vals, vecs = npl.eig(A) # 고유값과 고유벡터 계산
V = vecs
L = np.diag(vals) # 대각행렬 만드는 함수
A_rec = V @ L @ npl.inv(V) # A = VΛV^{-1}
print(&quot;||A - VΛV^{-1}||&quot;, npl.norm(A - A_rec)) # A와 고유값 분해된 값 비교

# 대칭 행렬의 고유값 분해
# 대칭 (스펙트럴 정리(Spectral Theorem) - 대칭행렬은 항상 직교대각화 가능하다는 것) 확인
S = np.array([[2.,-1.],
              [-1.,3.]])
valsS, QS = npl.eigh(S) # 대칭전용 -&gt; 실수 / 직교 보장 (QR)
# 직교행렬이면 역행렬이 전치행렬
# QQ^{T} = Q^{T}Q = I이고, QQ^{-1} = I이므로 Q^{-1} = Q^T
S_rec = QS @ np.diag(valsS) @ QS.T # A = QΛQ^{-1} = QΛQ^{T}
print(&quot;||S - QΛQ^T|| =&quot;, npl.norm(S - S_rec)) # S와 고유값 분해된 값 비교</code></pre>
<h2 id="pca--공분산의-고유-분해">PCA : 공분산의 고유 분해</h2>
<h3 id="pca의-절차-code-example">PCA의 절차 (Code Example)</h3>
<ol>
<li>데이터의 평균을 0으로 정규화</li>
<li>공분산행렬 계산 - 공분산행렬은 대칭행렬이기에 항상 고유값 분해가 가능하다. 또한, 공분산은 데이터의 분산 구조를 담는 행렬이기에 공분산 행렬의 고유 벡터는 데이터의 분산이 최대/최소인 방향을 나타낸다. 이를 주성분 벡터라 부를 수 있고, 고유값은 그 방향에서의 분산 크기(정보량)를 의미한다.</li>
<li>고유값 분해</li>
<li>주성분 선택</li>
<li>차원 축소 및 원래 데이터 X를 선택된 주성분 벡터 W에 투영 (Z=XW)</li>
</ol>
<pre><code class="language-python">from sklearn.datasets import load_digits

digits = load_digits()
X = digits.data.astype(float)
X = X - X.mean(axis = 0, keepdims=True) # 정규화
n = X.shape[0] # 데이터 샘플 개수
C = (X.T @ X) / n # 공분산행렬

vals, vecs = npl.eigh(C) # 대칭전용 -&gt; 실수 / 직교 보장 (QR)
idx = np.argsort(vals)[::-1] # 고유값 내림차순 정렬 후, 가장 큰 고유값이 첫 번째가 되도록 순서 바꿈
vals, vecs = vals[idx], vecs[:, idx] # 고유벡터도 같은 순서 정렬

explained = vals / vals.sum() # 고유값이 전체 분산에서 차지하는 비율
print(&quot;Top-10 explained variance ratio : \n&quot;, explained[:10])

def pca_project(X, vecs, k):
  W = vecs[:, :k] # 앞에서부터 k개 주성분 벡터 선택 (64*k)
  Z = X @ W # Z = XW (원래 데이터 X를 선택된 주성분 벡터 W에 투영)
  return Z, W

def pca_reconstruct(Z, W):
  return Z @ W.T # ZW^T (축소된 데이터 Z를 원래 차원으로 복원)

# k = 주성분 개수
for k in [5, 10, 20, 40]:
  Z, W = pca_project(X, vecs, k)
  # digits.data.mean(axis=0, keepdims=True) - 원래 X에서 평균을 뺐었으므로 다시 더해줘서 복원
  Xk = pca_reconstruct(Z, W) + digits.data.mean(axis=0, keepdims=True)
  fig = plt.figure(figsize=(6, 2))
  for i in range(8):
    ax = plt.subplot(1, 8, i+1)
    ax.imshow(Xk[i].reshape(8,8), interpolation=&quot;nearest&quot;)
    ax.set_xticks([]); ax.set_yticks([])
  plt.suptitle(f&quot;PCA reconstruction with k = {k}&quot;)
  plt.tight_layout()
  plt.show()</code></pre>
<h1 id="특이값-분해-singular-value-decomposition">특이값 분해 (Singular Value Decomposition)</h1>
<p>&nbsp;고유값 분해는 정방 행렬만 분해 가능하다. 따라서 정방 행렬이 아닌 경우(비정방행렬)에 대해서도 분해를 수행하기 위해선 특이값 분해를 활용해야 한다. 이는 임의의 행렬을 $A=UΣV^T$의 형태로 행렬을 쪼개는 것을 의미한다.</p>
<p>&nbsp;이때, 고유값은 행렬 A가 직사각행렬이면 입출력 차원이 달라 상수배(고유값)을 직접 정의할 수 없다. 따라서 길이(스케일 크기)만 추출하기 위해 $AA^T$, $A^TA$의 결과가 항상 정방 행렬이고 대칭행렬인 점을 활용한다.</p>
<ul>
<li><p>$U$ (왼쪽 특이행렬) :  $AA^T$의 고유 벡터를 열에 배치한 n*n 행렬</p>
</li>
<li><p>$S$  (특이값 리스트) : $AA^T$의 고유값의 제곱근을 대각선에 배치한 대각 행렬 (n*m 행렬)</p>
</li>
<li><p>$V^T$ (특이행렬) : $A^TA$의 고유 벡터를 열에 배치한 m*m 행렬</p>
<p>  $$
  C = \frac{1}{n} X^T X = V \left( \frac{\Sigma^2}{n} \right) V^T ;\Rightarrow; \text{PCA 고유벡터} = V, \quad \text{고유값} = \frac{\Sigma^2}{n} 
  $$</p>
</li>
</ul>
<p>&nbsp;실제 차원 축소(PCA)의 구현에는 비정방행렬에도 적용이 가능한 보다 일반적인 방법인 SVD가 주로 사용된다.</p>
<pre><code class="language-python"># SVD로 얻은 주성분 벡터랑 공분산 고유분해로 얻은 주성분 벡터가 같은지 검증하는 코드

# PCA의 목표는 데이터가 가장 많이 퍼져있는 방향을 찾는 거
# 공분산 이용하는 이유 -&gt; 데이터의 분산 구조를 담는 행렬이기 때문
# 따라서 공분산 행렬의 고유벡터 = 데이터 분산이 최대/최소인 방향
# = 주성분 벡터
# 고유값 = 그 방향에서의 분산 크기

# SVD로 얻은 V와 공분산 고유벡터 비교
from sklearn.datasets import load_digits
digits = load_digits()
Xraw = digits.data.astype(float)
Xc = Xraw - Xraw.mean(axis=0, keepdims=True)

# 행렬 Xc에 대해 SVD 분해
# U : 왼쪽 특이행렬 / S : 특이값 리스트 / VT : 오른쪽 특이행렬
U, S, VT = npl.svd(Xc, full_matrices=False)
V = VT.T
C = (Xc.T @ Xc) / Xc.shape[0] # 공분산
valsC, vecsC = npl.eigh(C) # 고유값, 고유벡터
idx = np.argsort(valsC)[::-1] # 가장 큰 고유값
vecsC = vecsC[:, idx] # 가장 큰 고유값에 대응하는 고유벡터

# 주성분 부호/순서 불확정성을 감안해 상관을 비교
# V = SVD에서 나온 오른쪽 특이벡터들 -&gt; 공분산 행렬의 고유벡터와 같아야 함.
# C = 공분산행렬을 직접 고유분해해서 얻은 고유벡터
# V, C는 둘 다 직교행렬이므로 두 직교행렬을 곱하면 직교 행렬이 되며,
# 두 값이 완전히 같으면 곱한 값이 단위행렬이 됨.
# 따라서 SVD의 V와 공분산 고유벡터가 같은지 확인
# SVD 결과 = PCA임을 증명 가능함. (오른쪽 특이벡터랑 공분산 행렬이 같아야 PCA 결과 = SVD 결과가 성립)
corr = np.abs(np.diag(V.T @ vecsC))
print(&quot;Diagonal of |V^T * eigenvector(C)| (shold be near 1) : \n&quot;, corr[:10])</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[과대적합, 규제, Bias-Variance Trade-off]]></title>
            <link>https://velog.io/@inhwaaa_v/%EB%AA%A8%EB%8D%B8-%EC%84%A0%ED%83%9D%EA%B3%BC-%EA%B3%BC%EB%8C%80%EC%A0%81%ED%95%A9-%EB%B0%8F-%EA%B7%9C%EC%A0%9C</link>
            <guid>https://velog.io/@inhwaaa_v/%EB%AA%A8%EB%8D%B8-%EC%84%A0%ED%83%9D%EA%B3%BC-%EA%B3%BC%EB%8C%80%EC%A0%81%ED%95%A9-%EB%B0%8F-%EA%B7%9C%EC%A0%9C</guid>
            <pubDate>Sun, 19 Oct 2025 15:43:46 GMT</pubDate>
            <description><![CDATA[<h1 id="모델-선택과-과대적합">모델 선택과 과대적합</h1>
<h2 id="과소적합과-과대적합">과소적합과 과대적합</h2>
<p>&nbsp;과소적합(underfitting)이란, 모델이 훈련 데이터를 충분히 학습할 만큼 복잡하지 않고 단순하거나, 학습이 충분히 이뤄지지 않아 데이터에 존재하는 패턴들을 제대로 학습하지 못하는 것을 의미한다. 다시 말해, 훈련 데이터의 복잡도에 비해 모델의 형태가 단순하여 발생하는 문제이다. 이러한 과소적합 문제가 발생한 경우, 모델이 학습 데이터셋(Training Dataset)과 테스트 데이터셋(Test Dataset) 모두 현저히 낮은 성능을 보인다. </p>
<p>&nbsp;일반적으로 과소적합 문제는 모델의 복잡도가 낮거나, 학습 시간이 부족하거나, Feature Engineering이 미흡하여 발생한다. 따라서 복잡한 모델을 사용하거나, 에폭 수를 증가시켜 더 오래 학습시키거나, Feature Engineering을 보강하는 방법을 통해 해결한다.</p>
<p>&nbsp;과대적합(overfitting)이란, 모델이 지나치게 복잡해 학습 데이터의 패턴 뿐만 아니라 노이즈까지 학습하여 훈련 데이터가 아닌 다른 데이터에 대해선 일반화 성능이 떨어지는 현상을 의미한다. 이는 훈련 데이터의 복잡도에 비해 모델의 형태가 너무 복잡하여 훈련 데이터의 패턴 뿐만 아니라 노이즈까지 학습한 경우에 발생한다. 이러한 과대적합이 발생한 경우, 모델이 학습 데이터셋(Training Dataset)에서는 성능이 매우 높지만, 테스트 데이터(Test Dataset)에 대해서는 현저히 낮은 성능을 보인다.</p>
<p>&nbsp;일반적으로 과대적합 문제는 모델의 복잡도가 지나치거나, 데이터의 양이 적거나, 정규화가 부족할 때 발생한다. 따라서 L1, L2 등의 정규화 기법을 적용하거나, Dropout, Early Stopping, Data Augmentation, 모델 단순화 등의 방법들을 통해 해결한다.</p>
<p>&nbsp;과소적합과 과대적합 모두 일반화 성능이 떨어진다는 점에선 공통점을 지니나, 과소적합은 모델이 너무 단순해 패턴을 제대로 학습하지 못해 발생한 문제를, 과대적합은 모델이 너무 복잡해 훈련 데이터의 패턴과 노이즈까지 모두 학습해 버린 문제를 의미한다는 차이가 있다.</p>
<p>&nbsp;수능으로 예를 들면, 과소적합은 공부를 하지 않아 교과서 개념도 제대로 못 익혀 기출 문제도 제대로 못 푸는 경우를 의미하고, 과대적합은 모의고사 문제 등 특정 시험에만 맞춰져 새로운 유형이 나오는 실제 수능에선 시험을 잘 못 보는 경우를 의미한다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/765d9c7d-fd5b-49ca-bf27-49b40b40892b/image.png" alt=""></p>
<h2 id="bias-variance-trade-off">Bias-Variance Trade-off</h2>
<h3 id="bias와-variance">Bias와 Variance</h3>
<p>&nbsp;Bias는 모델의 단순함으로 인한 오차, 다시 말해  모델의 예측값이 실제 값과 얼마나 멀리 떨어져 있는지를 나타내며, Bias가 크다는 것은 모델이 틀린 가정 위에서 학습한다는 것을 의미한다. (ex) 비선형 관계를 가진 데이터를 선형 회귀로 학습하는 것</p>
<p>&nbsp;Variance는 데이터 변화에 민감하게 반응하는 정도(같은 데이터 분포에서 다른 학습 데이터 샘플을 사용했을 때 모델이 얼마나 다르게 학습되는지)를 의미한다. 다시 말해, 훈련 데이터가 조금만 바뀌어도 모델의 예측값이 크게 변하는 정도, 즉 불안정성을 의미하며, Variance가 크다는 것은 모델이 훈련 데이터에 과하게 민감하다는 것을 의미한다. (ex) 다항식 차수가 매우 높은 모델은 학습 데이터에 대해선 성능이 좋으나, 데이터가 조금만 달라져도 정확도가 낮아지는 과적합 문제가 발생함.</p>
<p>&nbsp;따라서 Bias가 크면 모델이 너무 단순해 패턴을 제대로 못 잡는 Underfitting 문제가 발생하고, 이로 인해 항상 일정한 방향으로 오차가 남는다.  Variance가 크면 모델이 너무 복잡해져 데이터에 들어있는 작은 변화나 노이즈까지 민감하게 반응하는 현상인 Overfitting 문제가 발생하며, 데이터셋이 조금만 바뀌어도 모델의 결과가 크게 달라진다. 이러한 Bias-Variance는 Trade-off 관계에 있으며, 이는 모든 모델의 한계가 된다. 따라서 이를 해결하기 위해선, 두 요소 사이의 균형점을 찾는 것이 중요하다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/d286b421-6d39-4679-8e4a-0d244d1e72d6/image.png" alt=""></p>
<h3 id="bias-variance-trade-off-1">Bias-Variance Trade-off</h3>
<p>&nbsp;편향-분산 트레이드오프(Bias-Variance Trade-off)란, 모델이 복잡해질 수록 편향은 감소하고 분산은 증가하는 경향이 있다는 것이다. 다시 말해, 복잡한 모델은 데이터의 노이즈까지 학습하려 하므로 편향은 줄지만(과소적합 감소), 데이터가 조금만 바뀌어도 예측이 크게 변동하여(분산 증가) 과대적합될 위험이 커진다는 것이다. 이러한 상황에서 일반화 성능이 좋은 모델, 다시 말해 테스트 데이터에 대해 높은 성능을 보이는 모델을 만들기 위해선 결국 가능한 <strong>작은 바이어스와 낮은 분산을 가지도록 하는 모델을 선택</strong>하는 것이 필요하다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/7ce20975-9810-409b-95f5-0b76f4109cbd/image.png" alt=""></p>
<h3 id="bias-variance-trade-off-수식-표현">Bias-Variance Trade-off 수식 표현</h3>
<ul>
<li>Bias의 제곱은 실제값과 예측값들의 평균의 차이를 의미하며, 낮은 Bias는 평균적으로 우리가 실제값에 근사하게 예측을 수행한다는 것을 의미하며, 높은 Bias는 실제값과 예측값의 평균이 멀리 떨어져있는 poor match를 의미함. (모델의 예측값이 실제 값과 얼마나 떨어져 있는가)</li>
<li>Variance는 예측값들의 평균으로부터 특정 예측값이 어느 정도 퍼져있는가를 의미하며, 낮은 Variance는 들어오는 데이터에 따라 예측 값이 크게 바뀌지 않으나, 높은 Variance는 들어오는 데이터에 따라 예측 값이 크게 바뀌므로 poor match를 의미함. (예측 모델의 복잡도)</li>
<li>Bias-Variance는 Trade-off 관계이므로, 이 세 가지 값을 다 더한 값이 최소가 되는 모델이 일반화 성능이 좋은 가장 좋은 모델임.</li>
</ul>
<p>$$
Expected  Error = (Bias^2) + Variance + \sigma^2
$$</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/49006f1c-1754-4d13-9ebc-2bcbd48b2de4/image.png" alt=""></p>
<blockquote>
<p><strong>일반화 성능이 좋은 모델이란?</strong>
  → 학습 데이터셋과 테스트 데이터셋에 대한 성능이 비슷한 모델을 의미함.</p>
</blockquote>
<blockquote>
</blockquote>
<p><strong>데이터셋의 구분</strong></p>
<ul>
<li>Training - 모델을 학습할 때 사용하는 데이터</li>
<li>Vaildation - 모델 학습 하이퍼파라미터 최적화와 과적합 방지, 모델 학습 중 모델 일반화 성능 검증을 위해 활용되는 데이터<ul>
<li>데이터의 양이 부족한 경우, 검증 데이터를 마련하기 어려울 수 있는데, 이 경우 k-fold CV를 활용함.</li>
<li>이는 Training set을 k개의 fold로 나누어 k-1개의 fold를 train set, 1개의 fold를 validation set으로 설정해 모델의 성능을 검증하는 방식을 의미하며, 절차는 다음과 같다.<ol>
<li>데이터를 k개(=fold)로 나눈 뒤, k-1개를 학습용으로 사용하고, 남은 한 개를 검증용으로 사용하여 훈련 및 평가를 진행함. (k-1개로 학습, 1개로 검증)</li>
<li>이 과정을 fold마다 번갈아 가며 k번 반복함. (모든 데이터가 한 번씩은 검증용으로 사용됨)</li>
<li>마지막에 k번의 검증 성능을 평균 내어 최종 성능으로 활용함.</li>
</ol>
</li>
</ul>
</li>
<li>Test - 실제 평가 시 사용되는 데이터로 최종적으로 모델 성능을 평가하는 데이터이므로 학습에 관여하지 않음.<ul>
<li>테스트 데이터는 최종 평가용으로만 활용하고, 모델 학습 과정에는 절대 관여해선 안 된다. 테스트 데이터를 검증 데이터로 활용하지 않는 이유도 이 이유에서이다. 모델 개발 과정에서 테스트 데이터를 검증 용도로 사용하면, 모델이 그 데이터를 직접적으로 학습하지 않았더라도 하이퍼파라미터 최적화나 모델 구조 선택에 간접적으로 활용되어 실제 새로운 데이터에 대한 일반화 성능을 왜곡한다.</li>
<li>테스트 데이터는 반드시 모델이 본 적 없는 완전히 새로운 데이터를 가정하고 평가해야 한다.</li>
</ul>
</li>
</ul>
<blockquote>
<p><strong>파라미터와 하이퍼파라미터의 구분</strong></p>
</blockquote>
<ul>
<li><strong>파라미터</strong> : 모델이 학습하는 변수</li>
<li><strong>하이퍼파라미터 :</strong> 학습을 위해 초기에 설정되어야 하는 상수나 변수 (파라미터를 위한 파라미터)</li>
</ul>
<h1 id="규제">규제</h1>
<h2 id="규제란">규제란?</h2>
<p>&nbsp;규제(Regularization)란, 모델의 복잡도를 낮추어 과적합을 방지하고 일반화 성능을 향상시키기 위해 사용되는 기법으로, 모델이 학습 데이터에만 특화되지 않고, 일반화가 가능하도록 일종의 규제를 가하는 것을 의미한다.  복잡한 모델은 작은 데이터 변화에도 쉽게 모델이 바뀔 수 있고, 이로 인해 일반화 성능이 떨어지기 때문에 규제를 적용한다. 가장 일반적인 규제 기법으로는 L1 규제(Lasso)와 L2 규제(Ridge)가 있다.</p>
<p>&nbsp;L1, L2 규제는 모델의 과적합 방지를 위해 사용하는 기술로, 모델의 손실 함수에 각각 L1 Loss Function, L2 Loss Function을 더해 주는 것을 의미한다. 다시 말해, 손실 함수에 패널티 항을 추가하는 것이다.</p>
<p>$$
E(b)<em>{new} = E(b)</em>{org} + penalty
$$</p>
<p>&nbsp;L1 규제는 손실 함수에 가중치의 절댓값을 더하는 형태를 지닌다. 따라서, 모델이 너무 큰 가중치를 가지면 손실이 커지게 되고, 학습 과정에서 손실을 최소화하기 위해 가중치를 줄이는 방향으로 업데이트가 이루어진다.</p>
<p>&nbsp;즉, 가중치가 클수록 비용이 커지고, 이를 줄이기 위해 가중치가 0을 향해 수렴하도록 학습된다. 그 결과, 큰 가중치는 억제되고 중요하지 않은 가중치는 완전히 0으로 수렴하여, 과적합이 줄고 더 단순하고 안정적인 모델을 만들 수 있다. (Feature Selection → 모델이 희소(sparse)해져 해석이 쉬워짐)</p>
<blockquote>
</blockquote>
<ul>
<li>Feature selection : 모델 예측에 불필요한 변수를 제거하는 과정</li>
<li>L1 규제에서의 기울기 범위는 [-1, 1], 즉 상수 범위로 일정하다. 따라서 w가 크면 업데이트 되는 w 값이 작아지고, w가 작으면 업데이트 되는 w 값이 커진다. 이러한 이유에 가중치들이 0 근처로 수렴하도록 규제할 수 있다.</li>
</ul>
<p>$$
\text{Cost} = \text{Loss} + \lambda \sum_{i=1}^{n} |w_i|
$$</p>
<p>$$
w^{(t+1)} = w^{(t)} - \eta \left( \frac{\partial \text{Loss}}{\partial w} + \lambda \cdot \text{sign}(w) \right)
$$</p>
<p>&nbsp;L2 규제는 손실 함수에 가중치 제곱의 합을 더하는 형태로, 큰 가중치를 더 강하게 억제할 수 있고, 모든 가중치가 조금씩 줄어들어 비슷한 크기의 값으로 퍼지므로(spread out) 파라미터가 분산되는 효과를 얻을 수 있다. 하지만, 가중치의 제곱 값을 활용하기에 L1 Regulazation에 비해 이상치에 더 민감하게 반응한다는 특징이 있다.</p>
<p>$$
\text{Cost} = \text{Loss} + \lambda \sum_{i=1}^{n} w_i^2
$$</p>
<p>$$
w_i^{(t+1)} = w_i^{(t)} - \eta \left( \frac{\partial \text{Loss}}{\partial w_i} + 2\lambda w_i^{(t)} \right)</p>
<p>$$</p>
<blockquote>
<p>*<em>참고 ) *</em>
 선형 회귀 모델에서 L1 규제를 주는 것을 Lasso, L2 규제를 주는 것을 Ridge라고 한다.</p>
</blockquote>
<h2 id="명시적-규제">명시적 규제</h2>
<p>&nbsp;명시적 규제란, 모델에 직접적으로 부하를 주는 기법들을 의미한다.</p>
<h3 id="가중치-감쇠-weight-decay">가중치 감쇠 (Weight Decay)</h3>
<ul>
<li>가중치를 강제로 축소하는 방법 (위에서 말한 L1, L2 와 같규제 항을 추가하는 것)</li>
<li>가중치를 작게 유지함으로써 일반화 능력을 향상시킴.</li>
</ul>
<h2 id="암시적-규제">암시적 규제</h2>
<p>&nbsp;암시적 규제란, 모델에 직접적으로 부하를 주는 것이 아닌, 모델을 조기에 종료하거나, 모델을 여러 개 사용하는 등의 방법들을 의미한다. 모델을 직접적으로 변형시키거나, 부하를 주진 않는 방법, 다시 말해 손실 함수에 명시적인 제약항을 추가하지 않고 모델의 학습 과정이나 구조적 특성으로 일반화를 유도하는 기법이다.</p>
<h3 id="드롭아웃-dropout">드롭아웃 (Dropout)</h3>
<p>&nbsp;드롭아웃이란, 학습 단계마다 무작위로 일부 뉴런을 비활성화하여 신경망이 특정 뉴런에만 과하게 의존하는 것을 방지하기 위한 기법을 의미한다. 이는 과적합을 줄이고 일반화 성능을 향상시키는 효과가 있는데, 그 이유는 학습 과정에서 특정 가중치/뉴런에 과하게 의존하지 않고, 다양한 경로를 학습할 수 있게 하기 때문이다.</p>
<p>&nbsp;다시 말해, Dropout은 co-adaptation(신경망에서 특정 뉴런들이 서로 강하게 의존되는 현상)을 방지하는 효과가 있다. 특정 뉴런이 특정 feature만 잘 잡아내서 다른 뉴런이 그 feature를 잡아내지 않고, 다른 역할만 하면 뉴런들이 독립적으로 일반화된 표현을 학습하지 못 하는 현상이 발생할 수 있는데, Dropout은 이를 방지할 수 있고, 이를 통해, 각 뉴런이 다른 뉴런에 과도하게 의존하지 않고 자신만의 독립적 표현을 학습할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/1d636c46-d86d-4aa5-8a8c-64b52edc1a83/image.png" alt=""></p>
<h3 id="early-stopping">Early Stopping</h3>
<ul>
<li>모델이 과적합되기 전 훈련을 멈추는 정규화 기법으로, 모델 훈련 중 검증 데이터의 성능을 모니터링하고, 검증 데이터의 손실이 일정 횟수 동안 개선되지 않으면 학습을 중단함.</li>
<li>다시 말해, 검증 데이터의 오류가 최저인 지점을 만나면 조기에 학습을 종료하는 기법을 의미함. 이를 통해 모델이 훈련 데이터에 과적합되기 전에 일반화 성능이 가장 좋은 지점에서 종료 가능함.</li>
</ul>
<h3 id="data-augmentation">Data Augmentation</h3>
<ul>
<li>학습을 위한 정보의 양을 늘리기 위해/더 많은 특징을 뽑아내기 위해 Data Augmentation 기법을 활용할 수 있으며, 이는 기존의 데이터를 변형하여 새로운 데이터를 생성하는 기법임.</li>
</ul>
<h3 id="ensemble">Ensemble</h3>
<p>&nbsp;앙상블 학습의 핵심은 여러 개의 Weak Classifier를 결합해 Strong Classifier를 만드는 것이며, 이를 위해 Bagging이나 Boosting 등의 방법을 활용할 수 있다. </p>
<ul>
<li>Bagging : 샘플을 여러 번 뽑아 각 모델을 학습시켜 결과물을 집계하는 방법, 다시 말해 같은 모델 구조를 여러 번 학습시키되, 데이터를 무작위로 다르게 샘플링해 학습시키는 방</li>
<li>Boosting : 가중치를 활용해 Weak Classifier를 Strong Classifier를 만드는 것으로, 모델의 예측 결과에 따라 가중치를 부여하여 다음 모델에 영향을 줌.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/9bde946f-7ecc-457e-91bd-a5e88417e42d/image.png" alt=""></p>
<ul>
<li>Voting : 서로 다른 여러 모델의 결과를 결합하는 방법<ul>
<li>hard Voting : 각 분류기가 뽑은 최종 클래스에 대해 다수결 투표로 결정하는 것</li>
<li>Soft Voting : 각 분류기가 예측한 확률을 평균 내고, 그 중 가장 확률이 높은 클래스를 선택하는 것</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/de0e8c9e-516c-4d73-a979-af26526f9ce5/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[특징 공간과 차원의 저주]]></title>
            <link>https://velog.io/@inhwaaa_v/%ED%8A%B9%EC%A7%95-%EA%B3%B5%EA%B0%84%EA%B3%BC-%EC%B0%A8%EC%9B%90%EC%9D%98-%EC%A0%80%EC%A3%BC</link>
            <guid>https://velog.io/@inhwaaa_v/%ED%8A%B9%EC%A7%95-%EA%B3%B5%EA%B0%84%EA%B3%BC-%EC%B0%A8%EC%9B%90%EC%9D%98-%EC%A0%80%EC%A3%BC</guid>
            <pubDate>Sun, 19 Oct 2025 12:54:02 GMT</pubDate>
            <description><![CDATA[<h1 id="특징-공간과-결정-공간">특징 공간과 결정 공간</h1>
<p>&nbsp;특징 공간(Feature Space)이란, 데이터의 각 샘플이 가진 여러 특징(feature)들을 좌표로 표현하는 다차원 공간이다. 쉽게 말해, 특징들이 수치회된 벡터 공간이라고 할 수 있다. 머신러닝에서는 데이터가 특징 공간 내의 점(벡터)으로 표현되며, 모델이 패턴과 관계, 군집 등을 찾아내고 학습할 수 있도록 한다.</p>
<p>&nbsp;이러한 특징 변수가 1개이면 1차원의 특징 공간, 2개이면 2차원의 공간, 세 개면 3차원의 공간, N개이면 N차원의 공간이 형성된다. 이러한 특징 공간을 표현할 땐, 특징과 목푯값을 함께 축으로 표시할 수도 있고, 특징만을 축으로 표현할 수도 있다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/11f41ccf-0e00-4bec-a1a3-8c3101abc92b/image.png" alt=""></p>
<p>&nbsp;결정 공간(Decision Space)이란, 결과 값에 따라 나눠질 수 있는 공간으로, 모델이 입력 x를 받아 결과를 도출하는 공간을 의미한다. 이는 최종 Output 결정하는 공간이며, 특징 공간의 점이 어떤 클래스에 속하는지 표현한다. 이러한 결정 공간 안에서 클래스를 구분하기 위해선 결정 경계를 활용한다.</p>
<p>&nbsp;결정 경계(Decision Boundary)란, 머신러닝의 특징 공간 안에서 분류 모델이 각기 다른 클래스(범주)의 데이터를 나누는 기준이 되는 선, 면, 혹은 초평면을 의미한다. 쉽게 말해, 서로 다른 클래스에 속하는 데이터들을 구분하기 위한 경계라고 할 수 있다.</p>
<p>&nbsp;이러한 결정 경계는 특징 공간의 차원에 따라 다른 이름으로 불린다.</p>
<ul>
<li><strong>결정선 (Decision Line)</strong> : 2차원 결정 공간에서 사용되며, 결정 경계가 선 형태로 나타남.</li>
<li><strong>결정면 (Decision Plane)</strong> : 3차원 결정 공간에서 사용되며, 결정 경계가 평면 형태로 나타남.</li>
<li><strong>결정 초평면 (Decision Hyperplane)</strong> : 3차원보다 큰 결정 공간에서 사용되며, 시각적으로 그릴 순 없지만, 수학적으로는 n-1차원의 초평면이 존재하여 n차원 공간 데이터를 나눔. 다시 말해, 결정선과 결정면을 고차원으로 일반화한 개념임.</li>
</ul>
<p>&nbsp;이러한 특징 공간과 결정 공간은, 같은 공간일수도 있고, 아닐 수도 있다. 모델의 각 층이 만들어 내는 특징 공간이 연속적으로 이어지다가 마지막 층에서 결정 공간으로 귀결되기도 하고, 최종 특징 공간에서의 표현이 결정 함수에 의해 변환되어 특징 공간과 결정 공간이 다르게 표현되기도 하기 때문이다.</p>
<p>&nbsp;분류 모델의 목표는 결국 최적의 결정 경계를 찾는 것이며, 모델이 학습을 한다는 것은 주어진 데이터를 가장 잘 나눌 수 있는 결정 경계(선, 면, 초평면)의 위치와 형태를 찾아가는 과정이라고 할 수 있다.</p>
<blockquote>
<p><strong>정리 )</strong></p>
</blockquote>
<ul>
<li>특징 공간 : 입력 데이터가 존재하는 공간으로, 각 축이 입력 변수에 대응함.</li>
<li>결정 공간 : 모델이 특징 벡터 x를 결정 함수 f(x)를 통해 출력으로 사상시킨 공간 (다시 말해, 모델이 내린 분류 결과들이 표현된 공간)</li>
<li>결정 경계 : f(x) 값이 클래스 간 임계점에 해당하는 지점들의 집합</li>
</ul>
<h1 id="차원의-저주-curse-of-dimensionality">차원의 저주 <strong>(Curse of dimensionality)</strong></h1>
<p>&nbsp;차원의 저주(Curse of dimensionality)란, 학습 데이터에 비해 입력 차원의 수가 큰 경우 일정 차원을 기점으로 학습 능력이 급격히 감소하는 현상을 의미한다. 다시 말해, 특징 공간의 차원이 증가하면서 학습 데이터의 수가 특징 공간의 차원의 수보다 적어져 성능이 저하되는 것이다.</p>
<p>&nbsp;차원이 증가할 수록 특징 공간의 부피가 커지고, 개별 차원 내에서의 데이터의 밀도가 희소해지며, 이에 따라 거리 함수가 제대로 작동하지 않고, 계산 비용이 증가하는 등의 문제가 발생하는데, 이러한 문제를 차원의 저주라고 한다.</p>
<p>&nbsp;그러나, 입력 차원 수가 증가한다고 반드시 차원의 저주가 발생하는 것은 아니며, 학습 데이터보다 입력 차원의 수가 많아지는 경우에 차원의 저주 문제가 발생한다. 공간이 희소해짐에 따라, 저차원 데이터에서 패턴을 파악하는 것보다 고차원 데이터에서 패턴을 파악하는데 더 많은 데이터가 필요해지기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/acd9504b-9a30-44fc-b265-b03962723008/image.png" alt=""></p>
<p>&nbsp;이러한 차원의 저주는 차원이 증가함에 따라 동일한 데이터 개수로는 공간을 충분히 채울 수 없게 되어 데이터의 분포가 희소해지기에 발생한다. 입력 데이터의 차원이 증가하면, 특징 공간의 부피가 차원에 따라 기하급수적로 증가하여 데이터 간 거리가 멀어지고, 학습 데이터의 밀도가 낮아진다.</p>
<p>&nbsp;이러한 차원의 저주 문제를 해결하기 위한 이론적인 해결책은 훈련 샘플의 밀도가 충분히 높아질 때까지 데이터를 모아서 훈련 세트의 크기를 키우는 것이다. 그러나, 일정 밀도에 도달하기 위해 필요한 훈련 샘플 수는 차원의 수가 커짐에 따라 기하급수적으로 늘어난다는 문제가 존재한다. 따라서 PCA, SVD 등과 같은 차원 축소 기법들을 통해 학습 결과에 영향을 미치지 않는 불필요한 축을 줄임으로써 차원의 저주를 완화하기도 한다.</p>
<blockquote>
<p><strong>정리 )</strong></p>
</blockquote>
<ul>
<li>“A function defined in high-dimensional space is likely to be much more complex than a function defined in a lower-dimensional space, and those complications are harder to discern” – Friedman</li>
<li>This means that a more complex target function requires denser sample points to learn it well!
<img src="https://velog.velcdn.com/images/inhwaaa_v/post/62912703-b199-4e92-bc28-b7589439f33e/image.png" alt=""></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fuzzy Logic]]></title>
            <link>https://velog.io/@inhwaaa_v/Fuzzy-Logic</link>
            <guid>https://velog.io/@inhwaaa_v/Fuzzy-Logic</guid>
            <pubDate>Sat, 18 Oct 2025 18:45:39 GMT</pubDate>
            <description><![CDATA[<h1 id="1-퍼지논리">1. 퍼지논리</h1>
<h2 id="11-퍼지-논리fuzzy-logic란">1.1 퍼지 논리(Fuzzy Logic)란</h2>
<p>&nbsp;Fuzzy라는 단어는, 흐릿한, 불분명한이라는 의미를 가진다. 여기서도 알 수 있듯, <strong>퍼지 논리(Fuzzy Logic)</strong>는 명확하게 구분되지 않는 개념을 수학적으로 다루는 방법, 다시 말해 정확하지 않거나(imprecise) 불완전한(incomplete) 정보를 처리하는 데 유용한 논리 체계를 의미한다. 이는 1965년 L. A. Zadeh가 퍼지 집합 이론을 제시하면서 등장했고, 이후 퍼지 명제나 규칙을 다루기 위한 퍼지 논리로 발전하였다.</p>
<h2 id="12-크리스프-집합-vs-퍼지-집합">1.2 크리스프 집합 vs 퍼지 집합</h2>
<ul>
<li><strong>크리스프 집합 (Crisp Set)</strong> : 원소가 집합에 속하면 1, 아니면 0</li>
<li><strong>퍼지 집합 (Fuzzy Set)</strong> : 원소가 집합에 속하는 정도를 0~1 사이 값으로 표현</li>
<li>$키 큰 사람 = {(170, 0.3), (175, 0.5), (180, 0.95), (190, 1.0)}$</li>
<li>명제 논리 == 기존 집합 (크리스프 집합), 퍼지 논리 == 퍼지 집합</li>
</ul>
<blockquote>
<p>원소가 속하거나 속하지 않는 두 가지로만 구분되는 크리스프 집합과는 달리, 퍼지 집합에서는 원소가 특정 집합에 속하는 정도를 표현함.
그렇다고 해서, 퍼지 논리가 애매한 논리인 것은 아니다. 퍼지 논리는 애매함을 다루는 질서정연한 논리이다.</p>
</blockquote>
<h2 id="13-퍼지-연산자">1.3 퍼지 연산자</h2>
<ul>
<li>교집합 (AND) : $min(μ_A(x), μ_B(x))$</li>
<li>합집합 (OR) : $max(μ_A(x), μ_B(x))$</li>
<li>여집합 (NOT) : $1 - μ_A(x)$</li>
</ul>
<h2 id="14-퍼지-추론-과정">1.4 퍼지 추론 과정</h2>
<ul>
<li><strong>퍼지화 (Fuzzification)</strong> : 입력값을 소속 함수로 변환</li>
<li><strong>규칙 적용 (Rule Application)</strong> : 각 규칙의 조건에 맞게 소속 함수 값을 계산</li>
<li><strong>결합 (Aggregation)</strong> : 여러 규칙의 결과를 결합</li>
<li><strong>역퍼지화(Defuzzification)</strong> : 퍼지 결과를 실제 수치로 변환</li>
</ul>
<h3 id="141-헤지">1.4.1 헤지</h3>
<ul>
<li>퍼지 집합의 모양을 바꾸는 퍼지 집합 한정사 (퍼지 집합의 의미를 강화하거나 약화시키는 연산자처럼 작동)</li>
<li>(ex) 매우, 얼마간, 꽤, 다소, 조금 등</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/a76d28fe-7bec-4326-92d1-86d084cf23e0/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/f763a88f-0e2e-4d89-b258-19c6e87de832/image.png" alt=""></p>
<h3 id="142-퍼지-규칙">1.4.2 퍼지 규칙</h3>
<ul>
<li>IF x가 A THEN y는 B에서 x, y는 언어 변수, A, B는 각각 논의 영역 X와 Y의 퍼지 집합에서 결정된 언어 값</li>
<li>규칙 후건의 출력 값이나 소속도는 전건의 소속도에서 직접 추정할 수 있음.</li>
<li>퍼지 규칙의 전건은 여러 개일 수 있으며, 전건의 모든 부분은 동시에 계산되고, 숫자 하나로 결정됨. (-&gt; 이를 위해 퍼지 집합 연산을 사용함)</li>
<li>후건 역시 여러 개일 수 있음. (ex) IF 기온이 높다 THEN 뜨거운 물은 줄어든다 시원한 물은 늘어난다</li>
</ul>
<h3 id="143-퍼지-추론-기법">1.4.3 퍼지 추론 기법</h3>
<p><strong>맘다니형 추론 : 가장 흔히 쓰이는 퍼지 추론 기법</strong></p>
<blockquote>
<p>1단계 : 입력 변수의 퍼지화 
  2단계 : 규칙 평가 
  3단계 : 출력으로 나온 규칙의 통합
  4단계 : 역퍼지화</p>
</blockquote>
<p><strong>맘다니형 추론 예시 - 입력 2개, 출력 1개, 규칙 3개 문제 예시</strong></p>
<ul>
<li>x, y, z는 언어 변수 (프로젝트 자금, 인력, 위험도)</li>
<li>A1, A2, A3는 논의 영역 x 상의 퍼지 집합에서 정해지는 언어 값 (부족하다, 한계 수익점에 있다, 충분하다)</li>
<li>B1, B2는 논의 영역 y 상의 퍼지 집합에서 정해지는 언어 값 (적다, 많다)</li>
<li>C1, C2, C3는 논의 영역 z 상의 퍼지 집합에서 정해지는 언어 값 (낮다, 중간이다, 높다)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/aad72db7-fd9c-4ba2-8494-854fee69f858/image.png" alt=""></p>
<p><strong>1단계 : 입력 변수의 퍼지화</strong></p>
<ul>
<li>크리스프 입력 x1(프로젝트 자금), y1(프로젝트 인력)을 받고, 이를 적합한 퍼지 집합에 각각 어느 정도로 속할지 결정</li>
<li>논의 영역 : X, Y</li>
</ul>
<p><strong>2단계 : 규칙 평가</strong></p>
<ul>
<li>퍼지 입력 $μ<em>{(x=A1)} = 0.5$, $μ</em>{(x=A2)} = 0.2$, $μ<em>{(x=B1)} = 0.1$, $μ</em>{(x=B2)} = 0.7$을 받아 퍼지 규칙의 전건에 적용</li>
<li>주어진 퍼지 규칙에 전건이 여러 개 있다면 퍼지 연산자를 사용해 전건의 평가 결과를 나타내는 숫자 하나를 얻고, 이 숫자를 후건의 소속 함수에 적용함.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/90c32f32-54eb-421d-a72c-e5601cf84a69/image.png" alt=""></p>
<p><strong>3단계 : 출력으로 나온 규칙의 통합</strong></p>
<ul>
<li>여러 개 규칙이 동시에 발화되면 각 규칙마다 후건부 퍼지 집합이 나오는데, 이 결과들을 하나의 최종 퍼지 집합으로 합치는 것</li>
</ul>
<p><strong>4단계 : 역퍼지화</strong></p>
<ul>
<li>최종 퍼지 집합을 단일 수치로 바꾸는 것</li>
<li>대표적인 방법 -&gt; 무게 중심법</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/c1813c8f-e6de-4f24-90b5-26f7ae555d44/image.png" alt=""></p>
<p><strong>스게노형 추론 : 퍼지 집합 대신 입력 변수에 대한 수학 함수를 사용하는 추론 방법</strong></p>
<ul>
<li>맘다니형과 거의 비슷하나, 후건부가 입력 변수에 대한 수학 함수라는 차이가 존재함.</li>
<li>낮다, 보통이다 등의 언어적 표현이 아닌 상수로 값을 표현함.</li>
<li>(ex) IF 온도가 높음 THEN 팬 속도 = 0.5 * 온도 + 20</li>
<li>규칙 후건부를 단일체로 표현할 수 있음. (규칙 후건을 통합할 때 연속적인 것이 아닌 개별적인 값들로써 역퍼지화가 가능함. / 다시 말해 단일체의 가중평균으로 출력을 구함.)</li>
<li>효율적인 계산 / 빠른 역퍼지화<blockquote>
<p>스게노형 퍼지 규칙 형식
IF x가 A 
AND y가 B
THEN Z는 f(x, y) 
// x, y, z : 언어 변수
// A, B : 논의 영역을 x, y로 하는 퍼지 집합 
// f(x, y) : 수학 함수</p>
</blockquote>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/inhwaaa_v/post/51925bc9-a78e-4e2b-a891-c6628f3c9a1a/image.png" alt="">
<img src="https://velog.velcdn.com/images/inhwaaa_v/post/ae5279f4-2fd9-4fb4-9e7a-f885f348fc28/image.png" alt=""></p>
<p><strong>맘다니형 추론과 스게노형 추론의 차이</strong></p>
<ul>
<li>맘다니형 추론 방법은 전문 지식을 더욱 객관적이고 인간의 방식으로 설명할 수 있다는 장점이 있으나, 퍼지 집합을 다루기에 계산 비용이 많이 듦.</li>
<li>스게노형 추론 방법은 계산을 효율적으로 할 수 있고, 최적화나 적응형 기법과 함께 잘 작동함.</li>
</ul>
<h2 id="15-함축-연산자-처리">1.5 함축 연산자 처리</h2>
<ul>
<li>IF-THEN 부분을 연결하는 방법으로, IF 부분의 소속도 값과 THEN 부분의 소속도 값을 이용해 출력을 결정한다.</li>
<li>(ex) IF (0.7) THEN (aircon is &quot;high&quot;) -&gt; min(0.7, &quot;high&quot;)</li>
</ul>
<h1 id="code-example">Code Example</h1>
<h2 id="팬-속도-제어">팬 속도 제어</h2>
<ul>
<li>입력 : 온도 (0~40℃)</li>
<li>출력 : 팬 속도 (0~100%)</li>
<li>규칙 예시 :<ul>
<li>IF 온도가 낮음 → 팬 속도 낮음</li>
<li>IF 온도가 보통 → 팬 속도 보통</li>
<li>IF 온도가 높음 → 팬 속도 높음</li>
</ul>
</li>
</ul>
<pre><code>!pip install scikit-fuzzy matplotlib --quiet

import numpy as np
import skfuzzy as fuzz
import matplotlib.pyplot as plt

# -----------------------------------------------------
# 1. 변수 정의
# -----------------------------------------------------
x_temp = np.arange(0, 41, 1) # 0부터 40까지 숫자 배열 (온도)
x_fan = np.arange(0, 101, 1) # 0부터 100까지 숫자 배열 (팬 속도)

# -----------------------------------------------------
# 2. 소속 함수 정의 (Membership function를 정의)
# -----------------------------------------------------
# fuzz.trimf : Triangular membership function
# syntax : y = trimf(x,params)
# (입력값, [삼각형 소속 함수 세 꼭짓점 좌표])
# (왼쪽 시작점, 꼭짓점(최대 소속도=1), 오른쪽 끝점)
temp_low = fuzz.trimf(x_temp, [0, 0, 20])
temp_med = fuzz.trimf(x_temp, [10, 20, 30])
temp_high = fuzz.trimf(x_temp, [20, 40, 40])

fan_low = fuzz.trimf(x_fan, [0, 0, 50])
fan_med = fuzz.trimf(x_fan, [20, 50, 75])
fan_high = fuzz.trimf(x_fan, [50, 100, 100])

# -----------------------------------------------------
# 3. 입력값 설정
# -----------------------------------------------------
# fuzz.interp_membership(x, xmf, xx) : 특정 입력값에 대한 소속도(멤버십 값) 계산
# x : 입력 범위, xmf : 퍼지 집합의 멤버십 함수, xx : 평가 원하는 값
temp_value = 28

temp_level_low = fuzz.interp_membership(x_temp, temp_low, temp_value) # 범위밖 -&gt; 소속도 : 0
temp_level_med = fuzz.interp_membership(x_temp, temp_med, temp_value) # (c - x) / (c - b) -&gt; (30 - 28) / (30 - 20) -&gt; 소속도 : 0.2
temp_level_high = fuzz.interp_membership(x_temp, temp_high, temp_value) # (x - a) / (b - a) -&gt; (28 - 20) / (40 - 20) -&gt; 소속도 : 0.4

print(&quot;소속도(28도) : 낮음 = %.2f, 보통 = %.2f, 높음 = %.2f&quot; % (temp_level_low, temp_level_med, temp_level_high))

# -----------------------------------------------------
# 4. 규칙 적용
# -----------------------------------------------------
fan_activation_low = np.fmin(temp_level_low, fan_low) # IF 온도가 낮음 → 팬 속도 낮음
fan_activation_med = np.fmin(temp_level_med, fan_med) # IF 온도가 보통 → 팬 속도 보통
fan_activation_high = np.fmin(temp_level_high, fan_high) # IF 온도가 높음 → 팬 속도 높음

# -----------------------------------------------------
# 5. 결과 결합 및 역퍼지화
# -----------------------------------------------------
# np.fmax(a, b) : 두 값 중 큰 값 반환 (OR 연산)
# aggregated : 소속도가 가장 큰 값 찾는 거
aggregated = np.fmax(fan_activation_low, np.fmax(fan_activation_med, fan_activation_high))
# fuzz.defuzz(x, mf, &#39;centroid&#39;) : 퍼지값을 실제 값으로 반환
# &#39;centroid&#39; : 집합의 평균 계산
# &#39;bisector&#39; : 곡선을 반으로 나눈 값
# &#39;mom/som/lom&#39; : 최대 소속도를 갖는 값들 중 각각 중앙/최소/최대값
print(aggregated)
fan_result = fuzz.defuzz(x_fan, aggregated, &#39;centroid&#39;)
print(&quot;결정된 팬 속도 : %.2f %%&quot; % fan_result)

numerator = np.sum(x_fan * aggregated)
denominator = np.sum(aggregated)
fan_result = numerator / denominator
print(numerator, denominator, fan_result)

# -----------------------------------------------------
# 6. 시각화
# -----------------------------------------------------
plt.figure(figsize = (8, 4))
plt.plot(x_fan, fan_low, &#39;b&#39;, linewidth=1.5, label=&#39;Low&#39;)
plt.plot(x_fan, fan_med, &#39;g&#39;, linewidth=1.5, label=&#39;Medium&#39;)
plt.plot(x_fan, fan_high, &#39;r&#39;, linewidth=1.5, label=&#39;High&#39;)
plt.axvline(fan_result, color=&#39;k&#39;, linestyle=&#39;--&#39;, label=&quot;Output (defuzzified)&quot;)
plt.legend()
plt.title(&quot;Fuzzy Inference Result (Fan Speed)&quot;)
plt.show()</code></pre><h2 id="팁-주기-문제">팁 주기 문제</h2>
<ul>
<li>입력 : 서비스 (0-10), 음식 맛 (0-10)</li>
<li>출력 : 팁 퍼센트 (0-30%)</li>
<li>규칙 예시 :<ul>
<li>IF 서비스가 형편없다 OR 음식이 맛없다 → 팁 적게</li>
<li>IF 서비스가 보통이다 → 팁 보통</li>
<li>IF 서비스가 훌륭하다 OR 음식이 맛있다 → 팁 많이</li>
</ul>
</li>
</ul>
<pre><code># -----------------------------------------------------
# 1. 변수 정의
# -----------------------------------------------------
x_service = np.arange(0, 11, 1) # 0부터 10까지 숫자 배열 (서비스)
x_food = np.arange(0, 11, 1) # 0부터 10까지 숫자 배열 (음식 맛)
x_tip = np.arange(0, 31, 1) # 0부터 30까지 숫자 배열 (팁 퍼센트)

# -----------------------------------------------------
# 2. 소속 함수 정의
# -----------------------------------------------------
# fuzz.trimf : Triangular membership function
# (입력값, [삼각형 소속 함수 세 꼭짓점 좌표])

# 서비스 : 0 ~ 10
service_low = fuzz.trimf(x_service, [0, 0, 5])
service_med = fuzz.trimf(x_service, [0, 5, 10])
service_high = fuzz.trimf(x_service, [5, 10, 10])

# 음식 맛 : 0 ~ 10
food_bad = fuzz.trimf(x_food, [0, 0, 5])
food_ok = fuzz.trimf(x_food, [0, 5, 10])
food_good = fuzz.trimf(x_food, [5, 10, 10])

# 팁 : 0 ~ 30
tip_low = fuzz.trimf(x_tip, [0, 0, 15])
tip_med = fuzz.trimf(x_tip, [0, 15, 20])
tip_high = fuzz.trimf(x_tip, [15, 30, 30])

# -----------------------------------------------------
# 3. 입력값 (서비스 = 7, 음식 = 8)
# -----------------------------------------------------
# fuzz.interp_membership(x, xmf, xx) : 특정 입력값에 대한 소속도(멤버십 값) 계산
# x : 입력 범위, xmf : 퍼지 집합의 멤버십 함수, xx : 평가 원하는 값

service_val = 7
food_val = 8


service_level_low = fuzz.interp_membership(x_service, service_low, service_val) # 범위 밖
service_level_med = fuzz.interp_membership(x_service, service_med, service_val) # 높음
service_level_high = fuzz.interp_membership(x_service, service_high, service_val) # 높음

print(&quot;소속도(서비스 = 7) : 낮음 = %.2f, 높음 = %.2f, 높음 = %.2f&quot; % (service_level_low, service_level_med, service_level_high))


food_level_bad = fuzz.interp_membership(x_food, food_bad, food_val) # 범위 밖
food_level_ok = fuzz.interp_membership(x_food, food_ok, food_val) # 높음
food_level_good = fuzz.interp_membership(x_food, food_good, food_val) # 높음

print(&quot;소속도(음식 = 8) : 낮음 = %.2f, 높음 = %.2f, 높음 = %.2f&quot; % (food_level_bad, food_level_ok, food_level_good))

# -----------------------------------------------------
# 4. 규칙 적용
# -----------------------------------------------------
rule1 = np.fmax(service_level_low, food_level_bad) # 서비스가 나쁘거나 음식이 별로면
activation_rule1 = np.fmin(rule1, tip_low) # 팁 조금

rule2 = service_level_med # 서비스 중간
activation_rule2 = np.fmin(rule2, tip_med) # 팁 중간

rule3 = np.fmax(service_level_high, food_level_good) # 서비스도 좋고 음식도 좋음
activation_rule3 = np.fmin(rule3, tip_high) # 팁 많이


# -----------------------------------------------------
# 5. 결과 결합 및 역퍼지화
# -----------------------------------------------------
aggregate = np.fmax(activation_rule1, np.fmax(activation_rule1, activation_rule2))
print(aggregate)
tip_result = fuzz.defuzz(x_tip, aggregate, &#39;centroid&#39;)
print(&quot;결정된 팁 : %.2f %%&quot; % tip_result)

numerator = np.sum(x_tip * aggregate)
denominator = np.sum(aggregate)
fan_result = numerator / denominator
print(numerator, denominator, fan_result)

# -----------------------------------------------------
# 6. 시각화
# -----------------------------------------------------
plt.figure(figsize = (8, 4))
plt.plot(x_tip, tip_low, &#39;b&#39;, linewidth=1.5, label=&#39;Low&#39;)
plt.plot(x_tip, tip_med, &#39;g&#39;, linewidth=1.5, label=&#39;Medium&#39;)
plt.plot(x_tip, tip_high, &#39;r&#39;, linewidth=1.5, label=&#39;High&#39;)
plt.axvline(tip_result, color=&#39;k&#39;, linestyle=&#39;--&#39;, label=&quot;Output (defuzzified)&quot;)
plt.legend()
plt.title(&quot;Fuzzy Inference Result (Tip Percentage)&quot;)
plt.show()</code></pre>]]></description>
        </item>
    </channel>
</rss>