데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      Vision Transformer 구현 — TensorFlow/Keras로 손글씨(MNIST) 분류하기

      xipert 26.08.26
      130 1 1
      DEVOTEE 요약
      본 블로그는 흑백 손글씨 숫자(MNIST)를 분류하기 위해 자연어 처리 모델인 Transformer를 이미지에 적용한 Vision Transformer(ViT)의 개념과 구현 과정을 단계별로 설명합니다. 이미지를 작은 패치로 잘라 단어처럼 다루고, 텐서플로우(TensorFlow/Keras)를 이용해 데이터 전처리부터 패치화, 임베딩, 그리고 Multi-Head Self-Attention까지 순수 어텐션 구조로 구현하는 방법을 다룹니다. 글의 마지막에는 전체 소스 코드와 함께 각 단계별 텐서 모양 변화를 상세히 제시하여 ViT의 작동 원리를 쉽게 이해할 수 있도록 돕습니다.
      DEVOTEE 추천 블로그

      안녕하세요. SK AX 의 송영목 매니저입니다.

      Vision Transformer(ViT)를 TensorFlow/Keras로 직접 구현하면서, 개념 → 수식/구조 → 코드 → 실행 결과 순서로 한 단계씩 설명합니다.

      전체 소스 코드는 글 맨 아래에 첨부하였습니다.


      들어가며

      Vision Transformer(ViT)는 자연어 처리에서 쓰이던 Transformer 구조를 이미지 분류에 그대로 적용한 모델입니다.

      핵심 아이디어는 단순합니다.

      "이미지를 작은 조각(패치)들로 잘라서, 각 패치를 문장 속 단어처럼 취급하고 Transformer로 처리하자."

      이 글에서는 MNIST 손글씨 숫자(0~9) 이미지를 입력받아 어떤 숫자인지 분류하는 ViT를 구현해 보았습니다.

      CNN 없이 순수 Attention 구조만으로 이미지를 이해하는 과정을 구현해 보려고 하였습니다.

      (중간 중간 Claude 에게 자문을 구하였습니다.)


      실행 환경: TensorFlow 2.x, Python 3.x 기준으로 작성했습니다.

      하이퍼파라미터 한눈에 보기

      이름

      의미

      MY_SHAPE

      (28, 28, 1)

      입력 이미지 모양 (흑백)

      MY_EPOCH

      5

      전체 데이터 반복 학습 횟수

      MY_BATCH

      128

      한 번에 처리하는 이미지 수

      MY_LEARN

      0.005

      학습률

      MY_CLASS

      10

      분류할 클래스 수 (숫자 0~9)

      MY_PATCH

      7

      한 변에 배치할 패치 수 (7×7=49개 패치)

      MY_TOKEN

      50

      총 토큰 수 (패치 49개 + CLS 토큰 1개)

      MY_ENCODER

      6

      Encoder 블록 반복 횟수

      MY_MLP_MULT

      10

      MLP 확장 배수 (hidden 차원에 곱함)

      MY_HIDDEN

      8

      임베딩(hidden) 차원 수

      MY_HEAD

      2

      Multi-Head Attention의 머리 수


      전체 그림 먼저 보기

      Vision Transformer는 문장을 다루던 Transformer 구조를 그대로 가져와서, "단어" 대신 "이미지 조각(패치)"을 입력으로 사용합니다.

      손글씨 이미지(28×28×1)
          │  ① 패치로 자르기                              [Step 2]
          ▼
      패치 시퀀스 (49x16)
          │  ② Linear 임베딩                               [Step 3]
          ▼
      패치 토큰 (49x8)
          │  ③ CLS 토큰 추가                                [Step 4]
          ▼
      토큰 50개 (CLS 1개 + 패치 49개)(50x8)
          │  ④ 위치 임베딩 더하기                            [Step 5]
          ▼
      Encoder 입력 z0 (50x8)
          │  ⑤ Transformer Encoder × 6회 반복                [Step 6, 7, 8]
          ▼
      Encoder 출력 zL (50x8)
          │  ⑥ Global Average Pooling (50개 토큰 → 1개로 압축) [Step 9]
          ▼
      Pooling 결과 (1x8)
          │  ⑦ Dense(10) + Softmax (분류 헤드)               [Step 10]
          ▼
      클래스 확률 (1x10)
          ▼
      숫자 0~9 중 하나

      ⑤번 화살표는 세 개의 Step으로 이루어져 있습니다.

      Step 6(MHSA 부품 자체를 만드는 방법),

      Step 7(그 MHSA를 정규화·잔차연결과 묶어 Encoder 블록 하나로 만드는 방법),

      Step 8(그 Encoder 블록을 6번 반복해서 실제로 쌓는 방법)이 합쳐져 ⑤번 화살표 하나가 됩니다.


      괄호 안 숫자는 "토큰 개수 × 임베딩 차원" 모양을 뜻하며, 배치 크기(B=128)는 생략하고 표기했습니다.

      예를 들어 (49x16)은 실제로는 (128, 49, 16)에서 배치 차원을 뺀 모양입니다.


      ⑥번(Pooling)은 "50개 토큰을 1개로 압축"하는 연산이고, ⑦번(분류 헤드)는 "압축된 1개 벡터를 10개 클래스 확률로 바꾸는" 연산으로 역할이 다릅니다.


      이 순서를 코드에서 그대로 단계별로 구현해 보았습니다.

      image.png


      Step 1. 데이터 준비 — "학습 재료를 만드는 단계"

      개념

      모델은 0과 1 사이의 실수만 이해할 수 있고, 입력 모양이 반드시 (28, 28, 1)(높이, 너비, 채널)이어야 합니다.

      MNIST 원본 데이터는 0 ~ 255 정수이고 채널 차원도 없기 때문에 전처리가 필요합니다.

      • .astype('float32') — 정수를 실수로 변환

        MNIST 원본 데이터는 각 픽셀이 0 ~ 255 사이의 정수(uint8)입니다.

        신경망은 실수 연산(가중치 곱셈, 미분 등)을 하기 때문에 float32(32비트 실수) 타입으로 바꿔야 합니다.

      변환 전: [0, 128, 255]  (정수, uint8)
      변환 후: [0.0, 128.0, 255.0]  (실수, float32)
      • / 255.0 — 0 ~ 1 사이 값으로 정규화(스케일링)

        픽셀값의 최댓값이 255이므로, 전체를 255로 나누면 모든 값이 0.0 ~ 1.0 범위로 바뀝니다.

      변환 전: [0.0, 128.0, 255.0]
      변환 후: [0.0, 0.502, 1.0]

      왜 이렇게 할까요?

      0 ~ 255처럼 값의 범위가 크면 학습 초반에 그래디언트(경사)가 불안정해지고 학습이 느려집니다.

      0 ~ 1처럼 작은 범위로 맞춰주면 신경망이 훨씬 안정적이고 빠르게 학습합니다.

      (이 코드에서는 MY_LEARN=0.005라는 학습률을 쓰는데, 만약 픽셀값이 0 ~ 255 그대로였다면 이 학습률 자체가 너무 커서 학습이 발산할 수 있습니다.)

      • [..., None] — 채널 차원 추가

        ...(ellipsis, 생략 부호)은 "앞에 있는 모든 차원은 그대로 두고"라는 의미이고, None은 "맨 뒤에 크기 1짜리 새 차원을 하나 추가하라"라는 의미입니다.

      변환 전 모양: (60000, 28, 28)        # (이미지 개수, 높이, 너비)
      변환 후 모양: (60000, 28, 28, 1)     # (이미지 개수, 높이, 너비, 채널)


      왜 채널 차원이 필요할까요?

      이 ViT 모델은 입력으로 MY_SHAPE = (28, 28, 1) 모양을 기대하도록 설계되어 있습니다 (keras.Input(shape=MY_SHAPE)).

      흑백 이미지라 채널이 1개뿐이지만, 컬러 이미지(RGB, 채널 3개)와 형식을 통일하기 위해 28×28 흑백 이미지도 반드시 "채널 차원"을 명시해야 합니다.

      이 차원이 없으면 PatchLayer에서 사용하는 tf.image.extract_patches 함수가 모양이 안 맞다며 에러를 냅니다.


      주의할 점

      학습 데이터(x_train)만 전처리하고 평가 데이터(x_test)를 빠뜨리면,

      학습은 되지만 평가 단계(model.evaluate)에서 모양이 안 맞아 에러가 납니다.

      두 데이터셋은 항상 "같은 규칙"으로 함께 전처리해야 합니다.


      코드

      x_train = (x_train.astype('float32') / 255.0)[..., None]
      x_test = (x_test.astype('float32') / 255.0)[..., None]
      
      assert x_train.shape[1:] == (28, 28, 1)
      assert x_test.shape[1:] == (28, 28, 1)

      실행 결과 예시

      [검증] x_train 모양: (60000, 28, 28, 1)  값 범위: 0.0 ~ 1.0
      [검증] x_test 모양 : (10000, 28, 28, 1)  값 범위: 0.0 ~ 1.0


      Step 2. 이미지 패치화 — "이미지를 단어처럼 자르기" (흐름도 ①)

      개념

      28×28 이미지를 4×4 크기 조각 49개(=7×7)로 나눕니다. 원 논문 표기로는 이미지 크기 (H, W, C), 패치 크기 P일 때 패치 개수는 다음과 같습니다.

      N = (H × W) / (P × P)

      이 코드에서는 H=W=28, 패치 한 변 크기 4 (=28÷7) 이므로 N = 28×28 / (4×4) = 49개의 패치가 만들어집니다.

      각 패치는 4×4=16개의 픽셀값을 일렬로 편 벡터가 됩니다.


      PatchLayer()는 28×28 크기의 이미지 한 장을 4×4 크기 조각(패치) 49개로 잘라주는 레이어를 만들어서 반환하는 함수입니다.

      이 조각들이 이후 "단어"처럼 취급되어 Transformer에 입력됩니다.

      PatchLayer()

      PatchLayer()는 28×28 크기의 이미지 한 장을 4×4 크기 조각(패치) 49개로잘라주는 레이어를 만들어서 반환하는 함수입니다.

      이 조각들이 이후 "단어"처럼 취급되어 Transformer에 입력됩니다.


      실행 결과 예시

      입력  (128, 28, 28, 1)   # 배치 128장, 28x28 흑백 이미지
      출력  (128, 49, 16)      # 배치 128장, 패치 49개, 패치 하나당 16개 값

      ① 함수 바깥쪽: 크기 계산 (한 번만 계산되는 상수들)

      def PatchLayer():
          size = 28 // MY_PATCH      # 패치 한 변 크기 = 4
          num = MY_PATCH * MY_PATCH  # 패치 개수 = 49
          dim = size * size          # 패치 벡터 차원 = 16

      MY_PATCH = 7일 때:

      변수

      계산

      의미

      size

      28 // 7

      4

      패치 한 변의 픽셀 크기 (4×4 정사각형)

      num

      7 × 7

      49

      이미지 하나에서 나오는 총 패치 개수

      dim

      4 × 4

      16

      패치 하나를 일렬로 펼쳤을 때의 길이

      이 세 줄은 extract 함수가 호출될 때마다 다시 계산되지 않고, PatchLayer()가 실행될 때 한 번만 계산되어 고정됩니다.

      안쪽 extract 함수가 나중에 이 값들을 그대로 재사용(클로저)할 수 있도록 미리 계산해두는 것입니다.

      ② 함수 안쪽: 실제 패치를 잘라내는 연산

      def extract(x):
          p = tf.image.extract_patches(
              images=x,
              sizes=[1, size, size, 1],
              strides=[1, size, size, 1],
              rates=[1, 1, 1, 1],
              padding='VALID'
          )

      tf.image.extract_patches는 이미지를 격자로 잘라주는 TensorFlow 내장 함수입니다. 각 인자의 의미는 다음과 같습니다.

      인자

      의미

      images

      x

      입력 이미지 배치, 모양 (배치, 28, 28, 1)

      sizes

      [1, 4, 4, 1]

      잘라낼 패치의 크기. 가운데 두 숫자가 "높이 4, 너비 4"를 뜻함

      strides

      [1, 4, 4, 1]

      다음 패치로 넘어갈 때 이동하는 간격. size와 같은 값이라 겹치지 않게 자름

      rates

      [1, 1, 1, 1]

      패치 내부 픽셀을 건너뛰는 간격(팽창). 1이면 건너뛰지 않고 촘촘하게 자름

      padding

      'VALID'

      이미지 가장자리에 여백(패딩)을 추가하지 않음. 28이 4로 딱 나누어떨어지므로 문제없음

      비유로 이해하기: 28×28 크기의 초콜릿 판을 4×4칸짜리 조각으로 딱 맞게 자르는 것과 같습니다.

      stridessizes와 똑같기 때문에 조각끼리 겹치는 부분 없이 정확히 7×7=49조각이 나옵니다.


      extract_patches를 통과한 직후의 중간 모양은 다음과 같습니다.

      p.shape == (배치, 7, 7, 16)   # (배치, 세로 패치 개수, 가로 패치 개수, 패치 하나의 픽셀 수)

      tf.reshape — Transformer가 원하는 모양으로 정리

      p = tf.reshape(p, [-1, num, dim])
      return p

      바로 위에서 나온 (배치, 7, 7, 16) 모양은 아직 "격자" 형태입니다.

      Transformer는 격자가 아니라 **토큰들의 나열(시퀀스)**을 입력으로 받기 때문에, 7×7=49개의 패치를 한 줄로 펼쳐서 (배치, 49, 16) 모양으로 바꿔줍니다.

      변환 전: (배치, 7, 7, 16)   # 세로 7 x 가로 7 격자
      변환 후: (배치, 49, 16)     # 49개짜리 한 줄 시퀀스

      -1은 "이 위치의 크기는 배치 크기에 맞춰 자동으로 계산하라"는 뜻으로, 배치 크기를 하드코딩하지 않아도 되게 해줍니다.

      layers.Lambda(extract) — Keras 레이어로 감싸기

      return layers.Lambda(extract)

      extract는 지금까지 살펴본 일반 파이썬 함수입니다. 이걸 Keras 모델 안에서 다른 레이어들처럼 patches = PatchLayer()(input) 형태로 자연스럽게 쓰려면

      Keras Layer 객체여야 합니다. layers.Lambda는 "이 함수를 그대로 Keras 레이어처럼 다룰 수 있게 포장해주는" 역할을 합니다.

      왜 함수 안에 함수(PatchLayer 안에 extract)를 넣었을까

      size, num, dimMY_PATCH 값에 따라 정해지는 값인데, extract(x) 함수는 이미지 x만 인자로 받도록 설계되어 있습니다(Keras Lambda가 그렇게 요구합니다).

      그래서 size, num, dim을 바깥쪽 함수(PatchLayer)의 지역 변수로 미리 계산해두고, 안쪽 함수(extract)가 이 값들을 클로저(closure) 로 기억해서 재사용하는 구조를 쓴 것입니다.

      전체 흐름 정리

      단계

      연산

      모양 변화

      입력

      -

      (128, 28, 28, 1)

      ② extract_patches

      4×4 조각으로 자름

      (128, 7, 7, 16)

      ③ reshape

      격자를 한 줄로 펼침

      (128, 49, 16)

      Q&A. MY_PATCH가 달라지면 size도 달라지나요?

      네, size = 28 // MY_PATCH이기 때문에 MY_PATCH가 바뀌면 size도 함께 바뀝니다.

      다만 반비례 관계라는 점이 중요합니다 — MY_PATCH(한 변에 배치할 패치 개수)가 커질수록, size(패치 한 조각의 픽셀 크기)는 오히려 작아집니다.

      MY_PATCH

      size = 28 // MY_PATCH

      num = MY_PATCH²

      dim = size²

      의미

      2

      14

      4

      196

      큰 조각 4개 (14×14 크기)

      4

      7

      16

      49

      7×7 크기 조각 16개

      7 (현재값)

      4

      49

      16

      4×4 크기 조각 49개

      14

      2

      196

      4

      아주 작은 조각(2×2) 196개

      직관적으로: 이미지를 "몇 조각으로 나눌지"(MY_PATCH)를 정하면, 이미지 전체 크기(28)는 고정이므로 "조각 하나의 크기"(size)는 자동으로 결정됩니다.

      조각을 잘게 많이 낼수록(MY_PATCH ↑) 조각 하나는 작아지고(size ↓), 반대로 굵게 적게 낼수록(MY_PATCH ↓) 조각 하나는 커집니다(size ↑).


      주의할 점: MY_PATCH는 28의 약수여야 함


      size = 28 // MY_PATCH정수 나눗셈(몫만 취함) 이라서, MY_PATCH가 28을 정확히 나누어떨어지지 않으면 문제가 생깁니다.

      MY_PATCH = 5
      size = 28 // 5  # = 5 (나머지 3은 버려짐)

      이 경우 size × MY_PATCH = 5 × 5 = 25가 되어 28과 맞지 않습니다.

      stridessize와 같은 값을 쓰기 때문에, 이미지의 마지막 3픽셀(28-25=3)이 아예 잘리지 않고 버려지는 문제가 생깁니다.

      padding='VALID'이기 때문에 에러는 안 나지만, 이미지 오른쪽/아래쪽 가장자리 정보가 조용히 손실됩니다.


      정리: 28의 약수인 1, 2, 4, 7, 14, 28MY_PATCH로 써야 이미지 전체가 빠짐없이 정확하게 패치로 나뉩니다.

      현재 코드의 MY_PATCH=728 = 7×4로 딱 맞아떨어지는 값을 골라 쓴 것입니다.


      실행 결과 예시 (먼저 확인)

      입력  (128, 28, 28, 1)   # 배치 128장, 28x28 흑백 이미지
      출력  (128, 49, 16)      # 배치 128장, 패치 49개, 패치 하나당 16개 값

      코드

      def PatchLayer():
          size = 28 // MY_PATCH      # 패치 한 변 크기 = 4
          num = MY_PATCH * MY_PATCH  # 패치 개수 = 49
          dim = size * size          # 패치 벡터 차원 = 16
      
          def extract(x):
              p = tf.image.extract_patches(
                  images=x,
                  sizes=[1, size, size, 1],
                  strides=[1, size, size, 1],
                  rates=[1, 1, 1, 1],
                  padding='VALID'
              )
              p = tf.reshape(p, [-1, num, dim])
              return p
      
          return layers.Lambda(extract)

      실행 결과 예시

      입력 (128, 28, 28, 1)
      출력 (128, 49, 16)

      128은 배치 크기(한 번에 처리하는 이미지 수), 49는 패치 개수, 16은 패치 하나의 픽셀 수입니다.


      Step 3. 패치 임베딩 — "픽셀 값을 의미 있는 벡터로 변환" (흐름도 ②)

      개념

      16차원짜리 "생 픽셀값" 벡터는 아직 아무 의미도 없습니다. Dense 층(=행렬 곱)을 거쳐 모델이 학습으로 의미를 부여할 수 있는 8차원 벡터로 바꿔줍니다.

      이 변환 행렬을 논문에서는 E라고 부르며, 크기는 (패치벡터차원 → 임베딩차원)입니다.


      코드

      tokens = layers.Dense(MY_HIDDEN)(patches)   # 16차원 -> 8차원

      실행 결과 예시

      패치화 후 (None, 49, 16)
      임베딩 후 (None, 49, 8)


      Step 4. CLS 토큰 추가 — "정답을 대표할 토큰 하나 준비" (흐름도 ③)

      개념

      49개의 패치 토큰만으로는 "이미지 전체를 대표하는 하나의 결과"를 얻기 애매합니다.

      그래서 원 논문은 이미지 내용과 무관하게 처음부터 학습되는 벡터 하나를 맨 앞에 붙여두고, Encoder를 통과하면서 이 토큰이 "전체 이미지의 요약 정보"를 흡수하도록 학습시킵니다.

      이 토큰을 최종 분류에 사용합니다.


      주의할 점 (이전 버전의 설계 문제)

      CLS 토큰을 "패치들의 평균값을 변환한 것"으로 만들면, 이 토큰이 이미 입력 내용에 종속되어 시작하게 되어 논문의 의도(독립적으로 학습되는 대표 토큰)와 달라집니다.

      아래처럼 입력과 무관한 파라미터로 만드는 것이 표준 방식입니다.


      코드

      class ClsToken(layers.Layer):
          def build(self, input_shape):
              self.cls = self.add_weight(
                  shape=(1, 1, self.n_hidden),
                  initializer='random_normal',
                  trainable=True
              )
          def call(self, x):
              b = tf.shape(x)[0]
              return tf.tile(self.cls, [b, 1, 1])   # 배치 크기만큼 복제

      실행 결과 예시

      클래스 토큰 추가 후 (None, 50, 8)   # 49개 패치 + CLS 토큰 1개 = 50


      Step 5. 위치 임베딩 — "패치가 어디에 있었는지 알려주기" (흐름도 ④)

      개념

      Attention 연산은 순서/위치 정보를 모릅니다 (모든 토큰을 동시에 봄). 그래서 sin/cos 함수로 각 위치마다 고유한 패턴(지문 같은 것)을 만들어 토큰에 더해줍니다.

      위치 i, 임베딩 차원 j에 대해:

      짝수 차원:  PE(i, j) = sin( i / 10000^(j/d) )
      홀수 차원:  PE(i, j) = cos( i / 10000^((j-1)/d) )

      코드

      def pos_embed(n_token, d_hidden):
          pe = np.zeros((n_token, d_hidden), dtype=np.float32)
          for i in range(n_token):
              for j in range(d_hidden):
                  if j % 2 == 0:
                      pe[i, j] = np.sin(i / 10000 ** (j / d_hidden))
                  else:
                      pe[i, j] = np.cos(i / 10000 ** ((j - 1) / d_hidden))
          return tf.constant(pe, dtype=tf.float32)
      
      x = tokens + pe   # 토큰에 위치 정보를 더함

      실행 결과 예시

      위치 임베딩 후 (None, 50, 8)


      Step 6. Multi-Head Self-Attention — "서로 얼마나 관련 있는지 계산" (흐름도 ⑤의 부품)

      개념

      각 토큰이 Query(질문), Key(열쇠), Value(값) 세 가지 벡터를 만들고, "내 Query가 상대방 Key와 얼마나 잘 맞는지"를 점수로 계산해서 그 점수만큼 상대방의 Value를 가져옵니다.

      이 계산을 여러 "머리(head)"로 나눠서 동시에 서로 다른 관점으로 수행합니다.

      score = (Q · Kᵀ) / √dₖ
      Attention = softmax(score) · V

      dₖ는 머리 하나가 담당하는 차원 수이며, 이 코드에서는 MY_HIDDEN(8) ÷ MY_HEAD(2) = 4입니다.


      코드

      class MyMHA(layers.Layer):
          def call(self, x, training=False):
              q, k, v = self.wq(x), self.wk(x), self.wv(x)          # (B,50,8)
              q = tf.reshape(q, [b, t, self.n_head, self.d_head])   # 머리로 나누기
              ...
              scale = tf.cast(self.d_head, tf.float32) ** -0.5
              score = tf.matmul(q, k, transpose_b=True) * scale     # (B,2,50,50)
              att = tf.nn.softmax(score, axis=-1)
              final = tf.matmul(att, v)                              # (B,2,50,4)
              ...
              return self.wz(merge)                                  # (B,50,8)

      실행 결과 예시

      입력 (128, 50, 8)
      출력 (128, 50, 8)   # 모양은 그대로, 내용은 서로의 정보를 반영해 바뀜


      Step 7. Encoder 블록 — "정규화 + Attention + MLP를 하나로 묶기" (흐름도 ⑤의 부품)

      개념

      Attention 하나만으로는 표현력이 부족해서, 다음 두 서브블록을 순서대로 쌓습니다.

      이때 원 논문(ViT)은 일반 Transformer와 달리 연산 전에 먼저정규화(Pre-Norm) 를 하고, 입력을 결과에 다시 더해주는 잔차 연결(Residual Connection)을 사용합니다.

      z' = z + MSA(LN(z))
      z'' = z' + MLP(LN(z'))

      주의할 점

      LN이 어텐션 앞과 MLP 앞 두 군데에서 쓰이는데, 같은 레이어 객체를재사용하면 두 지점이 파라미터(γ, β)를 공유하게 되어 위 수식과 달라집니다.

      반드시 독립된 두 개의 LayerNorm이어야 합니다.


      코드

      def encoder_block(x, n_hidden, n_head, mlp_mult):
          norm1 = layers.LayerNormalization(epsilon=1e-5)   # 어텐션 전용
          norm2 = layers.LayerNormalization(epsilon=1e-5)   # MLP 전용
      
          y = norm1(x)
          y = MyMHA(n_hidden, n_head)(y)
          y = layers.Add()([x, y])          # 잔차 연결 1
      
          w = norm2(y)
          expand = mlp_mult * n_hidden      # 10 × 8 = 80차원으로 확장
          y = layers.Dense(expand, activation=tf.nn.gelu)(w)
          y = layers.Dense(n_hidden)(y)     # 다시 8차원으로 축소
          y = layers.Add()([w, y])          # 잔차 연결 2
          return y

      실행 결과 예시

      MLP 확장 차원 실제값: 80
      입력 (128, 50, 8)
      출력 (128, 50, 8)


      Step 8. 모델 조립 — "패치화부터 Encoder까지 순서대로 연결" (흐름도 ①~⑤ 전체 연결)

      개념

      Step 2~7에서 만든 부품(패치화, 임베딩, CLS 토큰, 위치 임베딩, Encoder 블록)을

      실제 순서대로 이어붙입니다. Encoder 블록은 MY_ENCODER=6번 반복해서 쌓습니다.

      이 단계의 결과는 아직 분류 확률이 아니라, 50개 토큰이 서로의 정보를 반영한상태의 텐서입니다 (위 전체 그림의 "Encoder 출력 zL").


      코드

      input = keras.Input(shape=MY_SHAPE)
      patches = PatchLayer()(input)                              # ① (49,16)
      tokens = layers.Dense(MY_HIDDEN)(patches)                   # ② (49,8)
      ctoken = ClsToken(MY_HIDDEN)(tokens)
      tokens = layers.Concatenate(axis=1)([ctoken, tokens])       # ③ (50,8)
      x = tokens + pos_embed(MY_TOKEN, MY_HIDDEN)                 # ④ (50,8)
      
      for _ in range(MY_ENCODER):                                 # ⑤ Encoder x6
          x = encoder_block(x, MY_HIDDEN, MY_HEAD, MY_MLP_MULT)

      실행 결과 예시

      패치화 후 (None, 49, 16)
      임베딩 후 (None, 49, 8)
      클래스 토큰 추가 후 (None, 50, 8)
      위치 임베딩 후 (None, 50, 8)
      인코더 추가 후 (None, 50, 8)


      Step 9. Global Average Pooling — "50개 토큰을 1개 대표값으로 압축" (흐름도 ⑥)

      개념

      Encoder 출력은 아직 토큰 50개짜리 텐서 (50, 8)입니다. 분류를 하려면 이걸 이미지 하나를 대표하는 벡터 1개로 줄여야 합니다.

      GlobalAveragePooling1D는 50개 토큰의 값을 차원별로 평균내어 (1, 8) 벡터 하나로 압축합니다.

      pooled[j] = (1/50) × Σ (token_i[j])   for i = 1 .. 50

      주의할 점 (Step 7과의 차이)

      원 논문은 평균 대신 CLS 토큰(0번째 토큰)만 그대로 뽑아서 사용합니다(x[:, 0]).

      이 코드에서는 50개 토큰 전체를 평균 내는 방식을 쓰는데, 두 방식 모두 통용되지만 서로 다른 설계 선택이라는 점을 구분해서 이해해야 합니다.


      코드

      x = layers.GlobalAveragePooling1D()(x)   # (50,8) -> (1,8)

      실행 결과 예시

      GAP 처리 후 (None, 8)


      Step 10. 분류 헤드 — "대표 벡터를 10개 클래스 확률로 변환" (흐름도 ⑦)

      개념

      Pooling으로 얻은 (1, 8) 벡터를 Dense(10) 층에 통과시켜 숫자 0~9 각각에 대한 점수를 만들고, softmax로 그 점수들을 "합이 1인 확률"로 바꿉니다.

      가장 확률이 높은 클래스가 모델의 최종 예측이 됩니다.

      logits = pooled · W + b        # (1,8) -> (1,10)
      확률   = softmax(logits)        # 합이 1이 되도록 정규화

      코드

      output = layers.Dense(MY_CLASS, activation='softmax')(x)   # (1,8) -> (1,10)
      model = keras.Model(input, output)

      실행 결과 예시

      최종 출력 (None, 10)

      (None, 10) 벡터가 위 전체 그림의 "클래스 확률 (1x10)"에 해당하며, 이 중 값이 가장 큰 위치의 인덱스(0~9)가 곧 "숫자 0~9 중 하나"라는 최종 예측값입니다.


      Step 11. 학습 및 평가

      개념

      분류 문제이므로 sparse_categorical_crossentropy 손실 함수를 쓰고, Adam 옵티마이저로 5 epoch 학습한 뒤 평가 데이터로 정확도를 확인합니다.


      코드

      model.compile(optimizer=keras.optimizers.Adam(learning_rate=MY_LEARN),
                    loss='sparse_categorical_crossentropy',
                    metrics=['accuracy'])
      model.fit(train_ds, epochs=MY_EPOCH)
      loss, acc = model.evaluate(test_ds)

      실행 결과 예시

      학습 시작
      Epoch 1/5  469/469 [====] - loss: 1.xx - accuracy: 0.6x
      ...
      Epoch 5/5  469/469 [====] - loss: 0.xx - accuracy: 0.9x
      학습 시간 xx.xx초
      
      평가 시작
      313/313 [====] - loss: 0.xx - accuracy: 0.9x
      정확도 0.9x
      평가 시간 xx.xx초

      image.png


      전체 요약표

      Step

      이름

      입력 모양

      출력 모양

      핵심 개념

      1

      데이터 준비

      (28,28)

      (28,28,1)

      정규화, 채널 추가

      2

      패치화

      (B,28,28,1)

      (B,49,16)

      이미지를 조각냄

      3

      패치 임베딩

      (B,49,16)

      (B,49,8)

      의미 있는 벡터로 변환

      4

      CLS 토큰

      (B,49,8)

      (B,50,8)

      대표 토큰 추가

      5

      위치 임베딩

      (B,50,8)

      (B,50,8)

      위치 정보 주입

      6

      MHSA

      (B,50,8)

      (B,50,8)

      토큰 간 관계 계산

      7

      Encoder 블록(부품)

      (B,50,8)

      (B,50,8)

      정규화+어텐션+MLP

      8

      모델 조립 (Encoder ×6)

      (B,28,28,1)

      (B,50,8)

      Step2~7을 연결

      9

      Global Average Pooling

      (B,50,8)

      (B,8)

      50개 토큰 → 1개로 압축

      10

      분류 헤드 (Dense+Softmax)

      (B,8)

      (B,10)

      클래스 확률 계산

      11

      학습 및 평가

      -

      -

      손실 계산, 정확도 측정


      마치며

      CNN 없이 패치 단위 Attention만으로도 이미지 분류가 가능하다는 것이 ViT의 핵심 메시지입니다.

      다만 이 구현은 학습용으로 규모를 줄인 버전(hidden=8, head=2)이라 실제 논문 규모(hidden=768, head=12 등)에 비하면 매우 작습니다.

      작은 데이터셋(MNIST)과 작은 모델 규모에서도 구조 자체가 정상 동작하는지 확인하는 데 목적을 두고 각 부품을 TDD로 검증하며 ViT를 이해해 보려고 만들었습니다.


      참고자료

      • Dosovitskiy, A. et al., "An Image is Worth 16x16 Words: Transformers forImage Recognition at Scale", ICLR 2021 (ViT 원 논문)

      • Vaswani, A. et al., "Attention Is All You Need", NeurIPS 2017 (Transformer 원 논문)

      • TensorFlow/Keras 공식 문서: tf.image.extract_patches, keras.layers.LayerNormalization


      전체 소스 코드

      아래는 위에서 단계별로 설명한 내용을 하나로 합친 전체 코드입니다.

      그대로 복사해서 실행하면 MNIST 데이터를 내려받아 학습부터 평가까지 자동으로 진행됩니다.

      # 비젼 트랜스포머로 손글씨를 인식하기
      #
      # ------------------------------------------------------------
      # [수정 이력]
      # 1. (심각) x_test 전처리 누락 수정
      #    -> 기존: x_train만 스케일링(÷255)과 채널 차원 추가를 했고 x_test는 안 함
      #    -> evaluate() 실행 시 모양 불일치(shape mismatch) 에러가 남
      #    -> x_train과 동일하게 x_test도 전처리하도록 수정
      #
      # 2. (설계 버그) encoder_block 안에서 LayerNormalization 레이어를 재사용하던 문제 수정
      #    -> 기존: norm 이라는 레이어 객체 하나를 어텐션 앞, MLP 앞에서 두 번 호출
      #       Keras는 레이어 객체를 재사용하면 같은 학습 파라미터(gamma, beta)를 공유함
      #       -> 표준 Transformer 구조와 달라지고 표현력이 줄어듦
      #    -> norm1(어텐션용), norm2(MLP용) 두 개의 독립된 레이어로 분리
      #
      # 3. (설계 노트) CLS 토큰 생성 방식을 표준 ViT 방식으로 교체
      #    -> 기존: 패치들의 평균(GAP)을 Dense로 변환해서 CLS 토큰으로 사용
      #       (입력값에 종속적인 방식, 원 논문과 다름)
      #    -> 수정: 입력과 무관하게 독립적으로 학습되는 파라미터 벡터를
      #       배치 크기만큼 복제(tile)해서 사용하는 표준 ViT 방식으로 변경
      # ------------------------------------------------------------
      
      import numpy as np
      import tensorflow as tf
      from tensorflow import keras
      from tensorflow.keras import layers
      from time import time
      
      
      # ============================================================
      # 0단계. 문제 정의
      # ------------------------------------------------------------
      # - 28x28 흑백 손글씨 숫자(MNIST) 이미지를 입력받아
      #   0~9 중 어떤 숫자인지 분류하는 Vision Transformer를 만든다.
      # - 이미지를 패치(작은 조각)들로 나눈 뒤, 각 패치를 하나의 "단어"처럼
      #   취급해서 문장을 이해하듯 Transformer로 이미지를 이해하게 한다.
      # ============================================================
      
      
      # 하이퍼 파라미터 지정
      MY_SHAPE = (28, 28, 1)             # 손글씨 이미지 데이터 모양
      MY_EPOCH = 5                       # 반복 학습 수
      MY_BATCH = 128                     # 배치 수
      MY_LEARN = 0.005                   # 학습율
      MY_CLASS = 10                      # 분류화 대상의 수
      
      MY_PATCH = 7                       # 한 면의 패치 수 (7x7 = 49개 패치)
      MY_TOKEN = 50                      # 총 입력 토큰 수 (49개 패치 + CLS 토큰 1개)
      MY_ENCODER = 6                     # 총 인코더 수
      MY_MLP_MULT = 10                   # MLP 확장 배수 (n_hidden에 곱해지는 배수)
      
      MY_HIDDEN = 8                      # 패치 임베딩 차원 수
      MY_HEAD = 2                        # 어텐션 동시 계산 머리 수
      
      
      # ============================================================
      # 1단계. 데이터 준비 (분석 -> 설계 -> TDD 순서로 진행)
      # ------------------------------------------------------------
      # [TDD] 기대하는 결과:
      #   - x_train, x_test 모두 0~1 사이 실수(float32)
      #   - x_train, x_test 모두 모양이 (28, 28, 1) 이어야 함 (채널 차원 포함)
      # ============================================================
      
      mnist = keras.datasets.mnist.load_data()
      (x_train, y_train), (x_test, y_test) = mnist
      
      # [수정 1] x_train과 x_test를 "동일한 방식"으로 함께 전처리한다.
      # 스케일링(÷255)으로 0~1 사이 값으로 만들고,
      # [..., None]으로 채널 차원(흑백=1)을 추가해서 모델 입력 모양과 맞춘다.
      x_train = (x_train.astype('float32') / 255.0)[..., None]
      x_test = (x_test.astype('float32') / 255.0)[..., None]
      
      # 검증: 두 데이터의 모양이 같은 규칙으로 처리되었는지 확인
      print('[검증] x_train 모양:', x_train.shape, ' 값 범위:', x_train.min(), '~', x_train.max())
      print('[검증] x_test 모양 :', x_test.shape, ' 값 범위:', x_test.min(), '~', x_test.max())
      assert x_train.shape[1:] == (28, 28, 1)
      assert x_test.shape[1:] == (28, 28, 1)   
      
      # 학습용 데이터 배치 처리
      train_ds = (
          tf.data.Dataset.from_tensor_slices((x_train, y_train))
          .shuffle(10000)
          .batch(MY_BATCH)
      )
      
      # 평가용 데이터 배치 처리
      test_ds = (
          tf.data.Dataset.from_tensor_slices((x_test, y_test))
          .shuffle(10000)
          .batch(MY_BATCH)
      )
      
      
      # ============================================================
      # 2단계. 이미지 패치 처리 [흐름도 ①, Step 2]
      # ------------------------------------------------------------
      # [문제 정의] 이미지를 작은 정사각형 조각(패치)들로 잘라야 한다.
      # [설계] 28x28 이미지를 4x4 크기 조각 49개(7x7)로 나눈다.
      # [TDD] 기대하는 결과:
      #   입력: (128, 28, 28, 1)
      #   출력: (128, 49, 16)   <- 49개 패치, 각 패치는 4x4=16개 픽셀값
      # ============================================================
      
      def PatchLayer():
          size = 28 // MY_PATCH      # 패치 한 변의 크기 (28 // 7 = 4)
          num = MY_PATCH * MY_PATCH  # 총 패치 개수 (7 * 7 = 49)
          dim = size * size          # 패치 하나의 픽셀 수 (4 * 4 = 16)
      
          def extract(x):
              p = tf.image.extract_patches(
                  images=x,
                  sizes=[1, size, size, 1],
                  strides=[1, size, size, 1],
                  rates=[1, 1, 1, 1],
                  padding='VALID'
              )
              p = tf.reshape(p, [-1, num, dim])
              return p
      
          layer = layers.Lambda(extract)
          return layer
      
      
      # 테스트용 코드 (TDD: 위에서 예상한 모양과 실제 결과가 같은지 확인)
      x = tf.random.normal([MY_BATCH, 28, 28, 1])
      patchify = PatchLayer()
      tmp = patchify(x)
      print('\n[PatchLayer 테스트]')
      print('입력', x.shape)
      print('출력', tmp.shape)
      assert tmp.shape == (MY_BATCH, 49, 16)
      
      
      # ============================================================
      # 3단계. Multi-Head Attention 구현 [흐름도 ⑤의 부품, Step 6]
      # ------------------------------------------------------------
      # [문제 정의] 각 패치(토큰)가 다른 패치들과 얼마나 관련 있는지
      #             계산해서 정보를 섞어주는 장치가 필요하다.
      # [설계] Q(질문), K(열쇠), V(값) 벡터를 만들고,
      #        Q와 K를 비교해 "얼마나 주목할지" 점수를 구한 뒤(Attention),
      #        그 점수로 V를 가중합해서 새로운 표현을 만든다.
      #        이 과정을 여러 "머리(head)"로 나눠 동시에 수행한다.
      # [TDD] 기대하는 결과:
      #   입력: (128, 50, 8)
      #   출력: (128, 50, 8)   <- 모양은 그대로, 내용만 문맥을 반영해 바뀜
      # ============================================================
      
      class MyMHA(layers.Layer):
          def __init__(self, n_hidden, n_head):
              super().__init__()
              self.n_hidden = n_hidden
              self.n_head = n_head
              self.d_head = n_hidden // n_head    # 각 머리가 계산하는 차원 수 (= dk)
      
              # Q, K, V 벡터 전환용 행렬
              self.wq = layers.Dense(n_hidden, use_bias=True)
              self.wk = layers.Dense(n_hidden, use_bias=True)
              self.wv = layers.Dense(n_hidden, use_bias=True)
      
              # 최종 결과
              self.wz = layers.Dense(n_hidden, use_bias=True)
      
          def call(self, x, training=False):
              b = tf.shape(x)[0]                # 한 배치 크기, 128
              t = tf.shape(x)[1]                # 50
      
              # q, k, v 벡터 전환
              q = self.wq(x)                    # 데이터 모양: (128, 50, 8)
              k = self.wk(x)
              v = self.wv(x)
      
              # 머리로 나누기: (128, 50, 8) -> (128, 50, 2, 4)
              q = tf.reshape(q, [b, t, self.n_head, self.d_head])
              k = tf.reshape(k, [b, t, self.n_head, self.d_head])
              v = tf.reshape(v, [b, t, self.n_head, self.d_head])
      
              # Attention 계산 전에 모양 준비: (128, 50, 2, 4) -> (128, 2, 50, 4)
              q = tf.transpose(q, [0, 2, 1, 3])
              k = tf.transpose(k, [0, 2, 1, 3])
              v = tf.transpose(v, [0, 2, 1, 3])
      
              # Attention 계산
              scale = tf.cast(self.d_head, tf.float32) ** -0.5      # 루트 dk로 나눔
              score = tf.matmul(q, k, transpose_b=True) * scale     # 모양: (128, 2, 50, 50)
              att = tf.nn.softmax(score, axis=-1)                   # 합이 1이 되도록 정규화
              final = tf.matmul(att, v)                             # 모양: (128, 2, 50, 4)
      
              # 머리 합치기
              final = tf.transpose(final, [0, 2, 1, 3])             # (128, 50, 2, 4)
              merge = tf.reshape(final, [b, t, self.n_hidden])      # (128, 50, 8)
              z = self.wz(merge)                                    # 최종 z 결과
      
              return z
      
      
      # 테스트용 코드
      x = tf.random.normal([MY_BATCH, MY_TOKEN, MY_HIDDEN])
      mha = MyMHA(MY_HIDDEN, MY_HEAD)
      y = mha(x)
      print('\n[MyMHA 테스트]')
      print('입력', x.shape)
      print('출력', y.shape)
      assert y.shape == (MY_BATCH, MY_TOKEN, MY_HIDDEN)
      
      
      # ============================================================
      # 4단계. 인코더 블록 만들기 [흐름도 ⑤의 부품, Step 7]
      # ------------------------------------------------------------
      # [문제 정의] Attention 하나만으로는 표현력이 부족하다.
      #             정규화 + Attention + 잔차연결(skip connection) +
      #             정규화 + MLP + 잔차연결을 하나의 블록으로 묶어야 한다.
      # [설계] Pre-Norm 구조: "정규화 -> 연산 -> 원본과 더하기" 순서를 사용한다.
      #        어텐션용 정규화와 MLP용 정규화는 서로 다른 통계(평균/분산)를
      #        학습해야 하므로 반드시 "서로 다른" 레이어여야 한다.
      # [TDD] 기대하는 결과:
      #   입력: (128, 50, 8)
      #   출력: (128, 50, 8)
      # ============================================================
      
      def encoder_block(x, n_hidden, n_head, mlp_mult):
          # [수정 2] 어텐션용(norm1)과 MLP용(norm2) 정규화 레이어를
          # 각각 독립적으로 만든다. 하나의 레이어를 두 번 재사용하면
          # 두 지점이 같은 gamma/beta 파라미터를 공유하게 되어
          # 서로 다른 통계를 학습할 수 없게 된다.
          norm1 = layers.LayerNormalization(epsilon=1e-5)
          norm2 = layers.LayerNormalization(epsilon=1e-5)
      
          # 어텐션 서브블록: 정규화 -> MHA -> 잔차연결
          y = norm1(x)
          y = MyMHA(n_hidden, n_head)(y)
          y = layers.Add()([x, y])
      
          # MLP 서브블록: 정규화 -> MLP -> 잔차연결
          w = norm2(y)
      
          expand = mlp_mult * n_hidden         # MY_MLP_MULT(10) * MY_HIDDEN(8) = 80 차원
          y = layers.Dense(expand, activation=tf.nn.gelu)(w)
          y = layers.Dense(n_hidden)(y)        # 다시 n_hidden(8) 차원으로 축소
          y = layers.Add()([w, y])
      
          return y
      
      
      # 테스트용 코드
      x = tf.random.normal([MY_BATCH, MY_TOKEN, MY_HIDDEN])
      y = encoder_block(x, MY_HIDDEN, MY_HEAD, MY_MLP_MULT)
      print('\n[encoder_block 테스트]')
      print('MLP 확장 차원 실제값:', MY_MLP_MULT * MY_HIDDEN)
      print('입력', x.shape)
      print('출력', y.shape)
      assert y.shape == (MY_BATCH, MY_TOKEN, MY_HIDDEN)
      
      
      # ============================================================
      # 5단계. 위치 임베딩 [흐름도 ④, Step 5]
      # ------------------------------------------------------------
      # [문제 정의] Attention은 순서를 모른다 (모든 토큰을 동시에 봄).
      #             패치가 이미지의 "어느 위치"에 있었는지 알려줘야 한다.
      # [설계] sin/cos 함수로 각 위치마다 고유한 패턴을 만들어 더해준다.
      # ============================================================
      
      def pos_embed(n_token, d_hidden):
          pe = np.zeros((n_token, d_hidden), dtype=np.float32)
          for i in range(n_token):
              for j in range(d_hidden):
                  if j % 2 == 0:
                      pe[i, j] = np.sin(i / 10000 ** (j / d_hidden))
                  else:
                      pe[i, j] = np.cos(i / 10000 ** ((j - 1) / d_hidden))
          pe = tf.constant(pe, dtype=tf.float32)
          return pe
      
      
      # 테스트용 코드
      pe = pos_embed(MY_TOKEN, MY_HIDDEN)
      print('\n[pos_embed 테스트]')
      print(pe.shape)
      print('위치 0 정보', pe[0].numpy())
      print('위치 1 정보', pe[1].numpy())
      print('위치 49 정보', pe[49].numpy())
      assert pe.shape == (MY_TOKEN, MY_HIDDEN)
      
      
      # ============================================================
      # 6단계. CLS(클래스) 토큰 레이어 [흐름도 ③, Step 4]
      # ------------------------------------------------------------
      # [문제 정의] 여러 패치 토큰들의 정보를 최종적으로 모아서
      #             "이 이미지는 무슨 숫자다"라고 판단할 대표 토큰이 필요하다.
      # [설계 - 수정 3] 표준 ViT 방식: 입력 이미지 내용과 무관하게
      #             독립적으로 학습되는 벡터 하나를 준비해두고,
      #             배치 크기만큼 복제(tile)해서 각 이미지 앞에 붙인다.
      #             (기존처럼 "패치 평균"을 변환해서 쓰지 않음)
      # [TDD] 기대하는 결과:
      #   입력 패치 토큰: (128, 49, 8)
      #   출력: (128, 1, 8)  <- 이미지 내용과 무관하게 같은 초기값에서 시작
      # ============================================================
      
      class ClsToken(layers.Layer):
          def __init__(self, n_hidden):
              super().__init__()
              self.n_hidden = n_hidden
      
          def build(self, input_shape):
              # 입력과 무관한, 독립적으로 학습되는 파라미터 벡터를 하나 만든다.
              self.cls = self.add_weight(
                  shape=(1, 1, self.n_hidden),
                  initializer='random_normal',
                  trainable=True,
                  name='cls_token'
              )
      
          def call(self, x):
              b = tf.shape(x)[0]                     # 배치 크기
              return tf.tile(self.cls, [b, 1, 1])    # 배치 크기만큼 복제
      
      
      # 테스트용 코드
      x = tf.random.normal([MY_BATCH, 49, MY_HIDDEN])
      cls_layer = ClsToken(MY_HIDDEN)
      cls_out = cls_layer(x)
      print('\n[ClsToken 테스트]')
      print('입력(패치 토큰)', x.shape)
      print('출력(CLS 토큰)', cls_out.shape)
      assert cls_out.shape == (MY_BATCH, 1, MY_HIDDEN)
      
      
      # ============================================================
      # 7단계. 비젼 트랜스포머 전체 조립
      # ------------------------------------------------------------
      # 입력 이미지 -> ①패치화 -> ②패치 임베딩 -> ③CLS 토큰 추가
      # -> ④위치 임베딩 추가 -> ⑤인코더 반복 -> ⑥GAP -> ⑦분류(Dense+Softmax)
      # ============================================================
      
      # 입력층
      input = keras.Input(shape=MY_SHAPE)
      
      # ① 패치로 자르기 [흐름도 ①, Step 2]
      patches = PatchLayer()(input)
      print('\n[모델 조립 과정]')
      print('패치화 후', patches.shape)
      
      # ② Linear 임베딩: 16차원 -> 8차원 [흐름도 ②, Step 3]
      tokens = layers.Dense(MY_HIDDEN)(patches)
      print('임베딩 후', tokens.shape)
      
      # CLS 토큰: 표준 ViT 방식으로 생성 (입력 무관, 독립 학습 파라미터)
      ctoken = ClsToken(MY_HIDDEN)(tokens)
      
      # ③ CLS 토큰 추가: 49개 패치 토큰 맨 앞에 붙이기 -> 총 50개 토큰 [흐름도 ③, Step 4]
      concat = layers.Concatenate(axis=1)
      tokens = concat([ctoken, tokens])
      print('클래스 토큰 추가 후', tokens.shape)
      
      # ④ 위치 임베딩 더하기 [흐름도 ④, Step 5]
      pe = pos_embed(MY_TOKEN, MY_HIDDEN)
      x = tokens + pe
      print('위치 임베딩 후', x.shape)
      
      # ⑤ Transformer Encoder x 6회 반복 [흐름도 ⑤, Step 6·7·8]
      #    (Step 6: MHSA 부품, Step 7: 정규화+MHSA+MLP를 묶은 encoder_block,
      #     Step 8: 그 encoder_block을 MY_ENCODER번 반복해서 쌓는 부분이 바로 여기)
      for _ in range(MY_ENCODER):
          x = encoder_block(x, MY_HIDDEN, MY_HEAD, MY_MLP_MULT)
      print('인코더 추가 후', x.shape)
      
      # ⑥ Global Average Pooling: 50개 토큰 -> 1개 대표 벡터로 압축 [흐름도 ⑥, Step 9]
      x = layers.GlobalAveragePooling1D()(x)
      print('GAP 처리 후', x.shape)
      
      # ⑦ Dense(10) + Softmax: 분류 헤드, 1개 벡터 -> 10개 클래스 확률 [흐름도 ⑦, Step 10]
      output = layers.Dense(MY_CLASS, activation='softmax')(x)
      print('최종 출력', output.shape)
      
      # 모델 생성
      model = keras.Model(input, output)
      model.summary()
      
      
      # ============================================================
      # 8단계. 학습 및 평가 [Step 11]
      # ============================================================
      
      adam = keras.optimizers.Adam(learning_rate=MY_LEARN)
      model.compile(optimizer=adam,
                    loss='sparse_categorical_crossentropy',
                    metrics=['accuracy'])
      
      print('\n학습 시작')
      begin = time()
      model.fit(train_ds, epochs=MY_EPOCH)
      print('학습 시간', time() - begin)
      
      print('\n평가 시작')
      loss, acc = model.evaluate(test_ds)   
      print('정확도', acc)
      print('평가 시간', time() - begin)

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      xipert 님의 최신 블로그

      더보기

      DEVOTEE 추천 블로그

      동영상 기고하기