{const cs =44, gap =3, st = poolSteps[poolStep];const W =520, H =230;const svg = d3.create("svg").attr("viewBox",`0 0 ${W}${H}`).attr("width","100%").style("max-width",`${W}px`).style("font-family","Pretendard, sans-serif"); svg.append("text").attr("x",20+2* (cs + gap)).attr("y",18).attr("text-anchor","middle").attr("font-size","12").attr("font-weight","600").attr("fill","#555").text("입력 특성 맵 (4×4)");for (let r =0; r <4; r++) for (let c =0; c <4; c++) {const on =Math.floor(r /2) === st.r&&Math.floor(c /2) === st.c, isMax = on && poolIn[r][c] === st.max; svg.append("rect").attr("x",20+ c * (cs + gap)).attr("y",30+ r * (cs + gap)).attr("width", cs).attr("height", cs).attr("rx",3).attr("fill", isMax ?"#FFCDD2": on ?"#FFF9C4":"#C8E6C9").attr("stroke", isMax ?"#C62828": on ?"#F57F17":"#4CAF50").attr("stroke-width", on ?2.5:1); svg.append("text").attr("x",20+ c * (cs + gap) + cs /2).attr("y",30+ r * (cs + gap) + cs /2+5).attr("text-anchor","middle").attr("font-size","15").attr("font-weight", isMax ?"700":"400").attr("fill", isMax ?"#C62828":"#333").text(poolIn[r][c]); } svg.append("text").attr("x",250).attr("y",30+2* (cs + gap)).attr("text-anchor","middle").attr("font-size","12").attr("fill","#555").text("2×2 Max →"); svg.append("text").attr("x",340+ (cs + gap)).attr("y",18).attr("text-anchor","middle").attr("font-size","12").attr("font-weight","600").attr("fill","#555").text("출력 (2×2)");for (let r =0; r <2; r++) for (let c =0; c <2; c++) {const idx = r *2+ c, done = idx <= poolStep, cur = r === st.r&& c === st.c; svg.append("rect").attr("x",340+ c * (cs + gap)).attr("y",30+ (cs + gap) + r * (cs + gap)).attr("width", cs).attr("height", cs).attr("rx",3).attr("fill", cur ?"#FFCDD2": done ?"#FFEBEE":"#fafafa").attr("stroke", cur ?"#C62828": done ?"#E57373":"#ccc").attr("stroke-width", cur ?2.5:1);if (done) svg.append("text").attr("x",340+ c * (cs + gap) + cs /2).attr("y",30+ (cs + gap) + r * (cs + gap) + cs /2+5).attr("text-anchor","middle").attr("font-size","15").attr("fill", cur ?"#C62828":"#333").attr("font-weight", cur ?"700":"400").text(poolSteps[idx].max); } svg.append("text").attr("x", W /2).attr("y", H -12).attr("text-anchor","middle").attr("font-size","12").attr("fill","#8C1515").attr("font-weight","600").text(`스텝 ${poolStep +1}/4 — 창 안의 값 {${st.vals.join(", ")}} 중 최댓값 ${st.max}`);return svg.node();}
채널이 여러 개일 때
풀링은 채널마다 따로 적용한다. \(14 \times 14 \times 6\) 특성 맵에 \(2 \times 2\) 풀링을 하면 여섯 장 각각이 \(7 \times 7\)이 되어 \(7 \times 7 \times 6\)이다. 크기는 절반이 되고 채널 수는 그대로다.
왜 풀링을 쓰는가
이미지의 크기를 줄여도 그 안의 특성은 크게 변하지 않는다. 건물 사진을 Max 풀링으로 계속 줄여 보자.
{const maxpool = m => { const n = m.length/2;returnArray.from({length: n}, (_, r) =>Array.from({length: n}, (_, c) =>Math.max(m[2*r][2*c], m[2*r][2*c+1], m[2*r+1][2*c], m[2*r+1][2*c+1]))) };const levels = [pool_img];for (let i =0; i <3; i++) levels.push(maxpool(levels[levels.length-1]));const wrap = d3.create("div").style("display","flex").style("gap","16px").style("justify-content","center").style("align-items","flex-end").style("flex-wrap","wrap").style("font-family","Pretendard, sans-serif"); levels.forEach((m, i) => {const n = m.length, size =192;const box = wrap.append("div").style("text-align","center");const svg = box.append("svg").attr("viewBox",`0 0 ${n}${n}`).attr("width", size).attr("height", size).attr("shape-rendering","crispEdges").style("background","#000").style("display","block").style("margin","0 auto");for (let r =0; r < n; r++) for (let c =0; c < n; c++) svg.append("rect").attr("x", c).attr("y", r).attr("width",1).attr("height",1).attr("fill",`rgb(${m[r][c]},${m[r][c]},${m[r][c]})`); box.append("div").style("font-size","0.85em").style("margin-top","0.3em").style("font-weight","600").text(i ===0?`원본 ${n}×${n}`:`풀링 ${i}번 → ${n}×${n}`); box.append("div").style("font-size","0.78em").style("color","#777").text(`${(n * n).toLocaleString()}개 값`); });return wrap.node();}
사진: Daniel Büchele, Flickr, CC BY 2.0
네 장을 같은 크기로 그려 비교했다. 값의 개수는 4,096 → 1,024 → 256 → 64로 줄었지만 건물의 윤곽과 밝은 벽, 어두운 지붕은 그대로 남아 있다. 풀링을 쓰는 이유는 세 가지다.
이미지 크기를 줄여도 특성은 크게 변하지 않는다
다음 층에 들어가는 값이 줄어 연산이 효율적이다
작은 위치 차이를 뭉갠다. \(2 \times 2\) 창 안에서 패턴이 한 칸 움직여도 최댓값은 같으므로 출력이 변하지 않는다. 축약 과정을 통해 변동(variance)이 줄어드는 효과다
풀링에는 학습 파라미터가 없다
풀링은 “최댓값을 고른다”는 고정된 규칙일 뿐, 학습되는 가중치가 없다. 파라미터를 셀 때 풀링 층은 0으로 센다.
완전연결 층 (Fully Connected Layer)
CNN의 마지막이다. 콘볼루션과 풀링을 거친 특성 맵은 아직 3차원이다. 이것을 펼쳐서(Flatten) 1차원으로 만들고, 2~3장의 완전연결 신경망에 넣어 분류한다.
같은 가중치(커널)를 이미지의 여러 부분에 적용하는 것. 커널이 이미지를 훑는 동안 커널 안의 값은 바뀌지 않는다.
\(5 \times 5\) 이미지에 \(3 \times 3\) 커널을 적용하면 출력은 9개다. 25개 입력에서 9개 출력을 만드는 데 필요한 파라미터를 비교한다.
방식
25 입력 → 9 출력
파라미터
완전연결
출력 노드 9개가 각각 입력 25개와 연결, 편향 9개
\((25 + 1) \times 9 = 234\)
콘볼루션
커널 9개 값 + 편향 1개를 9곳에 공유
\(9 + 1 = 10\)
같은 일을 23배 적은 파라미터로 한다. 이미지가 커질수록 차이는 더 벌어진다.
이동 불변성 (Shift Invariance)
이미지 속 글자 X가 왼쪽에 있든 오른쪽에 있든, 조금 기울어져 있든, 사람은 같은 X로 안다. 우리가 이미지를 인식할 때 중요한 것은 특성의 존재 여부이고, 특성이 어느 위치에 있는지는 크게 중요하지 않다.
이동 불변성 (Shift Invariance)
특성이 이미지의 어느 위치에 있어도 같은 방식으로 포착되는 성질. 커널이 유용한 특성을 표현하고 있다면, 그 특성이 이미지의 어느 부분에 있어도 같은 커널이 잡아낸다. 가중치 공유의 결과다.
5장에서 완전연결 신경망은 같은 무늬가 다른 위치에 오면 알아보지 못했다. 콘볼루션은 커널이 모든 위치를 지나가므로 위치가 달라도 같은 값을 낸다. 풀링이 여기에 더해져, 창 안의 작은 이동은 출력에 나타나지도 않게 된다. 5장이 요구한 “위치가 달라도 같은 특성을 같은 방법으로 찾는 연산”이 콘볼루션과 풀링의 조합이다.
CNN 아키텍처의 발전
딥러닝 아키텍처 (Architecture)
딥러닝 신경망을 구성하는 특정한 패턴. 층을 어떤 종류, 어떤 순서, 어떤 크기로 쌓는가를 정한 설계다. 컴퓨터 비전의 CNN은 1998년 LeNet-5에서 출발했다. 이후의 모델들은 같은 구조를 더 크고 깊게 쌓은 것이다.
LeNet-5 (1998)
콘볼루션 → 풀링 → 콘볼루션 → 풀링 → 펼치기 → 완전연결. 손글씨 숫자 인식에 쓰인 최초의 CNN이다. 연습 2의 신경망이 이 구조다.
특성 맵은 층을 지날수록 작아지고(32 → 28 → 14 → 10 → 5) 두꺼워진다(3 → 6 → 10). 콘볼루션이 특성을 뽑고 풀링이 크기를 줄이는 일을 두 번 반복한 뒤, \(5 \times 5 \times 10\)을 250개로 펼쳐 완전연결 층에 넣는다. 파라미터는 특성 추출부 1,966개, 분류부 41,134개다. 연습 1·2에서 본 대로 완전연결 층에 파라미터가 몰린다.
객체탐지
객체탐지는 컴퓨터 비전에서 가장 성공적이고 가장 널리 쓰이는 응용이다. 지금까지의 CNN은 이미지 한 장을 받아 그것이 무엇인지를 클래스 하나로 답했다. 제조 라인의 검사 카메라에는 그것으로 부족하다. 불량이 있는지가 아니라 어느 부품에 있는지를 알아야 골라낼 수 있다. 자율주행의 카메라도 보행자가 있는지가 아니라 어디에 있는지를 알아야 멈춘다. 무엇이 어디에 있는지를 찾는 것이 객체탐지다.
객체탐지 (Object Detection)
이미지 안의 물체마다 위치(바운딩 박스)와 종류(클래스)를 함께 찾는 작업.
이미지 한 장에 물체가 여럿일 수 있으므로 탐지는 물체마다 답을 하나씩 낸다. 그 답에 무엇이 들어가야 하는지부터 정한다.
추가되는 출력
분류 CNN의 출력은 클래스 확률 \(K\)개였다. 탐지는 물체마다 둘을 더 낸다. 물체가 어디에 있는지를 나타내는 바운딩 박스와, 그 박스 안 사물이 무엇인지를 나타내는 클래스다.
바운딩 박스 (Bounding Box)
물체를 둘러싸는 최소 크기의 직사각형. 두 가지로 적는다.
모서리 표기 \((x_1, y_1, x_2, y_2)\): 왼쪽 위 모서리와 오른쪽 아래 모서리
IoU는 두 곳에서 쓰인다. 학습에서는 박스의 오차로, 출력에서는 박스를 정리하고 판정하는 기준으로 쓰인다. 차례로 본다.
학습에서의 IoU
학습은 3장과 같다. 출력과 레이블의 차이로 손실을 만들고, 손실을 줄이는 방향으로 가중치를 갱신한다. 클래스 부분의 손실은 교차 엔트로피다. 박스 부분의 손실은 예측 박스와 정답 박스의 IoU에서 나온다. IoU가 1이면 오차가 0이고 겹침이 줄수록 오차가 커지므로, 손실을 줄이는 갱신은 예측 박스를 정답 박스 쪽으로 옮긴다. 에폭이 지날수록 IoU가 올라간다.
출력에서의 IoU
학습이 끝난 신경망에 이미지를 넣으면 물체 하나에 비슷한 박스가 여러 개 나온다. 물체 근처의 자리마다 박스를 내놓기 때문이다. 이 박스들이 같은 물체를 가리키는지를 IoU로 판단해 하나만 남긴다.
NMS (Non-Maximum Suppression, 비최대 억제)
겹치는 예측 박스 중 신뢰도가 가장 높은 것만 남기고 나머지를 제거하는 절차.
남은 박스 중 신뢰도가 가장 높은 박스를 채택한다
채택한 박스와 IoU가 0.5 이상인 박스를 모두 제거한다
남은 박스가 없을 때까지 1~2를 반복한다
B1을 채택하고 B1과 겹치는 B2, B3을 제거한다. B4를 채택하고 B4와 겹치는 B5를 제거한다. 출력은 B1, B4다.
남은 박스가 맞았는지의 판정에도 IoU를 쓴다. 예측 박스와 정답 박스의 IoU가 0.5 이상이고 클래스가 같으면 맞은 예측이다. 이 기준으로 세는 정밀도와 재현율, 그리고 탐지 모델의 학습은 심화 자료 9장에서 다룬다.
핵심 요약
개념
핵심 내용
CNN 구조
콘볼루션 → ReLU → 풀링 반복 → 펼치기 → 완전연결 → Softmax
ReLU
\(\max(0, u)\). 음수를 0으로 지워 두드러지는 특징만 남긴다
풀링
사이즈 2·스트라이드 2·Max. 크기 절반, 채널 유지, 파라미터 0
완전연결 층
작아진 특성 맵을 펼쳐 분류. Softmax + 교차 엔트로피
크기·파라미터 계산
\(\lfloor (N-K+2P)/S \rfloor + 1\), 커널 \(K \times K \times C + 1\), 풀링 0, FC \(n_{\text{in}} n_{\text{out}} + n_{\text{out}}\)
가중치 공유
같은 커널을 모든 위치에. 25→9에서 234개 vs 10개
이동 불변성
특성이 어디 있어도 같은 커널이 잡아낸다. 풀링이 작은 이동을 더 무시
LeNet-5
콘볼루션·풀링 두 번 → 펼치기 → 완전연결. 연습 2의 구조. 파라미터 95%가 FC
객체탐지
분류 출력에 바운딩 박스·클래스·신뢰도를 더 낸다. 레이블은 물체마다 박스와 클래스. 박스의 오차는 IoU = 교집합/합집합. 학습에서는 박스 손실, 출력에서는 NMS와 판정 기준 0.5
다음 장 예고: 학습한 CNN의 정확도를 높이는 전략(데이터 증강, 사전 학습 모델)과 시맨틱 분할을 다룬다.