CPU란 무엇인가

CPU는 Central Processing Unit의 약자로, '중앙 처리 장치'라고도 불립니다. 인간으로 따지면 두뇌에 해당하는 역할을 하는데요. 컴퓨터의 두뇌로서 모든 명령을 처리하고 중요한 계산을 하는 역할을 합니다. 사용자가 "1+2를 계산해라"라는 명령을 내렸을 때, 이 명령을 받아 계산을 실행하고 "3"이라는 결과값을 도출해 내는 게 CPU가 하는 일이죠.

저는 올해 초 맥북 프로를 구매했는데요. 칩(SoC: System on a Chip)으로는 M3 Pro를 골랐습니다. M3 Pro에는 12코어 CPU(6개의 고성능 코어, 6개의 고효율 코어)와 18코어 GPU가 포함되어 있습니다. 여기서 말하는 '12코어'란 CPU 내부에 12개의 처리 유닛(코어)가 있다는 뜻인데요. 쉽게 설명하자면, 컴퓨터가 여러 가지 작업을 동시에 할 때 이 코어들이 나눠서 일을 처리하는 것이죠. 각 코어는 독립적으로 작업을 처리할 수 있기 때문에, 코어가 많을수록 한 번에 더 많은 작업을 할 수 있어요. 예를 들어서, 제가 구매한 M3 Pro보다 비싼 모델인 M3 Max의 경우 16코어 CPU와 40코어 GPU가 탑재되어 있기 때문에 더욱 성능이 좋습니다.

이렇게 코어가 많을수록 CPU가 '병렬 처리'라는 것을 잘 해낼 수 있어서, 컴퓨터는 멀티태스킹이나 고사양 작업을 잘 해낼 수 있게 됩니다. 병렬 처리는 여러 작업을 독립적으로, 동시에 처리하는 능력을 의미하는데요. 병렬 처리를 활용하면 작업을 빠르게, 효율적으로 완수할 수 있어요. 

그렇지만 병렬 처리가 항상 100% 반드시 효율적인 것은 아닙니다. 작업을 병렬로 나누는 과정에서 오히려 시간이 더 소모될 수도 있고요. 소프트웨어가 병렬 처리를 지원하지 않으면 모든 코어를 제대로 활용하지 못할 수 있습니다. 따라서 CPU 자원을 잘 활용할 수 있도록 프로그램을 구성하는 것이 무척 중요하다고 볼 수 있겠습니다.


CPU와 GPU의 차이

CPU는 마치 스포츠카와 같습니다. 한두 사람을 아주 빠르게 운송할 수 있죠. 반면에 GPU는 고속버스와 같습니다. 속도는 스포츠카에 미치지 못하지만, 한 번에 훨씬 더 많은 사람을 태울 수 있어요. 만약 같은 단위 시간 동안 운송할 수 있는 사람의 수가 차량의 성능에 해당한다면, 고속버스에 해당하는 GPU의 성능이 더 좋다고 볼 수 있는 것이죠.

그러나 CPU와 GPU는 마치 스포츠카와 고속버스처럼 그 용도와 목적, 성질이 무척 다릅니다. GPU는 CPU보다 병렬 처리를 수십 배 잘해낼 수 있지만 하나의 작업을 빠르게 실행하는 속도는 느리고, CPU는 OS를 실행하는 데 필요한 여러 가지 기능을 다양하게 탑재하고 있는 등, 차이점이 많아요. 따라서 내가 실행하고자 하는 작업이 CPU와 GPU - 둘 중 무엇에 더 최적화 되어 있는지 잘 판단하고 알맞은 것을 선택하는 것이 중요합니다.


CPU 집약적 task

AI 엔지니어로서 머신 러닝, 딥러닝 모델을 많이 다뤄온 저에게는 항상 GPU에 대한 고민이 먼저 이루어지곤 했습니다. 딥러닝 모델은 대량의 데이터와 수십억 개의 파라미터를 다루고, 행렬 곱셈(Matrix Multification/Dot Product) 같은 대규모 수학 연산을 자주 하기 때문에, 많은 메모리 대역폭과 고성능 연산 능력을 가지고 있는 GPU의 병렬 처리가 필수적으로 요구됩니다. 그래서 GPU 자원이 턱없이 부족한 환경에서 공부를 해오던 저에게는 좋은 사양의 GPU에 대한 갈증이 항상 있어 왔습니다.

그런데 이번에 처음으로 CPU 집약적 task, CPU의 병렬 처리에 대한 고민을 하게 되었어요. 저는 현재 맡은 업무에서 빅데이터셋의 품질 관리를 위해 문서간의 '편집 유사도(Edit Similarity / Levenshtein Distance)'를 계산하는 알고리즘과 코드를 작성하고 있는데요. 전체 과정에서 논리적이고 순차적인 연산이 매우 중요하기 때문에,이는 GPU보다 CPU를 잘 활용해야 하는 task에 해당했습니다. 또한 해당 과제는 가지고 있는 데이터 내의 모든 문서쌍에 대해 편집 유사도를 계산해야 하는 O(n * m)의 시간 복잡도를 가진 작업으로, 엄청난 연산량이 요구되는 만큼 CPU의 병렬 처리가 반드시 필요한 상황이었습니다.


CPU 병렬 처리를 지원하는 라이브러리

파이썬에는 CPU의 병렬 처리를 지원하는 다양한 라이브러리가 존재합니다. 저는 그 중에서 아래와 같이 4개의 라이브러리를 선택, 직접 비교해 보았습니다.

[1] Multiprocessing

  • Python의 표준 라이브러리로 추가 설치 없이 사용할 수 있음
  • CPU의 여러 코어를 활용해 병렬 프로세싱을 쉽게 구현할 수 있음. 각 프로세스는 독립된 메모리 공간을 사용하기 때문에, 안전한 병렬 처리가 가능함. 단순한 병렬 작업을 수행할 때, 특히 CPU 집약적인 작업에 적합함
  • docs : https://docs.python.org/ko/3/library/multiprocessing.html
from multiprocessing import Pool

def f(x):
    return x*x

if __name__ == '__main__':
    with Pool(5) as p:
        print(p.map(f, [1, 2, 3]))
        
# [1, 4, 9]

[2] Ray

  • Ray는 기본적으로 CPU 자원을 병렬로 활용할 수 있도록 설계되어 있으며, 여러 CPU 코어를 이용해 동시에 여러 작업을 실행할 수 있게 해 줌.CPU, GPU 자원을 자동으로 관리하고 최적화된 성능을 제공.
  • 기존 코드를 수정할 필요 없이 @ray.remote 데코레이터를 사용해 매우 쉽게 병렬 작업을 처리할 수 있어 사용이 편리함
  • 로컬 환경뿐만 아니라 클러스터 환경에서 대규모 데이터 처리를 효율적으로 분산 실행할 수 있음
  •  docs : https://docs.ray.io/en/latest/ray-core/walkthrough.html?_gl=1*1e3lt4l*_ga*ODU4NzkwMDUxLjE3MjY3OTQxMjE.*_up*MQ..
# Define the square task.
@ray.remote
def square(x):
    return x * x

# Launch four parallel square tasks.
futures = [square.remote(i) for i in range(4)]

# Retrieve results.
print(ray.get(futures))
# -> [0, 1, 4, 9]

[3] Dask

  • 대규모 데이터 분석과 병렬 처리를 위한 동적 작업 스케줄러를 제공하는 라이브러리
  • Pandas, NumPy와 호환이 되므로 PandasNumPy 작업을 확장할 필요가 있을 때 매우 유용함
  • docs : https://docs.dask.org/en/stable/
import dask.array as da

x = da.random.random((10000, 10000), chunks=(1000, 1000))
result = x.mean().compute()  # Dask로 병렬 처리
print(result)

[4] ProcessPoolExecutor

  • Python의 concurrent.futures 모듈에 있는 고급 병렬 처리 라이브러리로, 다중 프로세스를 쉽게 관리할 수 있는 기능을 제공
  • multiprocessing과 유사하지만 더 높은 수준의 API를 제공해 프로세스를 관리하고 병렬 처리를 쉽게 수행
  • docs : https://docs.python.org/3/library/concurrent.futures.html
from concurrent.futures import ProcessPoolExecutor

def square(x):
    return x * x

with ProcessPoolExecutor() as executor:
    futures = [executor.submit(square, i) for i in range(5)]
    results = [f.result() for f in futures]
    print(results)  # [0, 1, 4, 9, 16]

처리 결과 비교

처리 결과, 제게 주어진 task에서 가장 좋은 성능을 보인 라이브러리는 Ray였습니다.

기존 작업 (병렬 처리 X) 약 26분
Ray 약 6분 (성능 약 77% 향상)
MultiProcessing 약 10분 (성능 약 61.54% 향상)
Dask 기존 시간(26분)을 초과하여 중단
ProcessPoolExecutor 기존 시간(26분)을 초과하여 중단

위와 같은 실험 결과를 토대로 저는 주어진 과제에 레이를 활용하기로 결정했습니다. 실제로 해당 결과를 공유한 이후MultiProcessing보다 레이가 더 빠르다니, 흥미로운 실험 결과라는 코멘트를 받기도 하였습니다 :)

물론 모든 작업에서 반드시 위와 같은 결과를 보이는 것이 절대 아니기 때문에, 과제에 따라 실험을 통해 적합한 라이브러리를 잘 취사 선택하는 것이 중요하겠습니다.


자원 확인

nproc
#128

리눅스 명령어로 확인한 결과 제 서버가 가지고 있는 CPU 코어는 총 128개로 확인이 되었습니다.

ray.init(include_dashboard=True)

 

ray의 init함수를 쓸 때 대시보드 설정을 True로 할당하면, 브라우저에서 localhost:8265에 접속해 대시보드를 볼 수 있습니다. (저는 회사 서버 + 도커 컨테이너를 쓰고 있어서 이 부분은 복잡해서 pass)

import ray

ray.init()

# 현재 사용 가능한 리소스 보기
available_resources = ray.available_resources()
print(available_resources)

위와 같이 available_resources 함수를 실행하면, 사용 중인 CPU와 GPU 자원에 대한 정보가 출력됩니다.

(예) {'accelerator_type:G': 1.0, 'node:__internal_head__': 1.0, 'CPU': 128.0, 'memory': 1058863122432.0, 'object_store_memory': 10000000000.0, 'GPU': 6.0, 'node:172.17.0.8': 1.0}

  • 'CPU': 128.0 -> 이 시스템에 128개의 CPU 코어가 사용 가능하다는 것을 나타냅니다. nproc 명령어에서 확인한 128개의 코어 수와 일치하네요. Ray는 이 128개의 코어를 병렬 처리에 사용합니다.
  • 'memory': 1058863122432.0 -> 이 값은 총 사용 가능한 메모리를 바이트 단위로 나타내고, 제게 주어진 시스템에는 약 1TB의 메모리가 사용 가능하네요.
  • 'object_store_memory': 10000000000.0 -> 이 메모리는 Ray에서 객체를 저장하는 데 사용됩니다.  10GB가 설정되어 있네요.

이상으로 CPU의 병렬 처리에 대한 간략한 포스팅을 마치도록 하겠습니다. 주어진 리소스를 잘 파악하고 100% 활용할 수 있도록 늘 고민하는 엔지니어가 될 수 있도록 오늘도 내일도 열심히 공부하고 노력하겠습니다 :)

감사합니다!

https://arxiv.org/pdf/2005.11401

pre-train된 LLM은 학습한 시점 이후의 데이터를 가지고 있지 않기때문에 outdated된 정보를 제공할 수 있으며 특히 domain-specific한 지식이 부족한 단점을 가지고 있습니다. 이를 보완하기 위해 주기적으로 LLM을 재학습시키거나 파인튜닝을 하는 방법이 고안되었지만 시간이나 비용적인 측면에서 비효율적이라는 문제점이 꾸준히 제기되어 왔습니다.

저 역시 domain-specific한 QA task를 실현하기 위해 LLM을 fine-tuning하고자 하는 시도를 하였지만 생각만큼 성능이 잘 확보되지 않으며 오히려 기존에 학습된 파라미터가 fine-tuning을 통해 망가지면서 답변 생성 성능이 저하되는 것을 여러번 경험한 바가 있습니다. 이럴 때 필요한 것이 RAG - Retrieval-Augmented Generation - 검색 증강 생성으로, LLM을 더 잘 활용하기 위해 널리 사용되고 있는 기술입니다.

RAG는 새로운 정보를 벡터 데이터베이스에 저장하고, 쿼리를 받았을때 데이터베이스에서 검색을 실행합니다. 이후 반환받은 관련 정보를 쿼리에 함께 담아 넘겨주면서 context 정보를 구체적으로 제공함으로써 기존의 문제점을 해결하고자 도모합니다.

본 포스팅에서는 RAG에 관해 처음으로 언급한 논문 <Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks>를 읽고 주요 내용을 리뷰해 보도록 하겠습니다.


Abstract

Large pre-trained language models have been shown to store factual knowledge in their parameters, and achieve state-of-the-art results when fine-tuned on downstream NLP tasks. However, their ability to access and precisely manipulate knowledge is still limited, and hence on knowledge-intensive tasks, their performance lags behind task-specific architectures. Additionally, providing provenance for their decisions and updating their world knowledge remain open research problems. (중략)

LLM은 많은 NLP task에서 SoTA 성능을 보여주며 다양하게 활용되어 왔지만, 지식을 습득하고 조종하는 부분에 있어 여전히 많은 한계점을 가지고 있습니다. 저 역시 개인적인 목적으로 GPT4.0등을 활용할 때와 달리 프로젝트를 위해 오픈소스 LLM 등을 활용할 때 특히 그러한 한계점을 많이 체감하게 되곤 했는데요. 특히 task-specific한 구조로 인해서 knowledge-intensive한 task에 있어서는 취약한 점이 많았죠. 나아가 결정에 대한 출처를 제공하는 일, 기존의 지식을 업데이트하는 일은 여전히 미해결 연구 문제로 남아 있었다고 합니다.

We explore a general-purpose fine-tuning recipe for retrieval-augmented generation (RAG)models which combine pre-trained parametric and non-parametric memory for language generation. We introduce RAG models where the parametric memory is a pre-trained seq2seq model and the non-parametric memory is a dense vector index of Wikipedia, accessed with a pre-trained neural retriever.

해당 논문에서는 2가지 종류의 knowledge를 사용하고 있습니다.

  • Parametric knowledge : 모델이 사전에 학습한 파라미터 정보
  • Non-parametric knowledge : retriever를 이용하여 외부 문서로부터 가져오는 파라미터 정보 (논문에서는 Wikipedea 활용)

그런데 논문에서 말하는 general-purpose fine-tuning recipe for RAG란 무엇일까요?

이어서 살펴보겠지만 RAG 모델의 파인튜닝은 리트리버와 생성기를 함께 엔드-투-엔드로 학습하여 모델을 최적화하는 과정을 의미합니다. 오늘날 우리가 RAG를 구현할 때는 이미 학습된 리트리버와 생성기를 LangChain과 같은 라이브러리에서 가져다 사용하기 때문에 별도의 파인튜닝 과정은 사실 필요 없습니다. 덕분에 보다 손쉽게 RAG 기능을 구현할 수 있는 것이죠. 물론 파인튜닝을 추가해서 더 고도화된 모델을 구현할 수도 있겠지만요.

We compare two RAG formulations, one which conditions on the same retrieved passages across the whole generated sequence, and another which can use different passages per token. (중략) For language generation tasks, we find that RAG models generate more specific, diverse and factual language than a state-of-the-art parametric-only seq2seq baseline.

본 논문에서는 2가지의 RAG 포뮬라를 비교 제시하는데요. 하나는 생성된 전체 시퀀스에 걸쳐 동일한 검색된 구절을 조건으로 하고, 다른 하나는 토큰당 다른 구절을 사용합니다. 이를 통해 생성 작업에 있어서 RAG 모델이 더 구체적이고 다양하며 사실에 기반한 언어를 생성한다는 사실을 발견했다고 합니다.


1 Introduction

They cannot easily expand or revise their memory, can’t straightforwardly provide insight into their predictions, and may produce “hallucinations”. (중략) 

기존의 Pre-train된 neural language model들은 파라미터에 저장된 정보를 이용하여 훌륭한 답변을 생성해 내곤 하였는데요. 하지만 이러한 모델들은 기억을 쉽게 확장하거나 수정할 수 없는 단점, 예측에 대한 통찰력을 직접적으로 제공할 수 없는 단점, 그리고 가장 치명적으로 "환각(Hallucinations)"을 일으킬 수 있는 단점을 가지고 있었습니다.

The retriever (Dense Passage Retriever [26], henceforth DPR) provides latent documents conditioned on the input, and the seq2seq model (BART [32]) then conditions on these latent documents together with the input to generate the output. We marginalize the latent documents with a top-K approximation, either on a per-output basis (assuming the same document is responsible for all tokens) or a per-token basis (where different documents are responsible for different tokens). Like T5 [51] or BART, RAG can be fine-tuned on any seq2seq task, whereby both the generator and retriever are jointly learned.

Retriever는 인풋을 받아 latent documents을 제공하고, seq2seq 모델은 이러한 문서들을 묶어서 출력을 생성합니다. RAG는 잠재 문서(latent documents)들 중에서 상위 K개의 문서를 선택(top-K approximation)하여 이 문서들에 기반해 출력을 생성하는데, 이 과정은 두 가지 방식으로 이루어질 수 있습니다. 예시 질문 "팥빙수를 맛있게 만드는 방법은?"을 가지고 각각의 방법이 어떻게 사용되는지 구체화 해보도록 하겠습니다.

  1. 출력 단위로(per-output basis): 하나의 문서가 모든 토큰에 대해 사용된다고 가정.
    • 모델이 가장 관련성 높은 문서 1개를 찾습니다. 예를 들어, 한 문서가 '팥빙수 레시피' 전체를 담고 있다고 가정합니다. 그 문서에서 얻은 모든 정보를 바탕으로 답변을 생성합니다.
    • 답변: "팥빙수를 맛있게 만들려면, 먼저 팥을 준비하고, 얼음을 갈아 위에 얹습니다. 그 다음, 연유와 다양한 토핑을 추가합니다."
  2. 토큰 단위로(per-token basis): 각 토큰이 서로 다른 문서에 의해 지원될 수 있음.
    • 모델이 여러 문서를 찾습니다. 만약 한 문서는 '팥 조리법'을 설명하고, 또 다른 문서는 '팥빙수 토핑 아이디어'를 제공한다면, 각 단어(토큰)를 생성할 때 가장 적절한 문서에서 해당하는 정보를 가져옵니다. 예를 들어, 첫 번째 문서에서 "팥을 준비하고" 정보를 가져오고, 두 번째 문서에서 "얼음을 갈아 얹고" 정보를 가져오며, 세 번째 문서에서 "연유와 다양한 토핑을 추가합니다" 정보를 가져옵니다.
    • 답변: "팥을 준비하고, 얼음을 갈아 얹고, 연유와 다양한 토핑을 추가합니다.
Crucially, by using pre-trained access mechanisms, the ability to access knowledge is present without additional training.

특히 RAG 모델에서 리트리버와 생성기를 사전 학습된 상태로 사용하기 때문에 별도의 추가 학습 과정 없이도 외부 지식을 효과적으로 사용할 수 있다는 것을 강조하고 있습니다. 보통 RAG를 실현할 때 langchain같은 라이브러리에서 사전 학습된 리트리버와 생성기를 간단하게 가져다 쓰기만 하면 되는 것처럼, 무척 편리한 기능 구현이 가능한 것이 바로 RAG의 핵심이기도 하죠.

Finally, we demonstrate that the non-parametric memory can be replaced to update the models’ knowledge as the world changes.

non-parametric memory의 경우에는 업데이트가 가능하기 때문에 유지보수를 통해 최신 정보를 반영하고 정확도를 개선할 수가 있겠습니다.


2 Methods

We explore RAG models, which use the input sequence x to retrieve text documents z and use them as additional context when generating the target sequence y.

RAG 모델은 입력 시퀀스 를 사용하여 텍스트 문서 를 검색하고, 이를 추가 컨텍스트로 활용하여 목표 시퀀스 를 생성합니다.


Figure 1.

  1. Query Encoder (q(x)): 입력된 질문(query)을 벡터 형태로 인코딩합니다.
  2. Retriever (Non-Parametric Memory): 인코딩된 질문을 기반으로 최대 내적 탐색(MIPS)을 사용하여 top-K 문서들을 찾습니다. (주어진 질문 x에 대해 상위 K개의 텍스트 구절 분포를 반환)
  3. Document Index (d(z)): 검색된 문서들의 벡터 인덱스입니다.
  4. Generator (Parametric Memory): 선택된 문서들(z)을 입력으로 받아 최종 출력을 생성합니다. 본 논문의 경우 seq2seq 모델(BART)을 채택하고 있습니다.
  5. Marginalize: 여러 문서들로부터 얻은 출력을 종합하여 최종 답변(y)을 도출합니다. 다양한 문서에 대해 seq2seq 예측을 주변화(marginalize)합니다.

이처럼 RAG 모델은 질문을 인코딩하여 관련 문서를 검색하고, 이를 바탕으로 답변을 생성합니다. 그렇다면 RAG는 retriever와 generator를 어떻게 학습시켰을까요?

To train the retriever and generator end-to-end, we treat the retrieved document as a latent variable. We propose two models that marginalize over the latent documents in different ways to produce a distribution over generated text. In one approach, RAG-Sequence, the model uses the same document to predict each target token. The second approach, RAG-Token, can predict each target token based on a different document.

RAG는 retrieved된 document를 latent variable로 취급한다고 명시하고 있습니다. 본 논문에서는 이와 같은 latent document에 대해 다른 방식으로 marginalization하는 두 가지 모델을 제안합니다.

  1. RAG-Sequence 모델 : Same document를 사용하여 each target token을 예측
  2. RAG-Token 모델 : Different document를 사용하여 each target token을 예측

RAG-Sequence 모델

 

하나의 문서 z에 대해 전체 시퀀스 의 모든 토큰 yi에 대한 확률을 계산합니다. 모든 top-K 문서에 대해 이 과정을 반복한 후, 최종 값을 주변화(marginalize)하여 계산합니다. 즉, 각 문서에 대해 시퀀스 전체를 고려하여 최종 출력을 생성합니다.

RAG-Token 모델

RAG-Token model은 RAG-Sequence와 다르게 각 토큰 yi를 생성할 때마다 모든 top-K 문서에 대해 확률을 계산하고, 그 후 주변화(marginalize)하여 최종 출력을 생성합니다. 즉, 각 토큰에 대해 개별 문서를 고려하여 출력을 생성합니다.

Retriever: DPR

앞서 언급한 바와 같이 RAG에서는 retriever 𝑝𝜂(𝑧|𝑥)로 DPR을 사용합니다. DPR은 기본적으로 bi-encoder 구조 - 사전 학습된 BERT 기반 인코더를 사용하여 리트리버를 초기화하고 문서 인덱스를 구축합니다.

https://arxiv.org/pdf/2004.04906

DPR은 논문 <Dense Passage Retrieval for Open-Domain Question Answering>에서 고안한 방법으로, RAG 논문보다 1년 앞서 출간되었습니다. 나중에 기회가 되면 DPR 논문도 리뷰해 보도록 하겠습니다.

Calculating top-k(pη(·|x)), the list of k documents z with highest prior probability pη(z|x), is a Maximum Inner Product Search (MIPS) problem, which can be approximately solved in sub-linear time [23]

주어진 질문 x에 대해 가장 높은 사전 확률 을 가진 상위 K개의 문서 목록을 계산하는 것은 최대 내적 탐색(MIPS) 문제로 해결합니다. 즉, input x 에 대한 document z의 분포는 위에서 산출한 d(z) 와 q(x)의 내적 연산을 통해 계산되며, 이 내적 값이 높은 순서대로 top-k document를 골라 retrieve를 하게 되는데, 이 과정은 REALM에서 사용되었던 MIPS 알고리즘을 사용하여 효율적인(sub-linear time) 탐색을 가능하게 했다고 합니다.

Generator: BART

생성기로는 무엇을 사용해도 상관이 없으며 본 논문에서는 BART-large를 사용했다고 밝히고 있습니다. 

Training

We jointly train the retriever and generator components without any direct supervision on what document should be retrieved

DPR 기반의 리트리버와 BART-large 기반의 생성기는 학습 과정에서 동시에 학습됩니다. 이때, 어떤 문서가 검색되어야 하는지에 대한 직접적인 감독 없이 진행되는 비지도 학습(unsupervised learning) 방식이 적용됩니다. 오로지 출력 시퀀스에 대한 NLL(Negative marginal Log-Likelihood)을 최소화하는 방향으로 학습되며, 리트리버 또한 이 과정에서 NLL을 최소화하는 방향으로 학습됩니다.

Decoding

앞서 살펴본 두 가지 모델 RAG-Sequence와 RAG-Token은 각각 output 산출 방법이 다르므로 token decoding 하는 과정도 달라지게 됩니다.

  1. RAG-Token 모델 : 표준 시퀀스-투-시퀀스 생성기처럼 작동하며, 여러 문서의 확률을 합산하여 전이 확률을 계산합니다. 이를 표준 빔 디코더에 적용하여 디코딩합니다.
  2. RAG-Sequence 모델 : 각 문서에 대해 별도의 빔 서치를 실행하고, 최종 확률을 추정하기 위해 추가적인 forward pass를 실행합니다. 이를 "Thorough Decoding"이라 하며, 효율성을 위해 추가 패스를 생략하는 "Fast Decoding"도 있습니다.

이 때, 빔 서치는 가장 가능성 높은 N개의 후보를 유지하며 다음 토큰을 생성, 이 과정을 반복해 최종 출력 시퀀스를 생성하는 디코딩 방식을 의미합니다.

출처 : https://slideplayer.com/slide/14552918/


Experiment & Result

해당 부분은 GPT4.0-Turbo를 이용하여 핵심 내용 정리 요약 후 패스하도록 하겠습니다.

  1. Dense Passage Retriever (DPR): DPR은 dense encoding을 통해 질문과 패시지 간의 의미적 관계를 잘 파악합니다.
  2. 성능 비교: BM25와 비교한 실험에서 대부분의 데이터셋에서 DPR이 더 우수한 성능을 보였습니다.
  3. 효율성: 적은 수의 학습 예제로도 고품질의 dense retriever를 학습할 수 있습니다.
  4. 일반화 성능: 학습된 데이터셋 외에도 어느 정도 잘 작동하며, BM25보다 우월한 성능을 보입니다.
  5. 질적 분석: BM25는 키워드에 민감하고, DPR은 의미적 관계를 잘 파악합니다.
  6. 종합 성능: 대부분의 데이터셋에서 DPR이 최상의 성능을 냈습니다.

RAG의 가장 큰 장점은 parametric & non-parametric memory의 결합을 통해 보다 정확하고 다양한 정보를 생성할 수 있다는 점입니다. 이를 통해 기존의 파라미터를 대량 업데이트하거나 fine-tuning 하지 않으면서도 domain-specific한 downstream task을 잘 수행할 수 있는 모델을 구현할 수 있게 되었습니다. 특히 검색 인덱스를 간단히 교체하여 모델을 업데이트할 수 있다는 점에서 매우 효율적이고 유용한 기법이라고 할 수 있겠습니다.

본 논문을 읽기 전에 langchain을 이용하여 RAG을 이미 구현해 본 입장에서, 논문을 통해 핵심 아이디어와 학습 방안에 대해 구체화하고 더 깊이 이해할 수 있어 무척 좋은 기회였습니다. 앞으로 RAG를 다양한 응용 분야에 적용해보고, task에 따라 어떤 방식으로 알맞게 사용할 수 있는지 실험해 보는 과정을 거쳐 보고자 합니다.

읽어주셔서 감사합니다 :)

 

 

 

Peft는 Parameter-Efficient Fine Tuning의 약자로, 말 그대로 파인튜닝을 조금 더 효율적으로 할 수 있는 방법론을 의미합니다. Peft를 실현할 수 있는 종류에는 다양한 것들이 있는데, 가장 대표적으로 사용되는 방법 중 하나로는 LoRA(로라)가 있습니다.

본 포스팅에서는 LoRA의 논문 핵심 파트를 가볍게 리뷰하고, peft와 unsloth, trl 라이브러리를 이용해서 로라방식의 LLM 파인튜닝을 직접 코드로 진행해 보겠습니다.


논문

출처 :  https://arxiv.org/pdf/2106.09685

LoRA는 2021년 발표된 마이크로소프트의 논문 [LoRA: Low-Rank Adaptation of Large Language Models]에서 제안한 효율적인 파인튜닝 방법입니다. 다양한 거대 모델에 적용할 수 있고, 논문에서는 언어 모델을 중심으로 설명을 합니다.


Abstract

논문의 초록에서는 다음과 같이 로라의 핵심을 짚어서 말해주고 있어요.

We propose Low-Rank Adaptation, or LoRA, which freezes the pretrained model weights and injects trainable rank decomposition matrices into each layer of the Transformer architecture, greatly reducing the number of trainable parameters for downstream tasks.
  1. pretrained된 모델의 weight를 freeze한다.
  2. 학습 가능한 rank-decomposition, 즉 '분해된' 행렬을 트랜스포머 구조의 각 레이어마다 하나씩 주입한다.
  3. 이를 통해 downstream 태스크를 위해 파인튜닝할 때 트레이닝할 파라미터의 수를 획기적으로 줄일 수 있다.

Figure 1의 그림은 다들 많이 보셨을 것이라고 생각합니다. 파란색의 Pretrained Weights는 기존의 사전학습된 모델이 가지고 있던 웨이트맵을 의미하고, LoRA는 이는 수정할 수 없도록 freeze시킵니다. 대신 벡터 내적을 수행했을 때 W와 크기가 같아지는 주황색 A, B 행렬을 만들어서 트랜스포머 레이어 사이사이에 inject - 꽂아 넞어주고, 그 A, B 행렬을 트레이닝 시킵니다.

We hypothesize that the change in weights during model adaptation also has a low “intrinsic rank”, leading to our proposed Low-Rank Adaptation (LoRA) approach. LoRA allows us to train some dense layers in a neural network indirectly by optimizing rank decomposition matrices of the dense layers’ change during adaptation instead, while keeping the pre-trained weights frozen, as shown in Figure 1.

LoRA는 기존의 웨이트맵 행렬을 분해(rank decomposition)시켜서 기존의 freeze된 웨이트맵에 더해주는 방식으로 간접적으로 업데이트 합니다. 예를 들어서 W의 크기가 (10, 10)이고 분해된 행렬 A, B의 크기는 각각 (10, 2)와 (2, 10)이라고 해 봅시다.

  • W의 파라미터 수는 10*10 = 100(개) 입니다.
  • A와 B의 파라미터 수는 각각 20개씩으로 총합 40개입니다.
  • LoRA를 사용하면 기존의 100개 파라미터는 freeze시키고, 대신 분해된 행렬(A, B)의 40개 파라미터만 트레이닝하면 됩니다. A, B를 행렬곱하면 (10, 10) 사이즈가 되므로 기존의 웨이트맵에 더해줄 수 있습니다. 따라서 학습시켜야 할 파라미터의 수가 full-finetuning에 비해서 줄어들게 되고, 리소스를 효율적으로 활용할 수 있습니다.

1. Introduction

논문의 서론(Introduction)에서는 아래와 같이 로라의 핵심 장점을 요약해서 말하고 있습니다.

  1. 사전 학습된 모델의 파라미터는 동결시키고, 필요한 task에 따라 A와 B만 학습시키면 되기 때문에, 사전학습된 거대 모델을 다양한 downstream에 더 잘 활용할 수 있습니다.
  2. LoRA를 사용하면 inject한 low-rank matrices만 최적화시키면 되기 때문에 학습이 매우 효율적이고, 진입에 대한 하드웨어 장벽을 최대 3배까지 낮춰 준다고 합니다.
  3. 기존의 fully fine-tuning과 비교했을 때 inference latency(input이 들어가서 모델이 예측을 하기까지 걸리는 시간)이 없는 장점이 있습니다 - 따라서 아주 간단한 선형 구조만으로 freeze된 기존의 웨이트와 새롭게 학습시킨 웨이트를 병합할 수 있습니다.
  4. LoRA는 prefix 기반 접근과 함께 사용될 수 있습니다.

2. Problem Statement

논문의 2장 PROBLEM STATEMENT에서는 수식과 함께 자세한 설명을 합니다.

(1) 기존의 최대우도법 목적함수

로라 기법을 적용하지 않은 full fine-tuning에서는 pre-trained된 웨이트맵을 불러와서 목적함수를 업데이트하는 최대우도법(1)을 사용합니다. 즉, 최대우도법을 이용해 모델의 전체 파라미터 Φ 를 업데이트합니다. 저자들은 이렇게 모든 파라미터를 업데이트하는 방식보다 효율적인 LoRA를 제시하는데요. LoRA를 이용해 일부분의 파라미터만 업데이트하는 목적식은 다음과 같습니다.

(2) LoRA 목적함수

기존의 model 파라미터인 를 이용해 forward를 진행하고 얻어지는 (기울기)를 이용해 backpropagation을 진행할 때, LoRA의 파라미터 를 이용합니다. 논문에 따르면 LoRA로 업데이트하는 파라미터 Θ 의 크기인 | Θ | 가 기존의 full fine-tuning으로 업데이트하는 파라미터 Φ 의 크기인 | Φ | 의 0.01%라고 합니다. 그만큼 훨씬 효율적으로 튜닝이 가능하며, 각 downstream task마다 다른 LoRA layer를 사용할 수 있기 때문에 목적에 맞는 파인튜닝된 모델을 효율적으로 생산할 수 있는 것입니다.


허깅페이스 & LoRA

저는 논문 뿐만 아니라 라이브러리 docs 보는 것도 아주 좋아하기 때문에 - 보기 좋게 정리가 잘 되어 있어서 특히 허깅페이스 좋아합니다 - 로라와 관련된 허깅페이스 문서들을 조금 더 살펴보도록 하겠습니다.

https://huggingface.co/docs/diffusers/training/lora

 

LoRA

This is experimental and the API may change in the future. LoRA (Low-Rank Adaptation of Large Language Models) is a popular and lightweight training technique that significantly reduces the number of trainable parameters. It works by inserting a smaller nu

huggingface.co

허깅페이스의 Diffuser Docs에서는 LoRA를 a popular and lightweight training technique that significantly reduces the number of trainable parameters라고 설명하고 있습니다. 또한 논문에서 미리 살펴본 바와 같이 It works by inserting a smaller number of new weights into the model and only these are trained - 적은 수의 새로운 웨이트맵을 Insert하고, 딱 그것들만 학습시킨다는 점을 짚어주고 있습니다.

로라는 가장 처음에 언어 모델에 고안되었지만, 그 확장성과 용이성으로 인해 많은 Diffusion 모델에 사용되고 있다고 합니다. 실제로 저도 Stable Diffusion을 파인튜닝하여 text-to-image 모델을 구현하는 데 로라 기법을 사용한 경험이 있습니다.


https://huggingface.co/docs/peft/conceptual_guides/adapter

 

Adapters

Adapter-based methods add extra trainable parameters after the attention and fully-connected layers of a frozen pretrained model to reduce memory-usage and speed up training. The method varies depending on the adapter, it could simply be an extra added lay

huggingface.co

허깅페이스의 PEFT docs 페이지의 'Adapters'에서도 LoRA에 관해 이야기하고 있습니다. 위 애니메이션은 우리가 논문에서 살펴보았던 LoRA의 행렬 분해 방식을 잘 보여주고 있습니다.

In principle, LoRA can be applied to any subset of weight matrices in a neural network to reduce the number of trainable parameters. However, for simplicity and further parameter efficiency, LoRA is typically only applied to the attention blocks in Transformer models. The resulting number of trainable parameters in a LoRA model depends on the size of the update matrices, which is determined mainly by the rank r and the shape of the original weight matrix.

이론적으로 LoRA는 neural-network 구조를 가지는 모델의 어떤 부분에든지 적용될 수 있지만, 간소화와 파라미터 효율성을 고려하여 보통 트랜스포머 구조의 attention layer에 적용되는 것이 일반적이라고 합니다. 나중에 코드에서 다시 살펴보겠지만, 분해된 행렬의 사이즈를 정의하는 하이퍼 파라미터로는 rank r이 있습니다. Figure 1 이미지의 주황색 부분에 잘 보면 r이라고 써있습니다 :)

r의 값이 작을수록 계산은 쉽고 빠르겠지만 downstream task에 따른 디테일한 학습이 어려울 수 있고, r의 값이 클수록 디테일한 파라미터 업데이트가 가능하지만 그만큼 분해 행렬의 파라미터 수도 많아지므로 계산량이 늘어납니다. 따라서 주어진 Task에 따라 적절한 r값을 찾아내고 설정하는 것이 모델러의 중요한 임무중 하나라고 할 수 있겠습니다.


unsloth

huggingface 공식 peft docs 페이지, unsloth 공식 깃허브, 허깅페이스 블로그 글 - 총 3가지 레퍼런스를 참고하여 최신 LLaMA 3.1-8B 모델을 직접 LoRA 방식으로 파인튜닝 해보도록 하겠습니다.

  1. https://huggingface.co/docs/peft/package_reference/lora
  2. https://github.com/unslothai/
  3. https://huggingface.co/blog/Andyrasika/finetune-unsloth-qlora
 

Unsloth AI

Our mission is to make LLMs for everyone 🦥. Unsloth AI has 6 repositories available. Follow their code on GitHub.

github.com

Unsloth는 대형 언어 모델(LLM)들의 파인튜닝과 훈련을 가속화하고 메모리 사용을 최적화하기 위한 오픈 소스 라이브러리입니다. 특히 Llama, Mistral, Phi, Gemma 등의 LLM 모델들을 2-5배 빠르게 훈련시키고 메모리 사용량을 최대 80%까지 줄일 수 있습니다​. 실제로 사용해본 결과, 지인짜 빠르고 편리합니다. 강추...

위 공식 깃허브에서 Finetune for Free 아래의 Llama 3.1 (8B) Free Notebooks 코드를 클릭하면 Colab 코드가 실행됩니다. 저는 코랩 코드를 참고하여 로컬 On-Premise GPU에서 진행하였습니다. 저처럼 로컬에서 진행하시는 경우 공식 깃허브를 참고하여 unsloth 라이브러리를 사용하기 위한 환경설정과 다운로드를 완료한 뒤에 코드를 실행하시기 바랍니다.

LoraConfig

먼저 간단하게 peft 라이브러리에서 설정할 수 있는 LoRA Configuration 하이퍼파라미터 3가지만 짚고 넘어가 보겠습니다.

  • r (int) — Lora attention dimension (the “rank”)
    • 위에서 살펴본 r - rank decomposition의 r값을 설정, r이 클수록 분해 행렬의 사이즈가 커집니다.
    • 예를 들어, 만약 원래의 파라미터 행렬이 m×n 크기라면, A m×r, Br×n 크기가 됩니다.
  • lora_alpha (int) — The alpha parameter for Lora scaling 
    • lora_alpha는 직접적으로 로라 페이퍼 원문에서 언급된 적은 없지만 중요한 역할을 하는 하이퍼파라미터입니다. lora_alpha 파라미터는 low-rank decomposition 행렬이 기존의 고정된(weight-frozen) 모델 파라미터에 합쳐질 때, 그 비율을 결정합니다. 즉, 분해된 저차원 행렬의 영향을 원래 모델에 얼마나 반영할지를 조절하는 것이 바로 lora_alpha 파라미터입니다.
    • 참고한 허깅페이스 블로그에서는 the strength of the applied LoRA라고 설명하고 있습니다.
  • lora_dropout (float) — The dropout probability for Lora layers.

Unsloth 이용 llama3.1-8B 파인튜닝 (QA task)

from unsloth import FastLanguageModel
import torch
max_seq_length = 2048
dtype = None

unsloth 라이브러리의 FastLanguageModel 클래스를 이용하여 라마3.1-8B모델을 불러올텐데요.

load_in_4bit = False # 4비트로 양자화된 모델을 불러올 경우 True로 설정 -> 조금 더 빨라짐

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = "unsloth/Meta-Llama-3.1-8B",
    max_seq_length = max_seq_length,
    dtype = dtype,
    load_in_4bit = load_in_4bit
)

저는 load_in_4bit을 False로 설정하고 양자화되지 않은 기본 라마3.1-8B을 불러왔습니다. 코랩 T4 등의 작은 GPU로 실행하시는 경우 이 값을 True로 설정하셔서 가볍게 돌리시는 것을 추천합니다.

model = FastLanguageModel.get_peft_model(
    model,
    r = 16, # Choose any number > 0 ! Suggested 8, 16, 32, 64, 128
    target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
                      "gate_proj", "up_proj", "down_proj",],
    lora_alpha = 16,
    lora_dropout = 0, # Supports any, but = 0 is optimized
    bias = "none",    # Supports any, but = "none" is optimized
    # [NEW] "unsloth" uses 30% less VRAM, fits 2x larger batch sizes!
    use_gradient_checkpointing = "unsloth", # True or "unsloth" for very long context
    random_state = 1004,
    use_rslora = False,  # We support rank stabilized LoRA
    loftq_config = None, # And LoftQ
)

위에서 살펴본 LoRA 관련 하이퍼파라미터들이 보입니다.

새로운 하이퍼파라미터로 use_rslora가 있는데요. RsLoRA는 LoRA 방식의 변형으로, 랭크를 안정화시키는 기법을 통해 효과적으로 파인튜닝을 할 수 있는 방법이라고 합니다. RsLoRA는 학습 과정 중에 랭크를 동적으로 조절하여 최적화하는데, 고정된 랭크를 사용하는 기존 LoRA 방식에 비해 더 나은 성능을 도출할 수 있다고 하네요. 저는 우선 False로 설정했고, 나중에 True로 변환하여 성능을 비교해보고자 하였습니다. 또한 코랩과 같은 제한된 환경에서 실행하는 경우에도 False로 설정하는것이 낫겠습니다.

또한 loftq_config의 경우 모델의 백본 가중치를 양자화하고 LoRA 계층을 초기화하는 방법을 정의한다고 합니다. 저는 모델을 로드할 때 load_in_4bit을 False로 두어 양자화되지 않은 기본 모델을 로드했으며, loftq_config 역시 None으로 설정하여 모델의 백본 가중치를 양자화하지 않는 것으로 설정하였습니다.

from datasets import load_dataset
dataset = load_dataset("ruslanmv/ai-medical-chatbot", split = "train")

prompt = """You are a professional medical doctor. Based on the below context, generate answer for the question.

### Description:
{}

### Patient:
{}

### Doctor:
{}"""


EOS_TOKEN = tokenizer.eos_token # Must add EOS_TOKEN
def formatting_prompts_func(examples):
    instructions = examples["Description"]
    inputs       = examples["Patient"]
    outputs      = examples["Doctor"]
    texts = []
    for instruction, input, output in zip(instructions, inputs, outputs):
        # Must add EOS_TOKEN, otherwise your generation will go on forever!
        text = prompt.format(instruction, input, output) + EOS_TOKEN
        texts.append(text)
    return { "text" : texts, }
pass

dataset = dataset.train_test_split(test_size=0.01)

dataset_train = dataset['train']
dataset_test = dataset['test']

dataset_train = dataset_train.map(formatting_prompts_func, batched = True,)
dataset_test = dataset_test.map(formatting_prompts_func, batched = True,)

저는 허깅페이스에서 medical chatbot QA task용으로 준비된 데이터셋을 사용해서 위와같이 전처리과정을 거쳤습니다. train 데이터는 총 254,346개이며 test 데이터는 총 2,570개가 있습니다.

from trl import SFTTrainer
from transformers import TrainingArguments
from unsloth import is_bfloat16_supported

trainer = SFTTrainer(
    model = model,
    tokenizer = tokenizer,
    train_dataset = dataset_train,
    dataset_text_field = "text",
    max_seq_length = max_seq_length,
    dataset_num_proc = 2,
    packing = False, # Can make training 5x faster for short sequences.
    args = TrainingArguments(
        per_device_train_batch_size = 2,
        gradient_accumulation_steps = 4,
        warmup_steps = 5,
        num_train_epochs = 1,
        max_steps = 60,
        learning_rate = 2e-4,
        fp16 = not is_bfloat16_supported(),
        bf16 = is_bfloat16_supported(),
        logging_steps = 1,
        optim = "adamw_8bit",
        weight_decay = 0.01,
        lr_scheduler_type = "linear",
        seed = 1004,
        output_dir = "outputs",
    ),
)

다음으로 trl 라이브러리의 SFTTrainer 클래스를 사용해서 superfised fine tuning(SFT)을 진행합니다. 

  • per_device_train_batch_size: 각 디바이스(예: GPU)당 훈련에 사용할 배치 크기- 모델이 한 번의 훈련 스텝에서 처리할 데이터 샘플의 수를 의미. 크기가 클수록 더 많은 메모리가 필요하며, 일반적으로 크기가 클수록 더 나은 일반화 성능을 제공함.
  • gradient_accumulation_steps: 그래디언트 누적 단계의 수
  • warmup_steps: 학습률이 선형적으로 증가하는 워밍업 스텝 수 - 모델이 학습 초반에 너무 큰 학습률로 인해 불안정해지는 것을 방지하는 역할. 초기에는 낮은 학습률로 시작하여 점진적으로 증가시키도록 함.
  • is_bfloat16_supported()
    • is_bfloat16_supported() : Unsloth 라이브러리에서 제공하는 함수 - 현재 사용 중인 하드웨어가 bfloat16(Brain Floating Point 16) 정밀도를 지원하는지 여부를 확인하는 역할
    • bfloat16이 지원되는 경우, 이를 사용하여 메모리 사용량을 줄이고 훈련 속도를 높이는 것이 가능 (지원되지 않는 경우, fp16 또는 fp32를 사용)
    • fp16과 bf16은 상호 배타적인 설정으로 동시에 사용할 수 없음
  • logging_steps : 로그를 기록할 스텝 간격
  • optim : 사용할 옵티마이저의 종류 ("adamw_8bit"는 AdamW 옵티마이저를 8비트 정밀도로 사용하여 메모리 효율성을 높이고 계산 비용을 줄임)
  • weight_decay : 가중치 감쇠(L2 정규화) 계수 (모델의 복잡성을 줄이고 과적합을 방지하기 위해 가중치 값을 점진적으로 감소시키는 역할)
  • lr_scheduler_type: 학습률 스케줄러의 유형 (linear는 학습률을 선형적으로 감소)
  • output_dir : 훈련 결과를 저장할 디렉토리 경로
trainer_stats = trainer.train()

학습이 완료되었습니다.

def inference_i(i):
    FastLanguageModel.for_inference(model) # Enable native 2x faster inference
    question = dataset_test[i]['Description']
    inputs = tokenizer(
    [
        prompt.format(
            "Answer the question truthfully, you are a medical professional.", # instruction
            question, # input
            "", # output - leave this blank for generation!
        )
    ], return_tensors = "pt").to("cuda")

    outputs = model.generate(**inputs, max_new_tokens = 1024, use_cache = True)
    tokenizer.batch_decode(outputs)

    predict = tokenizer.batch_decode(outputs)[0].split("###")[-1]
    truth = dataset_test[i]['Doctor']

    print("-----------------------")
    print(f"question : {question}")
    print("-----------------------")
    print(f"inference : {predict}")
    print("-----------------------")
    print(f"label answer : {truth}")
    print("-----------------------")
    
    return question, predict, truth

위와 같이 dataset_test의 i번째 데이터에 대해 inference를 수행할 수 있는 함수를 직접 작성했구요.

question, predict, truth = inference_i(0)

요런식으로 실행하면 question, inference 결과, 기존의 label answer값을 확인할 수 있습니다.


question : High BP, have consistent cough with chest pain, traces of blood in phlegm. Normal chest x-ray, on BP medicines. Cause?
[데이터셋의 답변 예시] label answer : Hello there, In addition to the possibilities of asthma, chest infection which have been ruled out by X Ray etc, another possibility is GERD(gastro esophageal reflux disease/gastritis). You might need to take a course of pantoprazole/omeprazole , if that is the case. It is possible that the stress(you said you were worried about your brother's son) could have caused GERD/gastritis.

1. I would advise you to have frequent small meals.
2. Avoid spicy,deep fried,fatty food and carbonated beverages.
3.Do relaxation exercises/meditation.
4. Avoid sleeping/lying down immediately after a meal. Wait for 2 hours before lying down after a meal.
5.Avoid smoking/alcohol. Hope this helps.

Please let me know if you need any further advise. Regards, Dr.Divya Kuttikrishnan

위와 같은 질문을 던졌을 때, 파인튜닝한 llama3.1-8B모델은 아래와 같이 답변했습니다.

Hello, Thanks for writing to us. Chest pain with cough is very serious and needs to be investigated. Chest X-ray is a very basic investigation and needs to be followed by a CT scan of the chest. CT scan is a very important investigation and will help in identifying the cause of the chest pain. If the CT scan is normal, then you need to get an ECG and echocardiogram done. These will help in identifying the cause of the chest pain. I hope this helps. Take care. Regards, Dr. Rakesh Kumar, General & Family Physician

데이터셋의 예시 답변과 비교하면 풍부함이 조금 떨어지긴 하나, medical QA로서의 의무는 어느정도 수행하고 있다고 평가할 수 있을 것 같습니다. 그렇다면 파인튜닝을 하나도 거치지 않은 기본 llama3.1-8B모델은 같은 질문에 대해서 어떻게 대답할까요?

ollama를 통해 터미널로 QA를 실행해본 모습

medical QA datset으로 파인튜닝한 모델에 비하면 기본 모델은 medical QA보다는 LLM스러운 조금 더 일반적이고 풍부한 대답을 보여주는 것 같습니다. 솔직히 파인튜닝 안한게 더 나아보입니다. 인삿말이나 마무리말 정도는 프롬프팅으로도 충분히 구현할 수 있으니까요. 파인튜닝이 반드시 성공적으로 끝나는 것은 아니다-라는 좋은 예시가 되겠습니다...(ㅋㅋㅋㅋ)

파인튜닝을 통해 기본 백본 모델보다 나은 결과를 도출하기 위해서는 하이퍼파라미터 튜닝과 최적화 과정을 거쳐야 할 것으로 생각되구요. 또 제가 의학 전문가가 아니다보니 퀄리티 측면에서 무엇이 더 낫다고 평하기 조금 애매한 부분이 있어서, 실제 프로젝트에서는 도메인 관련한 전문가와의 협업이 필수적으로 요구될 것으로 생각됩니다.


파인튜닝을 통해 downstream task에 적용할 수 있는 더 나은 모델을 만드는 과정은 제가 지금 구축하고 있는 실제 LLM 프로젝트에서 조금더 고민해보는것으로 하고, 본 포스팅은 이정도로 마무리를 하겠습니다 :-) 본 포스팅을 통해 LoRA, Peft, Unsloth를 통한 효율적인 파라미터 튜닝 방법에 대해 고찰해볼 수 있는 좋은 기회였습니다.

감사합니다 :)

재밌는 토이 프로젝트! RAG 기술을 활용하여 pdf 파일에서 내용을 검색, 질의응답(QA)을 구현하는 챗봇 시스템을 만들어 보았습니다.

* 본 포스팅 활용하시는 경우 출처를 밝혀주세요 :-)
* 전체 코드 .py파일을 원하시는 분들께서는 포스팅 하단에 github 링크 참고 바랍니다.


* 사용한 라이브러리

pdfplumber, pytesseract, langchain, ollama, chromadb, gradio (전부 무료)

* 프로젝트 개요

  1. PDF 파일의 텍스트를 pdfplumber, pytesseract로 추출합니다.
  2. 추출한 내용을 langchain을 이용해서 split, 임베딩하여 벡터화한 다음 Chroma 벡터 저장소에 저장합니다.
  3. 벡터 저장소에서 질문에 해당하는 내용을 검색하여 context로 준비합니다.
  4. Ollama 라이브러리를 이용해서 LLaMA3 모델에게 질문과 context를 프롬프트로 제공하고 답변을 받습니다.
  5. 모든 과정을 gradio와 연동하여 GUI로 실행할 수 있도록 설정했습니다.

1. 환경설정 및 라이브러리 준비

먼저 새로운 콘다 환경을 만들고 해당 환경 안에서 프로젝트를 진행하도록 하겠습니다. 

conda create -n ragchatbot python=3.11 -y # 환경 생성
conda activate ragchatbot                 # 활성화

필요한 라이브러리를 설치 후 임포트합니다.

# 설치 (터미널에서 실행)
pip install pdfplumber pytesseract ollama gradio langchain

# 임포트
import gradio as gr
from langchain.docstore.document import Document
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import OllamaEmbeddings
import ollama
import pdfplumber
import pytesseract
from PIL import Image

2. 텍스트 추출, 임베딩, chroma에 저장

텍스트 추출, 임베딩, 벡터 데이터베이스 저장에 필요한 2가지 함수를 작성하겠습니다.

# PDF page에서 텍스트 추출하는 함수 작성
def extract_text_with_ocr(page):
    text = page.extract_text()
    if not text: # 만약 추출할 텍스트가 없다면
        # PDF page를 이미지로 변환
        image = page.to_image()
        # 이미지에서 OCR 재실행하여 텍스트 추출
        text = pytesseract.image_to_string(image)
    return text
    
# PDF 파일을 열어서 extract_text_with_ocr 함수 실행 -> 벡터 데이터베이스에 저장하는 함수 작성
def load_and_retrieve_docs(file):
    text = ""
    try:
        with pdfplumber.open(file) as pdf:
            for page in pdf.pages:
                page_text = extract_text_with_ocr(page)
                # 페이지에서 추출한 텍스트가 있을 때마다 text에 누적해서 저장
                if page_text:
                    text += page_text
    except Exception as e:
        return f"Error reading PDF file: {e}"
    
    # 만약 추출한 텍스트가 하나도 없는 경우 아래와 같은 메세지 출력하고 함수 종료
    if not text:
        return "No text found in the PDF file."
    
    # 추출한 텍스트가 있는 경우 
    docs = [Document(page_content=text)]
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
    splits = text_splitter.split_documents(docs)
    embeddings = OllamaEmbeddings(model="mxbai-embed-large")
    vectorstore = Chroma.from_documents(documents=splits, embedding=embeddings)
    return vectorstore.as_retriever()
  • extract_text_with_ocr
    • 본 함수는 pdf 개별 페이지를 Input으로 받아서 페이지 내의 텍스트를 추출, 반환하는 함수입니다.
    • 텍스트 추출에는 pdfplumber 라이브러리의 extract_text() 함수를 사용했습니다. 만약 페이지 내에서 추출할 텍스트가 없는 경우 pdfplumber 라이브러리의 to_image() 함수를 사용해서 pdf파일을 pdfplumber가 핸들할 수 있는 PageImage object객체화(이미지화)합니다. 이후 해당 이미지에서 pytesseractimage_to_string() 함수를 사용해서 이미지로부터 텍스트를 추출하도록 했습니다.
    • pytesseract docs : https://pypi.org/project/pytesseract/
    • pdfplumber docs : https://github.com/jsvine/pdfplumber
  • load_and_retrieve_docs
    • 본 함수는 pdf파일을 열어서 모든 페이지마다 extract_text_with_ocr함수를 반복, text에 누적해서 더해주어 하나의 string으로 완성합니다.
    • pdf파일을 열기 위해서(load) pdfplumber 라이브러리의 open()함수를 사용했습니다.
    • 완성된 text를 langchainDocument 객체로 만든 다음, RecursiveCharacterTextSplitter 객체를 생성한 뒤 만들어 둔 Document 객체를 집어넣어 적당히 청킹을 해주었습니다. 이후 청킹이 완료된 텍스트들을 벡터화하기 위한 OllamaEmbeddings 객체를 생성해 주었습니다.
    • langchain에 연동된 Chroma 라이브러리를 불러온 다음, from_documents() 함수를 이용해 Chroma에 load를 해 줍니다. 이 때, 저장할 텍스트 청킹 객체와 임베딩 객체를 지정해 주어 무엇을 어떻게 임베딩하여 저장할지를 지정해주면 자동으로 임베딩을 실행하면서 vectorstore에 저장을 완료하기 때문에 사용이 매우 간편합니다.
    • 마지막으로 load_and_retrieve_docs 함수는 결과값으로 vectorstore에 as_retriever() 함수를 적용한 객체를 반환합니다.
    • langchain docs(Chroma) : click
    • langchain docs(RecursiveCharacterTextSplitter) : click

3. 검색 실행

먼저 터미널에서 LLaMA3 모델을 다운로드(pull) 해줍니다.

참고로 docs에 따르면 llama3 기본 모델보다 chatqa 모델이 대화형 QA와 RAG 기능을 훨씬 잘 수행한다 - excels at conversational question answering (QA) and retrieval-augmented generation (RAG) - 라고 기재가 되어 있어서 처음에는 LLaMA3-chatqa모델을 선택하였는데, 실험 결과 LLaMA3-chatqa보다 기본 LLaMA3 모델이 더 답변이 정상적으로 생성되는 것을 확인할 수 있었습니다. 그 이유에 대해서는 좀더 살펴봐야 할 것 같아요.

참고로 모델 크기가 커서 pull에 시간이 좀 소요되는 편입니다.

ollama pull llama3

 

# 리스트 안의 모든 document 객체 내용을 추출해서 string으로 이어붙여 반환
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# Function that defines the RAG chain
def rag_chain(file, question):
    retriever = load_and_retrieve_docs(file)
    if isinstance(retriever, str):  # 리턴받은 값이 string인 경우 에러를 의미하므로 함수 중단
        return retriever
    
    retrieved_docs = retriever.get_relevant_documents(question)  # Use get_relevant_documents method
    formatted_context = format_docs(retrieved_docs)
    formatted_prompt = f"Question: {question}\n\nContext: {formatted_context}"
    response = ollama.chat(model='llama3', 
                           messages=[
                                {"role": "system",
                                 "content": "You are a helpful assistant. Check the pdf content and answer the question."
                                },
                                {"role": "user", "content": formatted_prompt}])
    return response['message']['content']
  • rag_chain
    • 위에서 작성했던 load_and_retrieve_docs 함수는 pdf 내용을 저장해 둔 vectorstore에 as_retriever() 함수를 적용한 객체를 반환합니다. 먼저 이 객체에 retriever라는 이름을 할당한 다음, 만약 retriever의 객체 타입이 string인 경우 에러 메세지(추출한 텍스트 없을 때)가 리턴된 것을 의미하므로 해당 에러를 프린트하면서 함수를 종료합니다.
    • docs에 따르면 get_relevant_documents() 함수주어진 쿼리(질문)에 가장 관련성이 높은 문서를 벡터 저장소에서 검색하는 기능을 합니다. 예를 들어 제가 lloco_paper.pdf 파일로 테스트를 했을 때, RecursiveCharacterTextSplitters를 이용해서 split한 document 객체는 총 55개였고, 그 중에서 검색을 통해 반환하는 document 객체는 항상 4개인 것으로 확인이 되었습니다. 이 수치를 변경할 수 있는지는 나중에 확인을 해 보도록 하겠습니다.
    • 반환받은 document 객체 리스트인 retrieved_docs를 format_docs 함수를 이용해서 하나의 string으로 변환하고, 해당 string은 prompt의 context로 주어지게 됩니다.
    • ollama 라이브러리chat() 함수를 이용해서 LLaMA 3 모델에 question과 context를 제공하고, 답변(response)을 받습니다. chat

만약 답변을 한국어로 받고 싶은 경우 messages의 context 안에 Generate your answer in Korean 등과 같이 한국어로 답변을 내놓으라는 한 문장을 추가해서 프롬프팅을 해 주시면 됩니다.

4. Gradio GUI 인터페이스 할당

# Gradio interface
iface = gr.Interface(
    fn=rag_chain,
    inputs=["file", "text"],
    outputs="text",
    title="[LLAMA 3] RAG 검색 활용 챗봇 시스템",
    description="PDF파일을 업로드하고 질문을 입력하면 답변을 생성해 드립니다. (영어로!)"
)

# app 실행
iface.launch()

주피터 파일로 실행하는 경우 바로 인터페이스가 보이실 것이고, 만약 .py파일로 실행하신다면 아래와 같이 할당되는 로컬 URL 주소로 접속을 하시면 인터페이스를 확인하실 수 있습니다.

URL로 접속하면 위와 같은 화면이 보이고, pdf파일 업로드 + question 작성 후 submit을 누르면 output에 답변이 생성됩니다.

저는 제가 현재 읽고 있는 LLoCO 논문(2024년 4월 발행)을 업로드해서 4.1장과 4.5장을 요약해달라는 요청을 해 보았구요

위와 같이 답변을 생성받았습니다 :-) 이모지 넣어달라고 했더니 야무지게 2개 넣어줬네요. 제공한 pdf 파일이 15장의 긴 파일임을 생각하면, 간단히 진행해본 것과 비교했을 때 성능이 꽤 잘 나온 것으로 생각하고 있습니다.


https://github.com/surisurikim/deep_learning/tree/main/RAG

 

deep_learning/RAG at main · surisurikim/deep_learning

PyTorch 기반 딥러닝 논문 코드구현을 공부하는 레포지토리입니다. Contribute to surisurikim/deep_learning development by creating an account on GitHub.

github.com

해당 토이 프로젝트는 위 깃허브에서 코드 파일을 확인하실 수 있습니다. 감사합니다 :)

(*) 본 논문 리뷰 포스팅은 수리링이 직접 BERT 논문 원문을 처음부터 끝까지 읽고 작성했습니다. 포스팅을 참고하시는 경우 반드시 출처를 밝혀주시기를 미리 부탁드립니다. 감사합니다.

https://arxiv.org/pdf/1810.04805

본 논문 리뷰는 BERT 원문을 직접 읽고 버트의 핵심 아이디어와 구조에 대해서 살펴봅니다. 구체적인 실험과 학습 결과, 성능 지표 등에 대한 리뷰는 생략하는 점 양해 바랍니다.


초록 Abstract

  • 트랜스포머의 인코더는 Recurrent(순차) 구조 없이 입력을 '통으로' 받습니다. 따라서 입력 시퀀스의 각 위치에서 왼쪽, 오른쪽 양방향(Bidirectional) 문맥을 모두 고려할 수 있습니다.
  • 이와 반대로 디코더는 마스킹을 통해 현재 시점까지의 토큰들만을 참조하는 단방향(unidirectional)으로 작동합니다. 

BERT는 Bidirectional Encoder Representations from Transformers의 약자로, 이름에서도 알 수 있듯 트랜스포머의 '인코더'에만 집중한 모델입니다. 따라서 양방향(Bidirectional) 문맥을 고려하여 언어를 잘 이해하도록 학습(pre-train)이 되었습니다. 

이 때 BERT의 R(Representation)은 무슨 의미일까요? NLP 태스크에서 자주 언급되는 Word Representation은 인간의 언어를 다차원 벡터로 표현하여 컴퓨터가 이해할 수 있게 하는 작업이나 그 결과물을 의미합니다. BERT의 R(Representation) 역시 입력된 단어나 문장의 의미를 벡터 형태로 표현하여 모델이 해당 언어의 문맥과 의미를 이해할 수 있도록 하는 작업을 포함합니다. 본 포스팅에서는 따로 '표현'이라는 한국어로 직역하지 않고 그대로 reperesentation으로 표기할 것임을 미리 밝히겠습니다.

BERT is designed to pre-train deep bidirectional representations from unlabeled text by jointly conditioning on both left and right context in all layers. As a result, the pre-trained BERT model can be fine-tuned with just one additional output layer to create state-of-the-art models for a wide range of tasks, such as question answering and language inferences, without substantial task-specific modifications.

초록에서는 BERT가 모든 레이어에서 deep한 bidirectional representations을 학습하기 위해 고안되었으며, 사전 학습이 완료된 BERT 모델에 딱 1개의 output layer만 추가해서 파인튜닝이 가능할 정도로 '파인튜닝이 쉽고 용이하다'고 강조하고 있습니다. 이를 통해 QA(Question-Answering)이나 Langue Inferences과 같은 다양한 작업을 수행할 수 있는 것이죠 :-)



1 서론 Introduction

There are two existing strategies for applying pre-trained language representations to downstream tasks: feature-based and fine-tuning. .... (중략) they use unidirectional language models to learn general language representations. We argue that current techniques restrict the power of the pre-trained representations, especially for the fine-tuning approaches. 

사전 학습된 언어 representation을 다운스트림 작업에 적용하는 두 가지 기존 전략으로는 특징(feature-based) 기반 접근법과 파인튜닝 접근법이 있습니다.

  • 딥러닝에서 다운스트림 태스크(downstream tasks)는 특히 사전 학습(pre-trained)된 모델에 Transfer Learning/Fine Tuning을 통해 수행하고자 하는 구체적인 하위 작업을 뜻합니다. 예를 들어, 자연어 처리 분야에서는 텍스트 분류, 감정 분석, 명명된 개체 인식(NER), 질문 응답(QA) 등이 다운스트림 작업에 해당할 수 있겠죠.
  • Feature-based 방법은 사전 학습된 언어 모델을 사용하여 텍스트 데이터를 처리한 후, 그 결과로 나온 특징(feature) 벡터를 다른 기계 학습 모델에 입력으로 사용하는 방법입니다. 따라서 전체 과정이 하나의 모델로 통합되어 한 번에 학습되고 예측되는 end-to-end 방식이라고 볼 수 없습니다. (대표적으로 ELMo)
  • Fine-tuning 방법은 사전 학습된 언어 모델을 특정 작업에 맞게 추가 학습시켜 사용하는 방식으로, 전체 과정을 하나의 모델로 통합하여 end-to-end 방식으로 학습과 예측을 수행합니다. (대표적으로 OpenAI GPT)

논문에서는 ELMo나 GPT같은 기존의 모델이 representation을 학습하기 위해 unidirectional - 단방향 언어모델을 사용하면서 사전 학습된 representation을 온전히 활용하지 못했고, 그래서 특히 파인튜닝 단계에서 문제가 많았다고 지적합니다.

부록 Figure 3.

The major limitation is that standard language models are unidirectional, and this limits the choice of architectures that can be used during pre-training. For example, in OpenAI GPT, the authors use a left-to-right architecture, where every token can only attend to previous tokens in the self-attention layers of the Transformer (Vaswani et al., 2017). Such restrictions are sub-optimal for sentence-level tasks, and could be very harmful when applying fine-tuning based approaches to token-level tasks such as question answering, where it is crucial to incorporate context from both directions.

단방향 모델은 문맥의 일부만을 참조할 수 있습니다. 예를 들어, OpenAI GPT는 디코더 특화 모델로, 자신의 이전 토큰까지만 참조할 수 있는 좌->우 단방향 아키텍처를 사용하여 출력을 생성하는 특징이 있습니다.

  • 예를 들어 GPT가 QA(Question-Answering) 작업을 수행한다고 해 봅시다. QA 작업에서는 질문(Q)을 받아 문서(Context)의 어느 부분이 정답에 해당하는지를 알아내야 하고, 이 과정에서 정답의 앞뒤 문맥이 모두 중요하게 고려되어야 합니다. 하지만 GPT는 문서를 왼쪽->오른쪽 단방향으로만 읽기 때문에, 정답의 뒷부분에 있는 중요한 정보를 참조하기 어렵습니다. 따라서 QA 작업을 위한 fine-tuning을 수행할 때 전체 문맥을 이해하는 데 어려움이 있을 것이고, 그만큼 정확한 답을 찾아내도록 fine-tuning이 어려울 수 있다는 것이죠.

대놓고 OpenAI 저격하는거 꿀잼...

In this paper, we improve the fine-tuning based approaches by proposing BERT: Bidirectional Encoder Representations from Transformers. (중략) The masked language model randomly masks some of the tokens from the input, and the objective is to predict the original vocabulary id of the masked word based only on its context. Unlike left-to-right language model pre-training, the MLM objective enables the representation to fuse the left and the right context, which allows us to pretrain a deep bidirectional Transformer.

In addition to the masked language model, we also use a “next sentence prediction” task that jointly pre-trains text-pair representations.

본 논문은 BERT를 통해 파인튜닝 기반 접근법을 개선하는데, 대표적으로 "MLM(Masked Language Model)"을 도입합니다.

  1. 입력 토큰에서 무작위로 마스킹을 한 다음,
  2. '양방향' 문맥을 고려하여 마스킹한 부분을 예측하도록 학습을 시킵니다.
  3. deep한 스트럭처를 실현합니다.

또한 BERT는 MLM 외에도 텍스트 쌍 표현을 공동으로 사전 학습하는 "NSP(Next Sentence Prediction)"를 함께 사용합니다.



3 버트 BERT

BERT는 크게 pre-training과 fine-tuning의 2스텝으로 이루어져 있는데요.

During pre-training, the model is trained on unlabeled data over different pre-training tasks.

For finetuning, the BERT model is first initialized with the pre-trained parameters, and all of the parameters are fine-tuned using labeled data from the downstream tasks. Each downstream task has separate fine-tuned models, even though they are initialized with the same pre-trained parameters. 
  1. 우선 버트는 라벨이 따로 없는 데이터로 '비지도 학습'을 통해 사전 학습(pre-training)을 합니다.
  2. 다음 파인튜닝 단계에서는 라벨이 있는 데이터로 '지도 학습'을 하면서 사전에 학습한 파라미터 전체를 업데이트하게 됩니다. 나의 다운스트림 태스크가 무엇이냐에 따라서 사용하는 데이터도 다를거고, 당연히 그에 따라 업데이트 되는 파라미터 값도 달라지겠죠.
A distinctive feature of BERT is its unified architecture across different tasks. There is minimal difference between the pre-trained architecture and the final downstream architecture.

BERT는 초록에서 살펴본 바와 같이 fine-tuned with just one additional output layer : 단 하나의 아웃풋 레이어만 추가해서 파인튜닝을 합니다. 그래서 다운스트림 태스크에 따른 파인튜닝 전후에 구조 차이가 거의 없는 편이고, 그만큼 다양한 NLP 작업에서 일관된 성능을 발휘할 수 있는 장점이 있습니다.

We primarily report results on two model sizes: BERT BASE (L=12, H=768, A=12, Total Parameters=110M) and BERT LARGE (L=24, H=1024, A=16, Total Parameters=340M).

버트에는 크게 두가지 사이즈의 모델이 있어요.

  • BERT BASE
    • 768차원(H)을 12개의 벡터로 나눠서(64차원씩) 멀티헤드 어텐션수행(A)
    • 인코더 블록 총 12번 반복(L)
  • BERT LARGE
    • 1024차원(H)을 16개의 벡터로 나눠서(64차원씩)멀티헤드 어텐션수행(A)
    • 인코더 블록 총 24번 반복(L)

바닐라 트랜스포머가 512차원을 8개의 벡터로 나눠서(64차원씩) 멀티헤드 어텐션 수행, 인코더 블록을 총 6번 반복(후 디코더를 사용)했던 것과 비교하면 버트는 그보다 훨씬 더 deep한 인코더를 구성했다고 볼 수 있고, 이로 인해 버트는 문맥 정보를 더욱 깊이 학습하게 되어 다양한 NLP작업에서 높은 성능을 발휘할 수 있게 됩니다. 논문 초록에서 BERT가 deep한 bidirectional(both left and right) representations을 학습하도록 고안되었다고 한 이유를 여기서 찾아볼 수 있겠네요.

BERT BASE was chosen to have the same model size as OpenAI GPT for comparison purposes. Critically, however, the BERT Transformer uses bidirectional self-attention, while the GPT Transformer uses constrained self-attention where every token can only attend to context to its left.

논문에서 계속 OPENAI GPT와의 비교를 하면서 우리는 양방향이고 쟤네는 단방향이라 우리가 더 좋다!는 뉘앙스의 문장이 빈번하게 나오는데요. (ㅋㅋㅋㅋ) BERT와 GPT는 애초에 타겟하는 목적이 다르기 때문에, 무엇이 더 좋고 나쁘다고 비교할 문제는 아닌것 같고, 저는 각자가 목적에 맞는 구조를 알맞게 잘 선택한 것으로 이해했습니다.


[CLS] is a special symbol added in front of every input example, and [SEP] is a special separator token.

To make BERT handle a variety of down-stream tasks, our input representation is able to unambiguously represent both a single sentence and a pair of sentences (e.g., h Question, Answeri) in one token sequence. We use WordPiece embeddings (Wu et al., 2016) with a 30,000 token vocabulary.

BERT는 fine-tuning을 통해 다양한 다운스트림 task를 수행할 수 있도록 input representation으로 하나의 문장(a single sentence)을 받을 수도 있고, 한 쌍의 문장(a pair of sentence)을 받을 수도 있도록 설계되었습니다. 토큰 임베딩을 위해서는 30,000개의 토큰 어휘를 가진 WordPiece 임베딩(Wu et al., 2016)을 사용하구요.

The first token of every sequence is always a special classification token ([CLS]). The final hidden state corresponding to this token is used as the aggregate sequence representation for classification tasks. Sentence pairs are packed together into a single sequence. We differentiate the sentences in two ways. First, we separate them with a special token ([SEP]). Second, we add a learned embedding to every token indicating whether it belongs to sentence A or sentence B. 

모든 시퀀스는 다음과 같이 구성되어 있습니다.

  • 첫 번째 토큰 : 분류 작업에 필요한 토큰 [CLS]
  • 첫 번째 문장(A) 토큰 
  • 가운데 [SEP] 토큰 : 두 문장을 구분하는 역할
  • 두 번째 문장(B) 토큰
  • 마지막 [SEP] 토큰 : 시퀀스의 끝을 알려주는 역할

이 때 각 토큰이 문장 A에 속하는지 B에 속하는지 구별할 수 있는 Segment Embeddings 작업이 추가 수행됩니다. 아래 Figure 2에서 이 부분을 조금더 시각화해서 살펴볼 수 있습니다. 

트랜스포머가 Token Embedding과 Positional Encoding 정보를 더해서 입력값을 완성하는 것에서 나아가 BERT는 Segment Embeddings까지 한 번 더 더해주게 됩니다. [SEP] 토큰을 기준으로 각 토큰이 문장 A에 속하는지 B에 속하는지를 구분하는 것이죠.

이 작업은 이어서 자세히 살펴볼 문장 예측(NSP)을 위해 사용됩니다.


3-1. Pre-training BERT

BERT의 Pre-training의 핵심 unsupervised-task, MLM과 NSP를 살펴봅시다. 

[1] Masked LM(MLM) : 마스킹된 언어 모델

In order to train a deep bidirectional representation, we simply mask some percentage of the input tokens at random, and then predict those masked tokens. (중략) In all of our experiments, we mask 15% of all WordPiece tokens in each sequence at random. In contrast to denoising auto-encoders (Vincent et al., 2008), we only predict the masked words rather than reconstructing the entire input.

MLM은 deep bidirectional representation을 가능하게 하기 위해서 input tokens의 15%를 무작위로 랜덤 마스킹하고, 그 부분을 예측하도록 학습을 시키는 과정을 의미합니다.

Although this allows us to obtain a bidirectional pre-trained model, a downside is that we are creating a mismatch between pre-training and fine-tuning, since the [MASK] token does not appear during fine-tuning. To mitigate this, we do not always replace “masked” words with the actual [MASK] token.

The training data generator chooses 15% of the token positions at random for prediction. If the i-th token is chosen, we replace the i-th token with (1) the [MASK] token 80% of the time (2) a random token 10% of the time (3) the unchanged i-th token 10% of the time. 

그런데, 문제가 있습니다. 우리가 downstream 태스크를 위해 BERT를 파인튜닝할 때, 파인튜닝용으로 준비한 데이터에 15%를 지켜 마스킹이 준비되어있기는 아무래도 어렵겠죠. 따라서 pre-training data와 fine-tuning 데이터가 서로 일치하지 않는 문제점이 발생할 수가 있습니다.

  1. 따라서 버트는 input tokens의 15%를 무작위로 선택한 다음, 선택된 i번째 토큰을
  2. 80% 확률로 진짜 마스크 토큰([MASK])으로 대체합니다. 예를 들어 '감자'를 [MASK]로 대체하고 이를 예측하도록 학습시킵니다.
  3. 10% 확률로 무작위 토큰으로 대체합니다. 예를 들어 '감자'를 '생선'으로 바꾸어 버립니다.
  4. 10% 확률로 원래 토큰을 그대로 둡니다. 예를 들어 '감자'를 그냥 그대로 '감자'로 둡니다.

부록 A - Additional Details for BERT

The advantage of this procedure is that the Transformer encoder does not know which words it will be asked to predict or which have been replaced by random words, so it is forced to keep a distributional contextual representation of every input token.

부록에 따르면 이러한 방법을 사용함으로써 인코더가 예측할 단어나 무작위로 대체된 단어를 알지 못해, 모든 입력 토큰의 문맥을 이해하도록 상황이 강제되는 이점이 있다고 합니다. 즉, 항상 [MASK] 토큰에만 의존하지 않고 문맥을 기반으로 예측할 수 있는 능력을 기르고, 파인튜닝 데이터에 [MASK] 토큰이 등장하지 않아도 문제가 없도록 설계한 것입니다.


[2] Next Sentence Prediction(NSP) : 다음 문장 예측

Many important downstream tasks such as Question Answering (QA) and Natural Language Inference (NLI) are based on understanding the relationship between two sentences, which is not directly captured by language modeling.

질의응답(QA)나 자연어 추론(NLI) 같은 작업에서는 '두 문장의 관계'를 파악할 필요성이 있습니다. 그런데 기존의 language modeling은 주로 다음 단어를 예측하거나, 문장 내에서 단어의 순서와 패턴을 학습하는 등의 작업을 중점적으로 수행합니다. 두 문장 사이의 관계를 파악하는 것과는 거리가 멀죠. 그래서 BERT는 QA나 NLI같은 다양한 downstream task를 수행할 수 있도록, 모델이 두 문장 사이의 관계를 파악할 수 있도록 pre-train을 시켰습니다.

In order to train a model that understands sentence relationships, we pre-train for a binarized next sentence prediction task that can be trivially generated from any monolingual corpus.

BERT에서 NSP(Next Sentence Prediction)는 입력 시퀀스 내에서 문장 A와 문장 B가 서로 연속된 문장인지 아닌지를 판단하는 이진 분류(binary classification) 작업입니다. 주어진 두 문장이 실제로 연속된 문장(True)인지, 아니면 무작위로 선택된 다른 문장(False)인지를 예측합니다. NSP는 monolingual corpus(단일 언어 코퍼스) - 한 가지 언어로 된 텍스트 데이터 집합 내에서 학습이 가능합니다.

이 때, 앞서 살펴본 [CLS] 토큰이 바로 다음 문장 예측(NSP)에 사용되는 토큰이구요. MLM의 경우 입력 시퀀스 내에서 선택된 15%의 토큰이 80%의 확률로 마스킹되지만, NSP의 경우 모든 입력 시퀀스에 대해서 100% 수행이 됩니다.

Specifically, when choosing the sentences A and B for each pretraining example, 50% of the time B is the actual next sentence that follows A (labeled as IsNext), and 50% of the time it is a random sentence from the corpus (labeled as NotNext). As we show in Figure 1, C is used for next sentence prediction (NSP).5 Despite its simplicity, we demonstrate in Section 5.1 that pre-training towards this task is very beneficial to both QA and NLI.
  • 50%의 입력 시퀀스는 실제로 문장 A와 문장 B가 연속된 문장으로 구성되며, 레이블 IsNext(True)를 가집니다.
  • 나머지 50%의 입력 시퀀스는 무작위로 선택된 문장으로 구성되며, 레이블 NotNext(False)를 가집니다.

부록 A - Additional Details for BERT

어찌 보면 굉장히 단순한 작업이지만, NSP 작업을 통해 BERT는 두 문장 간의 관계를 이해하는 능력을 학습하게 되고, 이로 인해 QA(Question Answering)나 NLI(Natural Language Inference)와 같은 다운스트림 작업을 더 잘 수행하게 된다고 하네요.

The NSP task is closely related to representationlearning objectives used in Jernite et al. (2017) and Logeswaran and Lee (2018). However, in prior work, only sentence embeddings are transferred to down-stream tasks, where BERT transfers all parameters to initialize end-task model parameters

이 부분은 - BERT의 NSP task가 이전 연구 (Jernite et al., 2017; Logeswaran and Lee, 2018)에서 영감을 받기는 했지만, 두 문장 사이의 관계를 파악하도록 CLI 토큰을 사용한 것은 BERT의 독창적인 아이디어다 - 정도로 이해하고 넘어가겠습니다.


3-2. Fine-tuning BERT

For each task, we simply plug in the task-specific inputs and outputs into BERT and finetune all the parameters end-to-end.

BERT의 파인튜닝(fine-tuning)은 사전 학습된 모델의 모든 파라미터를 end-to-end 방식으로 업데이트합니다.

At the input, sentence A and sentence B from pre-training are analogous to
(1) sentence pairs in paraphrasing,
(2) hypothesis-premise pairs in entailment,
(3) question-passage pairs in question answering, and
(4) a degenerate text-∅ pair in text classification or sequence tagging.

BERT의 사전 학습에서 사용된 문장 A와 문장 B의 구조는 파인튜닝을 통해 다양한 다운스트림 작업에 적용될 수 있어요. 예를 들어서,

  • Paraphrasing: 두 문장이 서로 같은 의미를 전달하는지 평가할 수 있습니다.
  • Entailment: 문장 A는 가설(hypothesis), 문장 B는 전제(premise)일 때, 이 두 문장이 포함 관계에 있는지 판단할 수 있습니다.
  • Question Answering: 문장 A는 질문(question), 문장 B는 답변을 포함한 문맥(context)일 때, 문맥에서 질문에 대한 답을 찾는 질의응답 작업을 수행할 수 있습니다.
  • Text Classification or Sequence Tagging: 문장 A는 텍스트, 문장 B는 빈 값(∅)일 때, 단일 문장 분류 작업이나 시퀀스 태깅 작업을 수행할 수 있습니다.

At the output, the token representations are fed into an output layer for tokenlevel tasks, such as sequence tagging or question answering, and the [CLS] representation is fed into an output layer for classification, such as entailment or sentiment analysis.

출력 단계에서는 2가지 작업이 이루어집니다.

  1. Token-Level Tasks (토큰 레벨 작업)
    • 입력 시퀀스의 각 토큰에 대해 BERT는 벡터 representation을 생성하고, 이 벡터는 각 토큰의 의미와 문맥 정보를 담고 있습니다. 이 representation이 출력 레이어로 전달되면  Sequence Tagging (시퀀스 태깅)이나 Question Answering (질문 응답) 등의 다운스트림 태스크를 수행할 수 있습니다.
      • Sequence Tagging (시퀀스 태깅): 예를 들어, 입력 문장에서 각 단어를 명사, 동사 등으로 태깅하는 작업입니다. "I am eating an apple"라는 문장이 주어지면, 각 단어에 대해 'I: PRON', 'am: VERB', 'eating: VERB', 'an: DET', 'apple: NOUN'와 같이 태깅합니다.
      • Question Answering (질문 응답): 문맥 내에서 질문에 대한 답을 찾는 작업입니다. 예를 들어,  "What is the color of the sky?"라는 질문이 주어졌을 때, "The sky is blue"라는 문맥에서 "blue"라는 단어를 찾아내는 작업입니다.
  2. Sentence-Level Tasks (문장 레벨 작업)
    • BERT에서 입력 시퀀스의 첫 번째 토큰인 [CLS] 토큰이 벡터로 변환되고, 이 벡터는 입력 시퀀스 전체를 대표하는 의미를 담고 있습니다. 이 CLS representation이 출력 레이어로 전달되면 entailment 또는 sentiment analysis 등의 문장 전체에 대한 예측을 수행할 수 있습니다.
      • Entailment (포함 관계 판단): 두 문장이 포함 관계에 있는지 판단하는 작업입니다. 예를 들어, "The cat is on the mat" (전제)와 "There is a cat on the mat" (가설)가 주어졌을 때, 포함 관계(True/False)를 예측합니다.
      • Sentiment Analysis (감정 분석): 입력 문장의 감정이 긍정적인지 부정적인지 분류하는 작업입니다. 예를 들어, "I love this movie"라는 문장이 주어졌을 때, 감정이 긍정적인지 부정적인지 예측합니다.
Compared to pre-training, fine-tuning is relatively inexpensive. 

사전 학습에 비해 파인튜닝은 상대적으로 굉장히 비용 효율적이고, 논문의 모든 결과는 단일 Cloud TPU에서 최대 1시간, GPU에서는 몇 시간 내에 재현할 수 있다고 하네요.



마무리

BERT 논문을 리뷰하면서 언어 모델의 사전 학습과 전이 학습이 자연어 처리에서 얼마나 중요한 역할을 하는지 깊이 이해할 수 있었습니다. 특히, BERT가 문맥을 이해하는 인코더를 구현하기 위해 양방향 아키텍처, MLM, NSP 작업을 적절히 고안하고 배치한 점이 무척 인상깊었어요. 자본과 기술력 뿐만 아니라 창의적이고 새로운 아이디어로 인공지능 혁신을 이어가는 모델들을 리뷰할 때마다 경이로움을 느끼게 됩니다.

저는 Long-Context를 처리해야 하는 LLM / QA task 솔루션 개발이라는 과제를 맡으면서 버트 논문을 리뷰하게 되었는데요. 최신 QA task들은 Long-Context를 처리하기 위해 버트 기반의 인코더 중심 모델뿐만 아니라 RAG 기술을 적용한 GPT 기반 디코더 중심 모델을 사용하고 있기도 한다는 사실을 알았습니다. 따라서 다음 논문 리뷰로는 OpenAI의 ChatGPT 초기 모델을 선정하여 구조를 파악해보려고 합니다. 

이것으로 BERT 논문 리뷰를 마치겠습니다. 읽어주셔서 감사합니다 :-)

https://smartest-suri.tistory.com/46

 

ASAC 빅데이터 분석 5기 | 8-10주차 회고 (+ 머신러닝 팀 프로젝트 발표와 디벨롭)

6-9주차는 머신러닝 팀프로젝트와 발표가 있었던 주간이었습니다. 저희 조는 이라는 주제로 프로젝트를 진행했고, 발표 이후 디벨롭 과정을 거쳐 라는 새로운 주제로 다가오는 7월 학회 발표를

smartest-suri.tistory.com

 

ASAC 5기에서 진행했던 머신러닝 팀 프로젝트를 디벨롭한 결과에 대해서 업데이트를 하고자 합니다.


서론

저는 본 프로젝트를 학술지로 작성하여 2024 한국컴퓨터정보학회 하계 학술대회에 제출하였고, 심사를 완료받은 뒤 지난 7월 12일 학술대회에서 구두 발표를 마쳤습니다. 최종적으로 본 학술지는 우수논문상을 수상하게 되었습니다. 본 프로젝트를 리드했던 팀장이자 학술지 제1저자, 발표자로서 무척 뿌듯합니다. 

'성과로 말할 수 있어야 한다'는 원칙의 중요성을 실감하고 있는 요즘입니다. 과정도 중요하지만, 결국 나를 가장 잘 어필할 수 있는 것은 탁월한 성과입니다. ASAC 5기에서 모두가 같은 시간동안 'ML 프로젝트'라는 같은 과제를 준비했고, 같은 날에 발표를 마쳤습니다. 그러나 그 중에 발표 이후 반성과 회고를 통해 프로젝트를 보완하고 더 나은 방향으로 디벨롭하여 학술지로 작성/발표한 뒤 우수논문상까지 이끌어낸 사람은 제가 유일합니다. 저는 본 프로젝트 경험을 통해 같은 시간과 과제가 주어졌을 때 몰입과 주인의식, 기술에 대한 깊은 이해와 응용력을 토대로 남들보다 더 탁월한 성과를 도출해낼 수 있다는 자신감을 얻었습니다. 제가 과연 어디에 첫 취직을 하게 될지는 모르겠지만, 나의 이런 열정과 노력을 관통하고 알아보는 사람이 이 세상에 누군가 한 명은 있을 것이라고 생각합니다 :-)

프로젝트의 주제는 다음과 같습니다. 

유튜브 쇼츠의 조회수 예측
- 뷰티 광고 콘텐츠를 중심으로

차별화

본 프로젝트는 4가지 키워드로 차별화를 확보했습니다. 첫째는 쇼츠, 둘째는 뷰티, 셋째는 광고(마케팅), 넷째는 국내입니다.

"유튜브 영상의 조회수 예측"은 아주 예전부터 꾸준한 연구가 이루어진 바 있는 스테디하고 좀 뻔한 주제입니다. 그러나 조사를 통해 이러한 연구들이 비교적 옛날(2010년대)에 주로 이루어졌으며, 따라서 2021년 7월 출시된 쇼츠의 조회수를 예측하는 모델이나 연구는 상대적으로 부족하다는 사실을 발견할 수 있습니다. 또한 쇼츠 중에서도 특히 '뷰티 광고' 콘텐츠를 타겟팅한 연구는 전무했습니다.

그래서 프로젝트를 이끌고 완성하기까지 많은 고난과 역경이 있었지만, 내가 전에 없던 새로운 연구를 수행해 낸다는 자체로 큰 동기부여가 되어 무탈히 성과를 도출해 낼 수 있었습니다.


성능 고도화

제가 프로젝트에서 가장 많은 시간을 할애한 파트는 예측 모델의 성능 고도화였습니다.

먼저 선행 연구를 조사한 결과 유튜브 영상의 조회수 예측 회귀 모델의 경우 최고 성능을 가진 모델의 평가 지표가 MSE 3.5 ~ 5.5 사이를 웃도는 것을 확인할 수 있었습니다. 그러나 제가 첫 baseline 모델링을 수행했을 때 별다른 하이퍼파라미터 튜닝을 거치지 않은 상태에서도 모델의 예측 성능이 MSE 2.0-3.0 사이를 기록하였고, 보팅이나 하이퍼파라미터 최적화, 피처엔지니어링 등을 거친 끝에 모델의 예측 성능을 MSE 1.36까지 극대화할 수 있었습니다.

우리의 연구 모델은 왜 이렇게 성능이 좋았을까? 돌아보면 두가지 이유를 생각해볼 수 있었습니다.

  • 첫째, 저는 국내/쇼츠/뷰티/광고 라는 4가지 키워드로 한정하여 데이터를 수집하였습니다. 이 과정에서 신뢰할 수 있는 YouTube Data API를 사용하였고, 수집 이후에는 일일히 하나씩 검토하며 garbage data가 없는지 필터링하는 과정을 거쳤습니다. 따라서 저는 제가 수집한 2,184개의 데이터가 '목적에 부합하는 진실된 데이터'라고 자신할 수 있습니다. 이렇게 비슷한 주제와 카테고리의 데이터만을 제대로 필터링하게 되면서 데이터 사이의 유사성이 잘 확보되었을 것이고, 따라서 예측 모델의 성능도 좋아졌을 것입니다.
  • 둘째, 피처엔지니어링을 통해 중요도나 기여도가 낮은 변수를 삭제 처리하여 최종적으로 18개의 변수만 남긴 했지만, 처음에 수집한 row data 변수는 30개가 넘었습니다. 저는 모든 변수를 일일히 하나씩 뜯어보면서 시각화하고 꼼꼼하게 분석하여 회귀 모델에 적합하도록 전처리과정을 거쳤습니다. 이상치 하나도 허투루 보지 않았고, 모델링 중간 중간 성능의 변화를 살피며 변수마다 다양한 방법으로 표준화와 정규화를 진행하였습니다. 이 과정에서 다양한 통계 지식을 활용하였고, 확률과 통계 공부를 열심히 해 둔 보람을 느꼈습니다.  

그러나 아무리 데이터를 신경써서 수집하고, 하이퍼파라미터 최적화를 하고, 피처 엔지니어링에 혼을 쏟아 부어도, 모델 성능이 결국엔 나아지지 않는 (슬픈) 경우를 저도 수없이 겪어보았기 때문에- 위와 같이 노력을 통해 빛을 발할 수 있는 기회가 주어진 것에 대해 무척 감사하고 있습니다.


학술지 작성

저는 프로젝트의 팀장으로서 책임감을 가지고 학술지 제1저자 역할을 맡아 처음부터 끝까지 학술지를 직접 작성했습니다. 쉬운 작업은 아니었지만, 원래부터 글을 읽고 쓰는 것을 좋아해서 한켠으로는 이런 고된 과정을 즐기기도 하였던 것 같습니다.

2024 한국컴퓨터정보학회 하계 학술대회에서는 투고 받는 학술지를 2쪽 또는 4쪽 분량으로 제한하고 있었습니다. 데이터 수집/추출부터 전처리, 시각화, 분석, 모델링, 피처엔지니어링 등 모든 과정에서 정말 많은 노력을 기울였고 할 말이 많았지만, 최종적으로 학술지를 2쪽으로 압축하여 작성하기로 결정했습니다. 본 프로젝트는 학문적 의의가 다분하고 선행 연구에 비해 우수한 성능을 확보했다는 점에서 가치가 충분합니다. 하지만, 생성형 AI 시대인 2024년을 사는 지금 "조회수 예측 회귀 모델"이라는 주제 자체는 매우 가벼울 수밖에 없습니다. 비록 주제는 가벼울지언정 그 과정에서 많은 것을 배웠다는 점에서 매우 자랑스럽기도 합니다. 어쨌든 저는 명확한 자기객관화를 통해 2쪽의 짧은 학술지에 함축적으로 필요한 내용을 선별적으로 담기로 결정했습니다. 나중에 기회가 되면 4쪽 학술지를 작성할 수 있는 헤비한 주제를 다룰 날도 오겠죠.

학술지 작성 과정에는 ASAC 권강사님께서 정말 많은 도움을 주셨습니다. 학회가 어떤 식으로 운영되고 학술지를 어떻게 써야하는지 경험이 전무했던 제가 명확한 글을 쓸 수 있도록 혼을 담아서 코칭을 해주셨습니다. 이 과정에서 명확한 기술적 커뮤니케이션의 중요성을 뼈저리게 배웠습니다. 특히 제가 타겟으로 하는 데이터 사이언티스트나 AI 엔지니어 직무는 기술에 대한 이해와 활용력도 무척 중요하지만, 다양한 유관 부서와 협업을 필요로 하는 만큼 명확한 기술적 소통 능력 역시 매우 중요합니다. 이번 학술지 작성을 통해서 내가 한 일이나 내가 이루어낸 성과에 대해서 담백하게 글로 전달하고 설명할 수 있는 능력을 조금 더 키울 수 있었습니다. 

권강사님 오늘도 감사합니다!


예측 결과

최종 선정한 모델을 통해서 예측한 결과는 포트폴리오에 포함되어 있으며, 유튜버 개인정보 보호를 위해 본 포스팅에는 공개하지 않기로 결정하였음을 양해 바랍니다. 본 프로젝트의 포트폴리오를 보고 싶으신 분들께서는 댓글 또는 niceonesuri@gmail.com 으로 소속과 목적을 말씀해 주시면 포트폴리오 링크를 전송해 드리도록 하겠습니다.


마무리

본 프로젝트를 통해 제가 얻은 가장 값진 자산은 '내 프로젝트'라는 주인의식을 가지고 누구보다 프로젝트에 몰입하여 탁월한 성과를 도출한 일련의 경험입니다. 뻔한 주제를 트랜디하게 각색하고 그 과정에서 이루어진 논문 조사와 트랜드 분석, 목적에 맞는 데이터 수집을 위해 새로운 API를 연구하여 정복했을 때의 짜릿함, 모델의 예측 성능을 조금이라도 더 끌어올리고자 무한히 반복했던 하이퍼파라미터 최적화, 인사이트 도출을 위해 수없이 뜯어본 변수들과 SHAP분석 플롯, 고작 2장짜리의 짧은 학술지를 고치고 고치고 고쳐서 최선의 결과물을 도출했던 집념까지-

이 모든것을 경험해볼 수 있게 해준 ASAC 5기의 시스템과 환경, 그리고 무한한 지원을 보내주신 권강사님께 무척 감사합니다. 이번 경험을 통해서 앞으로 더 어렵고 복잡한 주제의 프로젝트에 도전해보고 싶은 동력, 무엇이든 거뜬히 해낼 수 있다는 자신감을 얻었습니다.


다음 포스팅에서는 딥러닝 팀 프로젝트 회고로 돌아오겠습니다. 감사합니다.

 

 

저는 현재 트랜스포머 전체 구조를 코드화하는 작업중에 있습니다.

https://smartest-suri.tistory.com/48

 

딥러닝 | 트랜스포머(2017) 논문 리뷰 - Attention is all you need

[참고] 본 포스팅은 수리링 본인이 Attention is all you need 논문을 처음부터 끝까지 직접 읽으며 분석하고 리뷰하여 작성했습니다. 불펌 절대 금지! 본문 내용에 잘못된 부분이 있다면 댓글 달아주

smartest-suri.tistory.com


지난 논문 리뷰에서 살펴본 바와 같이 트랜스포머는 '위치 인코딩(positional encoding)'을 통해 통으로 받은 입력에 문맥 정보를 추가하는데요. 본 포스팅에서는 포지셔널 인코딩을 파이토치로 구현하는 과정에서

1.  제가 처음에 쓴 코드에 어떤 문제가 있었고
2.  그걸 어떻게 더 나은 방향으로 개선했으며
3.  개선한 최종 코드 결과물은 어떠한지

를 중점적으로 다루어 보겠습니다.


출처 :  https://arxiv.org/pdf/1706.03762

참고 문헌 목록


문제 1

먼저, 논문의 positional encoding 수식과 kaggle 참조 코드를 참조하면서 첫 코드를 작성했습니다. (내가 왜 그랬을까)

캐글 코드

먼저, 참조한 캐글 코드에서 빨간색 박스 부분이 잘못 되었음을 곧바로 인지했습니다. (여기서 바로 캐글 닫았어야 했는데)

논문 원문에서 발췌

포지셔널 인코딩 수식에 의하면,

  • 임베딩 벡터의 차원이 짝수일 때와 홀수일 때를 나누어 sin, cos에 각각 할당합니다.
  • 싸인 안에 분모값으로 들어가는 10,000의 지수는 짝홀이 짝을 맞춰 같은 값의 짝수로 할당됩니다.
  • 그런데 캐글 코드는 이 부분이 좀 지저분하고, 안 맞습니다.
  • for문을 보시면 range가 0부터 임베딩 벡터의 차원까지 step을 2로 건너뛰어 i 자체가 0, 2, 4, 6...과 같이 짝수로 할당이 됩니다. 이미 i값이 짝수인데, 지수를 보면 거기에 2를 또 곱해서 중복이 되어 4의 배수가 됩니다. 그리고 10,000의 지수에 짝홀이 짝을 맞춰 같은 값의 짝수로 할당되지 않고, cos의 지수가 더 크게 할당됩니다.

그런데 실제로 저렇게 수식을 잘못 썼다고 해도 사실 크리티컬한 성능의 차이는 없을것이라고 여겨진다는 코멘트를 받았습니다. 어쨌든 포지셔널 인코딩의 핵심이 position과 i값에 따라서 삼각함수로부터 다른 임의의 값을 뽑아내는 것에 있고, 그래서 저렇게 써도 어쨌든 비스무리하게 돌아는 갈 것이라는 것입니다. 하지만 논문의 수식을 100% 그대로 재현하고 싶은 저의 입장에서는 굉장히 거슬렸고요. 그래서 일단 아래와 같이 수정을 해주었습니다.

# 위치 인코딩(Positional Embedding)
class PositionalEncoding(nn.Module):
    def __init__(self, max_seq_len, d_model):
        """
        입력 - max_seq_len : input sequence의 최대 길이
              d_model : 임베딩 차원
        """
        super(PositionalEncoding, self).__init__()
        self.d_model = d_model
        
        pe = torch.zeros(max_seq_len, self.d_model) # 포지셔널 인코딩 벡터 -> 모든 자리에 초기값 0으로 설정
        for pos in range(max_seq_len):
            for i in range(0, self.d_model, 2): # 0, 2, 4... 
                pe[pos, i] = math.sin(pos / (10000 ** (i/self.d_model))) # 짝수 차원 -> 싸인 (0->0, 2->2..)
                pe[pos, i+1] = math.cos(pos/ (10000 ** (i/self.d_model))) # 홀수 차원 -> 코싸인 (1->0, 3->2, 5->4....)
        pe = pe.unsqueeze(0) # [max_seq_len, d_model] 차원 -> [1, max_seq_len, d_model] 차원으로 1차원 앞에 추가해줌 (예 : [6, 4] -> [1, 6, 4])
        # 해주는 이유 : input shape이 [batch_size, seq_len, d_model] 이기 때문이다!! (임베딩 결과값이랑 더해야되니깐 shape 맞춰주는거임)
        self.register_buffer('pe', pe) # pe 벡터를 buffer로 register : state_dict()에는 parameter와 buffer가 있는데, 그 중 buffer로 등록 -> 학습할때 update 되지 않도록 고정

삼각함수 안에 10000의 지수 부분을 전부 i로 바꾸어 주면서 논문의 수식과 통일을 시켜주었습니다.


문제 2

위에서 이미 신뢰를 잃어서 (ㅋㅋㅋㅋ) kaggle 코드를 꺼버리려고 했는데, 일단 forward까지만 참조를 해보자는 마음으로... forward 함수까지 작성을 해보았습니다. 1차 수정한 PositionalEncoding 클래스를 전체 보여드리겠습니다.

# 위치 인코딩(Positional Embedding)
class PositionalEncoding(nn.Module):
    def __init__(self, max_seq_len, d_model):
        """
        입력 - max_seq_len : input sequence의 최대 길이
              d_model : 임베딩 차원
        """
        super(PositionalEncoding, self).__init__()
        self.d_model = d_model
        
        pe = torch.zeros(max_seq_len, self.d_model) # 포지셔널 인코딩 벡터 -> 모든 자리에 초기값 0으로 설정
        for pos in range(max_seq_len):
            for i in range(0, self.d_model, 2): # 0, 2, 4... 
                pe[pos, i] = math.sin(pos / (10000 ** (i/self.d_model))) # 짝수 차원 -> 싸인 (0->0, 2->2..)
                pe[pos, i+1] = math.cos(pos/ (10000 ** (i/self.d_model))) # 홀수 차원 -> 코싸인 (1->0, 3->2, 5->4....)
        pe = pe.unsqueeze(0) # [max_seq_len, d_model] 차원 -> [1, max_seq_len, d_model] 차원으로 1차원 앞에 추가해줌 (예 : [6, 4] -> [1, 6, 4])
        # 해주는 이유 : input shape이 [batch_size, seq_len, d_model] 이기 때문이다!! (임베딩 결과값이랑 더해야되니깐 shape 맞춰주는거임)
        self.register_buffer('pe', pe) # pe 벡터를 buffer로 register : state_dict()에는 parameter와 buffer가 있는데, 그 중 buffer로 등록 -> 학습할때 update 되지 않도록 고정 
        
    def forward(self, x):
    	x = x * math.sqrt(d_model) # 워드임베딩 벡터에 √d_model 곱해줌 (논문 3.4장)
        seq_len = x.size(1) # 각 시퀀스가 몇개의 토큰인지 숫자를 뽑아냄 (max_seq_len이 6이라면 6 이하의 숫자일것)
        x = x + self.pe[:, :seq_len].to(x.device) # 길이 맞춰서 pe랑 더해줌!!!

처음엔 인지하지 못했는데, math가 좀 많이 쓰인 것이 보입니다. math.sin math.cos math.sqrt.......

math 대신 torch를 쓰면 어떨까요?

시간 측정해보기 (math)

time 라이브러리를 불러와서 소요되는 시간을 측정해보면, 1.10에 가까운 값이 나옵니다.

시간 측정해보기 (torch)

https://pytorch.org/tutorials/beginner/translation_transformer.html

 

Language Translation with nn.Transformer and torchtext — PyTorch Tutorials 2.3.0+cu121 documentation

Note Click here to download the full example code Language Translation with nn.Transformer and torchtext This tutorial shows: How to train a translation model from scratch using Transformer. Use torchtext library to access Multi30k dataset to train a Germa

pytorch.org

파이토치 닥스에서 Transformer Tutorial을 찾아내서, 코드를 비교해 봅니다.

아놔 첨부터 이거 볼걸. 확실히 다르네.

  • 파이토치 공식 닥스에서는 for문 대신 indexing을 활용하고 있으며
  • math 대신 torch.sin/torch.cos를 사용하고 있습니다.

파이토치 닥스 튜토리얼 코드로 작업에 소요된 시간을 측정해 비교해보니, 0.01이 나옵니다. 샘플로 돌려보기만 했는데도 약 100배의 속도 차이가 난다면, 실제로 모델을 만들었을때 얼마나 큰 성능 저하를 유발하게 될까요? 저는 이쯤해서 참조하고 있던 캐글 코드를 버리고, 파이토치 공식 닥스를 참조하면서 코드를 쓰기로 합니다. (ㅋㅋㅋㅋㅋ)

앞으론 바로바로 공식 문서부터 찾아보는 습관을... 어쨌든 코드의 신뢰도와 정확도, 효율성에 항상 의문을 가지고 바라봐야 한다는 좋은 교훈을 얻었으니, 삽질은 아니었다고 생각합니다 :-) .. 우는거 아님


최종 코드

그럼 이제 pytorch docs 페이지의 Positional Encoding 클래스를 참고해서 2차로 코드를 수정하려는데요.

근데 이번엔 저기 math.log()부분이 거슬려요. 미치겄네.  

여긴 왜 torch를 안쓰고 굳이 math를 썼을까요?
torch에는 log함수가 (설마) 없을까요?

https://docs.python.org/ko/3/library/math.html
https://pytorch.org/docs/stable/generated/torch.log.html

찾아 보니 있습니다.
다른 점이라고 하면, math.log나 torch.log나 똑같이 자연로그를 취해서 반환하는데,
math와 달리 torch는 tensor를 입력하고 tensor를 출력합니다.

확인해보니, torch.log()를 사용할 경우 텐서의 값을 뽑아주는 변환작업이 추가가 되어 오히려 math보다 더 비효율적이 됩니다.

이번에도 간단하게 작동 시간을 비교해 봤는데요.

값을 바로 넣지 못하고 tensor를 넣어준 다음 .item()을 사용해서 다시 그 값을 추출해야 하는 torch.log()보다 math.log()가 훨씬 빠른 것을 확인할 수 있습니다. 그런데 이제 또 거슬리는게(ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ)

아니 왜 토치랑 매스랑 똑같은 자연로그를 취해주는데 왜 결과값이 달라요?

여기까지 오니까 살짝 기빨려서 chatgpt-4o한테 물어봤습니다.

네 그렇다고 합니다. 공식 닥스에서 math를 쓴걸 보면 math를 써도 되나본데 맞느냐, torch랑 math랑 서로 값이 좀 다르더라도 성능에 큰 차이가 없는것이냐, 근데 너 어디서 찾아서 그렇게 대답하는거냐, 출처 밝혀라... 등등 집요하게 물어봤습니다.

그래서 결론은 그거 그렇게 별로 안중요하니까 그냥 파이토치 공식 닥스를 믿고 math.log()를 사용하면 된다는 것이었습니다. 이후 √d_model을 곱하기 위해 사용되는 math.sqrt()도 같은 이유로 torch 대신 사용됩니다. 텐서 연산이 아닌 간단한 스칼라 값을 계산할 때에는 math가 더 효율적일 수 있습니다. (개-운)

그럼 이제 다시 본론으로 돌아와서 진도좀 나갈게요. 공식 닥스 참고해서 다시 쓴 positional encoding 클래스입니다. 제가 편한대로 고쳐서 썼기 때문에 닥스 공식문서와 다른 부분이 많습니다.

# 위치 인코딩(Positional Embedding)
class PositionalEncoding(nn.Module):
    def __init__(self, d_model: int,
                       dropout: float,
                       maxlen: int = 5000,
                       device = None):
        super(PositionalEncoding, self).__init__()
        
        # 위치 정의 벡터
        pos = torch.arange(0, maxlen, device = device).reshape(maxlen, 1)
        # 위치에 곱해줄 값 정의
        den = torch.exp(-torch.arange(0, d_model, 2, device = device) * math.log(10000) / d_model)
        # 포지셔널 인코딩 벡터 초기값 설정 (모든 자리 0으로 시작)
        pe = torch.zeros((maxlen, d_model))
        # 포지셔널 인코딩 마지막 차원이 짝수일 때 (슬라이싱 0::2 -> 0부터 시작해서 스텝 2씩이니까 짝수)
        pe[:, 0::2] = torch.sin(pos * den) # 싸인함수
        # 포지셔널 인코딩 마지막 차원이 홀수일 때 (슬라이싱 1::2 -> 1부터 시작해서 스텝 2씩이니까 홀수)
        pe[:, 1::2] = torch.cos(pos * den) # 코싸인함수
        # 차원 추가
        pe = pe.unsqueeze(0) # 임베딩 결과값이랑 더해야되니깐 shape 맞춰주기
        
        self.dropout = nn.Dropout(dropout) # dropout 추가
        self.register_buffer('pe', pe) # pe 벡터를 buffer로 register : state_dict()에는 parameter와 buffer가 있는데, 그 중 buffer로 등록 -> 학습할때 update 되지 않도록 고정 
        
    def forward(self, x: torch.Tensor):
        seq_length = x.size(1) # 입력 시퀀스의 길이 반환
        pe = self.pe[:, :seq_length, :].expand(x.size(0), -1, -1) # 입력 시퀀스의 길이에 맞춰 위치 인코딩 텐서를 슬라이싱
        return self.dropout(x + pe)

(1) pos

먼저 maxlen = 20일 때 pos의 결과를 찍어보면, 다음과 같습니다.

[참고] 저는 모델링을 하면서 벡터가 머릿속에 바로바로 시각화가 안 되면, python IDLE을 켜가지고 이렇게 대충이라도 시각화를 해서 결과를 바로바로 확인하는 버릇이 있습니다. 그럼 좀더 머릿속에서 구체화가 빠르게 됩니다. 그냥.. 이런 간단한건 idle이 편하더라고요.

(2) den

maxlen = 20, d_model = 100일 때 den의 결과를 찍어보면, 다음과 같습니다.

안에 몇개의 값이 있을까요?

총 50개가 있습니다.

den = torch.exp(-torch.arange(0, emb_size, 2) * torch.log(10000) / d_model)
# 보기 편하게 device는 뺐음

den을 구하는 코드는 아래와 같이 하나씩 직접 손으로 써서 계산해서 이해했어요. 참고로 여기 쓰인 torch.exp() 함수는 입력값을 e를 밑으로 하는 지수함수에 대입해서 출력합니다.

참고 : https://pytorch.org/docs/stable/generated/torch.exp.html
[참고] emb_size = d_model 입니다. 제가 나중에 변수명을 바꿨습니다.

삼각함수 안에 들어가는 분모 부분을 den이라는 벡터로 효율적으로 표현하여 pos * den과 같이 아주 간단하게 나타내 주었습니다. 

        # 위치 정의
        pos = torch.arange(0, maxlen).reshape(maxlen, 1)
        # 위치에 곱해줄 값 정의
        den = torch.exp(-torch.arange(0, d_model, 2) * torch.log(10000) / d_model)
        # 포지셔널 인코딩 벡터 초기값 설정 (모든 자리 0으로 시작)
        pe = torch.zeros((maxlen, d_model))
        # 포지셔널 인코딩 마지막 차원이 짝수일 때 (슬라이싱 0::2 -> 0부터 시작해서 스텝 2씩이니까 짝수)
        pe[:, 0::2] = torch.sin(pos * den) # 싸인함수
        # 포지셔널 인코딩 마지막 차원이 홀수일 때 (슬라이싱 1::2 -> 1부터 시작해서 스텝 2씩이니까 홀수)
        pe[:, 1::2] = torch.cos(pos * den) # 코싸인함수

기존의 캐글 코드와 비교한다면 for - for 더블 iteration 없이 슬라이싱만으로 해당 수식을 표현하며 벡터 내적을 활용하므로, 계산이 훨씬 빠를 수밖에... 아니 캐글이 느릴수밖에 없습니다.


(3) sin, cos

pe의 shape을 찍어봤습니다. maxlen이 20이고 d_model를 100으로 두었으니, 당연히 (20, 100)이 나오네요.

        # 포지셔널 인코딩 마지막 차원이 짝수일 때 (슬라이싱 0::2 -> 0부터 시작해서 스텝 2씩이니까 짝수)
        pe[:, 0::2] = torch.sin(pos * den) # 싸인함수
        # 포지셔널 인코딩 마지막 차원이 홀수일 때 (슬라이싱 1::2 -> 1부터 시작해서 스텝 2씩이니까 홀수)
        pe[:, 1::2] = torch.cos(pos * den) # 코싸인함수

슬라이싱을 통해서 d_model, 즉 마지막 차원의 짝수 번째 요소와 홀수 번째 요소를 지정하면서, 원래 (20, 100)이였던 pe가 절반인 벡터 (20, 50) 두개로 나뉘었어요. 

pos의 크기는 (20, 1)이고 den의 크기는 (50)이므로 두개를 벡터 내적하면 (20, 50)의 쉐입이 나옵니다. 이 내적한 값에...

  • 싸인함수를 취해서 pe의 짝수 차원에 갈아끼워 줍니다.
  • 코싸인함수를 취해서 pe의 홀수 차원에 갈아끼워 줍니다.

shape이 같기 때문에 어렵지 않게 호로록 가능합니다.


(4) unsqueeze

마지막으로 unsqueeze를 통해서 나중에 임베딩 벡터와 더해줄 때 shape이 맞도록 해줍니다.


(5) forward

진짜 마지막으로 한번만 더 볼게요.

# 위치 인코딩(Positional Embedding)
class PositionalEncoding(nn.Module):
    def __init__(self, d_model: int,
                       dropout: float,
                       maxlen: int = 5000,
                       device = None):
        super(PositionalEncoding, self).__init__()
        
        # 위치 정의 벡터
        pos = torch.arange(0, maxlen, device = device).reshape(maxlen, 1)
        # 위치에 곱해줄 값 정의
        den = torch.exp(-torch.arange(0, d_model, 2, device = device) * math.log(10000) / d_model)
        # 포지셔널 인코딩 벡터 초기값 설정 (모든 자리 0으로 시작)
        pe = torch.zeros((maxlen, d_model))
        # 포지셔널 인코딩 마지막 차원이 짝수일 때 (슬라이싱 0::2 -> 0부터 시작해서 스텝 2씩이니까 짝수)
        pe[:, 0::2] = torch.sin(pos * den) # 싸인함수
        # 포지셔널 인코딩 마지막 차원이 홀수일 때 (슬라이싱 1::2 -> 1부터 시작해서 스텝 2씩이니까 홀수)
        pe[:, 1::2] = torch.cos(pos * den) # 코싸인함수
        # 차원 추가
        pe = pe.unsqueeze(0) # 임베딩 결과값이랑 더해야되니깐 shape 맞춰주기
        
        self.dropout = nn.Dropout(dropout) # dropout 추가
        self.register_buffer('pe', pe) # pe 벡터를 buffer로 register : state_dict()에는 parameter와 buffer가 있는데, 그 중 buffer로 등록 -> 학습할때 update 되지 않도록 고정 
        
    def forward(self, x: torch.Tensor):
        seq_length = x.size(1) # 입력 시퀀스의 길이 반환
        pe = self.pe[:, :seq_length, :].expand(x.size(0), -1, -1) # 입력 시퀀스의 길이에 맞춰 위치 인코딩 텐서를 슬라이싱
        return self.dropout(x + pe)

forward에서 바뀐 점은 다음과 같습니다

  1. 캐글 코드와 비교했을 때 √d_model를 곱해주는 코드를 기본 워드임베딩 클래스 모듈로 이동했습니다. 파이토치 공식 닥스를 참고하여 dropout이 추가되었습니다.
  2. 파이토치 공식 닥스와 비교했을 때 조금 더 여러줄의 코드로 나누어서 (스스로) 이해하기 편하게 작성했습니다.

캐글 코드
닥스 코드



포지셔널 인코딩 코드화! 여기까지입니다. 진짜 이거 하나를 이렇게 딥하게 팔줄은 저도 몰랐는데요. 확실히 인간은 삽질을 통해 발전하는게 맞다... 남의 코드 많이 들여다 보되... 절대로 믿지는 말아라... 특히 캐글..... 이라는 좋은 교훈을 얻었습니다.

time 모듈을 통해서 시간을 측정하고 계산 효율성을 판단하는 일도 재미있었습니다. 이렇게 해볼 수 있도록 힌트를 주신 SK플래닛 T아카데미 ASAC 5기 권강사님께 무한 감사의 말씀을 전하며.............(리스펙 그 잡채) 혹시라도 처음부터 끝까지 전부 다 읽어주신 분이 계시다면, 정말 감사합니다. :-)

포스팅 끝! 본문 코드 오류가 발견될시 꼭 댓글로 알려주세요. 

 

 

 

 


[번외] 임베딩 전체과정
# 위치 인코딩(Positional Embedding)
class PositionalEncoding(nn.Module):
    def __init__(self, d_model: int,
                       dropout: float,
                       maxlen: int = 5000,
                       device = None):
        super(PositionalEncoding, self).__init__()
        
        # 위치 정의 벡터
        pos = torch.arange(0, maxlen, device = device).reshape(maxlen, 1)
        # 위치에 곱해줄 값 정의
        den = torch.exp(-torch.arange(0, d_model, 2, device = device) * math.log(10000) / d_model)
        # 포지셔널 인코딩 벡터 초기값 설정 (모든 자리 0으로 시작)
        pe = torch.zeros((maxlen, d_model))
        # 포지셔널 인코딩 마지막 차원이 짝수일 때 (슬라이싱 0::2 -> 0부터 시작해서 스텝 2씩이니까 짝수)
        pe[:, 0::2] = torch.sin(pos * den) # 싸인함수
        # 포지셔널 인코딩 마지막 차원이 홀수일 때 (슬라이싱 1::2 -> 1부터 시작해서 스텝 2씩이니까 홀수)
        pe[:, 1::2] = torch.cos(pos * den) # 코싸인함수
        # 차원 추가
        pe = pe.unsqueeze(0) # 임베딩 결과값이랑 더해야되니깐 shape 맞춰주기
        
        self.dropout = nn.Dropout(dropout) # dropout 추가
        self.register_buffer('pe', pe) # pe 벡터를 buffer로 register : state_dict()에는 parameter와 buffer가 있는데, 그 중 buffer로 등록 -> 학습할때 update 되지 않도록 고정 
        
    def forward(self, x: torch.Tensor):
        seq_length = x.size(1) # 입력 시퀀스의 길이 반환
        pe = self.pe[:, :seq_length, :].expand(x.size(0), -1, -1) # 입력 시퀀스의 길이에 맞춰 위치 인코딩 텐서를 슬라이싱
        return self.dropout(x + pe)

# 워드 임베딩 -> 파이토치 nn.Embeding : https://wikidocs.net/64779
class TokenEmbedding(nn.Module):
    def __init__(self, vocab_size: int, d_model):
        super(TokenEmbedding, self).__init__()
        self.embedding = nn.Embedding(vocab_size, d_model)
        self.d_model = d_model
    
    def forward(self, tokens: torch.Tensor):
        # 토큰 임베딩에 √d_model 곱해주기 (논문 3.4장에 그러랍디다)
        out = self.embedding(tokens.long()) * math.sqrt(self.d_model)
        # self.long()는 self.to(torch.int64)와 같은 역할
        return out

# "트랜스포머 임베딩" 만들어주기 (임베딩 + 포지셔널 인코딩)
class TransformerEmbedding(nn.Module):
    def __init__(self, vocab_size, d_model, max_len, drop_prob, device):
        super(TransformerEmbedding, self).__init__()
        self.tok_emb = TokenEmbedding(vocab_size, d_model)
        self.pos_enc = PositionalEncoding(d_model, drop_prob, max_len, device)
        
    def forward(self, x):
        tok_emb = self.tok_emb(x)
        pos_enc = self.pos_enc(tok_emb) # 두개 더하는건 이미 pos에서 했음
        return pos_enc

잘 됐는지 테스트

if __name__ == "__main__":
    vocab_size = 10000
    d_model = 512
    max_len = 5000
    drop_prob = 0.1
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    
    model = TransformerEmbedding(vocab_size, d_model, max_len, drop_prob, device)
    input_tokens = torch.randint(0, vocab_size, (32,100)).to(device)
    output = model(input_tokens)
    print(output.shape)

output

확인완

현재 수강하고 있는 SK플래닛 T아카데미 ASAC 빅데이터 분석 & AI 전문가 양성과정 5기에서 기업 연계 프로젝트를 시작했습니다. 저는 국내 딥러닝 관련 스타트업 기업 팀에 참여해서 딥러닝 프로젝트를 진행하게 되었습니다. 주제는 Table Detection인데요.

이번 기업연계 프로젝트에서 저는 최신 논문을 직접 선정해서 리뷰하고 코드화, 서비스화를 할 예정입니다. 그래서 본격적인 프로젝트에 앞서, Microsoft가 2021년 발표한 PubTables-1m dataset 논문을 읽으면서 Table Detection의 전반적인 발전 흐름과 데이터 구성, 평가 지표에 대해서 짚어보는 시간을 가졌습니다.

논문을 읽으면서 궁금증이 생겨 DETR(Detection Transformer) 논문도 살펴보고, 트랜스포머 허깅페이스를 방문해 객체 탐지 pipeline으로 여러 가지 실험을 해보기도 했는데요. ( 포스팅 : https://smartest-suri.tistory.com/52  ) 이렇게 논문을 기반으로 딥러닝을 연구하고 아이디어를 확장해나가는 것이 되게..... 변태같이 재밌네요.........^__^

 

딥러닝 | Transformer Huggingface 탐방, pipline 가지고 놀기(객체 탐지)

Transformers지난번에 트랜스포머 논문을 처음부터 끝까지 열심히 읽고 리뷰를 포스팅했었습니다.https://smartest-suri.tistory.com/48 딥러닝 | Attention is all you need - 트랜스포머(2017) 논문 리뷰[참고] 본 포

smartest-suri.tistory.com

이번 PubTables-1m 논문 리뷰는 Table Detection의 발전 과정, 기본 아이디어와 중요 사항 등을 체크할 수 있었던 좋은 시간이었습니다. 그럼 리뷰 들어가겠습니다!



PubTables-1M: Towards comprehensive table extraction from unstructured documents
https://arxiv.org/pdf/2110.00061v3


0. 초록 Abstract

(중략) we develop a new, more comprehensive dataset for table extraction, called PubTables-1M. PubTables-1M contains nearly one million tables from scientific articles, supports multiple input modalities, and contains detailed header and location information for table structures, making it useful for a wide variety of modeling approaches.

(뭔들 아니겠냐만은...) Table detection task 분야에서 당시 가장 큰 문제가 되는 것은 '데이터'였고, 테이블 트랜스포머 팀은 그 해결방안으로 'PubTales-1M'(이하 펍테이블)이라는 데이터셋을 공개하는데요. 펍테이블은 과학 기사에서 추출한 약 1백만 여개의 테이블을 포함하며, multiple input modalities를 지원하는 데이터 셋입니다. 또한 표 구조에서 디테일한 header 정보나 위치 정보를 포함하고 있기 때문에 다양한 목적으로 사용될 수 있습니다.

It also addresses a significant source of ground truth inconsistency observed in prior datasets called oversegmentation, using a novel canonicalization procedure. We demonstrate that these improvements lead to a significant increase in training performance and a more reliable estimate of model performance at evaluation for table structure recognition. 

특히 이전 데이터셋에서 흔히 발생하는 과분할, 과세분화(oversegmentation) 문제를 해결했다고 하는데요. 이를 위해 새로운 정규화 절차(canonicalization procedure)를 도입했습니다. 결과적으로 학습이 잘되고 모델 성능이 좋더라!라고,,, 모든 연구 논문의 빼놓을 수 없는 주장을 하고 있습니다. (ㅋㅋㅋㅋ)

Further, we show that transformer-based object detection models trained on PubTables-1M produce excellent results for all three tasks of detection, structure recognition, and functional analysis without the need for any special customization for these tasks.

테이블 트랜스포머 팀은 여기서 약간은 애매한 닉값을 하는데, 트랜스포머를 기반으로 한 object detection 모델을 가지고 테이블 탐지, 구조 인식, 기능 분석이라는 3가지 과업을 아주 잘 수행했다고 합니다. 후에 얘기하겠지만, 그냥 갖다 써서 실험만 했거든요. 데이터만 새로 생성했고 아쉽게도 새로운 모델 구조까지 제안하지는않습니다. 그래서 테이블 트랜스포머,,,,,,라고 명명하긴 조금 짜치지 않나.. 하는 생각을 감히 하기도 했는데요. 그래도 이렇게 새로 구축한 데이터셋으로 학습한 최신 pre-trained model card를 배포하고 있으니 어쨌든 닉값을 하기는 합니다!

배우는 입장에서... 기존의 문제점을 해결하기 위해 새로운 모델 구조를 제안하는 것이 가장 혁신적, 혁명적으로 느껴지긴 합니다. 시간과 노력같은 비용뿐만 아니라 창의적인 아이디어에 재능까지 필요한 일이기 때문이죠. 하지만 때론 이렇게 '데이터의 품질'을 향상시키는 것또한 근본적인 문제 해결의 한가지 방안이 될 수가 있습니다. 어쩌면 모델 구조 변경보다 더 나은 획기적인 성능 향상을 불러 오기도 하죠. 데이터셋 논문을 굳이 리뷰하는 이유도 이와 같은 이유에서입니다. (아, 짜친다고 한거 취소할게요)


1. 서론 Introduction

The problem of inferring a table’s structure from its presentation and converting it to a structured form is known as table extraction (TE). (중략...) 

표의 구조를 추론하고 이를 구조화된 형태로 변환하는 문제를 표 추출(Table Extraction, TE)이라고 합니다. TE는 아래와 같이 세 가지 하위 작업으로 구성됩니다.

  • 표 탐지 (Table Detection, TD)
  • 표 구조 인식 (Table Structure Recognition, TSR)
  • 기능 분석 (Functional Analysis, FA)

Fig 2.

The primary advantage of DL methods is that they can learn to be more robust to the wide variety of table presentation formats. However, manually annotating tables for TSR is a difficult and time-consuming process [7]. 

딥러닝을 통한 TE는 다양한 표 형식을 탄탄(robust)하게 학습할 수 있다는 장점이 있습니다. 그런데 딥러닝 - 지도 학습(Supervised Learning) - 에는 labeling이 필요하잖아요? TSR(Table Structure Recognition)을 하려면 사람이 일일이 레이블링(annotating)을 해줘야 하는데, 이게 여간 번거로운 게 아니다 보니, 항상 데이터와 관련된 문제점이 존재했던 모양이에요. 아무래도 데이터 확보 자체가 쉽지 않았겠죠.

To overcome this, researchers have turned recently to crowd-sourcing to construct larger datasets [9,22,23]. These datasets are assembled from tables appearing in documents created by thousands of authors, where an annotation for each table’s structure and content is available in a markup format such as HTML, XML, or LaTeX.

문제 해결을 위해서 기존 연구진들은 크라우드 소싱이란 걸 활용해서 더 큰 데이터 세트를 구축했다고 합니다. 이렇게 만들어진 데이터셋은 각 표의 구조와 내용에 대한 annotation을 HTML, XML, LaTeX 같은 마크업 형식으로 제공하는 특징이 있는데요.

* 크라우드 소싱 

- 전문가가 풀지 못한 문제를 대중이 풀다
- 군중(crowd)과 아웃소싱(outsourcing)의 합성어
- 비용 효율적이고 빠른 문제 해결을 가능하게 함

- 생산과 서비스의 과정에 소비자 혹은 대중을 참여시켜 더 나은 아이디어, 제품, 서비스를 만들고 수익을 참여자와 공유하고자하는 방법을 칭한다. [참고 : 기획재정부 시사경제용어사전]
While crowd-sourcing solves the problem of dataset size, repurposing annotations originally unintended for TE and automatically converting these to ground truth presents its own set of challenges with respect to completeness, consistency, and quality. 

크라우드 소싱이 데이터셋의 사이즈 - 즉, 수량을 확보하는 데에는 도움이 되긴 했는데, 사실 이렇게 얻은 annotation이 애초에는 TE를 목적으로 한 게 아니었고, 그래서 TE용으로 적합하지가 않았습니다. 

Another significant challenge for the use of crowdsourced annotations is that these structure annotations encoded in markup often exhibit an issue we refer to as oversegmentation. Oversegmentation occurs in a structure annotation when a spanning cell in a header is split into multiple grid cells.

특히 oversegmentation(과세분화) 문제가 있었습니다. 과세분화는 header(머리글)의 셀이 여러 격자 셀로 나뉘는 경우를 말합니다. 과세분화가 발생해도 보기에는 문제가 없어보입니다만, 딥러닝 모델 학습과 평가를 위한 정답 데이터로 사용될 때는 큰 문제가 될 수 있습니다.

The first issue is that an oversegmented annotation contradicts the logical interpretation of the table that its presentation is meant to suggest. (중략....) would and does lead to ambiguous and inconsistent ground truth, due to there then being multiple possible valid interpretations for a table’s structure, such as in Fig. 3. This violates the standard modeling assumption that there is exactly one correct ground truth annotation for each table.

크게 표의 구조를 올바르게 해석하지 못하는 문제점, 표의 구조에 대해 여러 가지 해석을 내놓는 문제점이 발생할 수 있습니다. 그래서 ground truth가 애매 모호해지고, 1개의 정해진 정답이 있다는 가정을 위반하게 됩니다. 따라서 과세분화된 주석이 포함된 데이터셋은 학습 중 모순된 피드백을 제공하고 평가 시 실제 성능을 과소평가하게 만듭니다. 


PubTables-1M (주요 내용)

We introduce a novel canonicalization procedure that corrects oversegmentation and whose goal is to ensure each table has a unique, unambiguous structure interpretation.
  • 새로운 canonicalization(정규화) 방법을 통해 oversegmentation(과세분화) 문제를 바로잡고 하나의 테이블에 대해 명확한 하나의 구조 해석을 내놓도록 하는 방법을 보여준다고 합니다.
We show that data improvements alone lead to a significant increase in performance for TSR models, due both to improved training and a more reliable estimate of performance at evaluation.
  • (새로운 모델 구조 제안 없이) 좋은 데이터를 쓰는 것만으로도 TSR(Table Structure Recognition) 과업에 얼마나 큰 성능 향상을 보일 수 있는지 보여준다고 합니다.
Finally, we apply the Detection Transformer (DETR) [2] for the first time to the tasks of TD, TSR, and FA, and demonstrate how with PubTables-1M all three tasks can be addressed with a transformer-based object detection framework without any special customization for these tasks.
  • Detection Transformer(DETR)을 TD, TSR, FA 과업에 사용한 첫 사례로서, PubTables-1M 데이터랑 DETR이랑 같이 쓰면 얼마나 괜찮은지 보여준다고 합니다. (특별한 모델 변형이나 새로운 구조 제안은 없음 - 데이터셋이 중심이 되는 논문임을 확인)

2. 관련 연구 Related Work 

해당 부분은 chat-gpt 4o를 통해 간단히 정리만 하고 넘어가도록 하겠습니다.

구조 인식 데이터셋

  • ICDAR-2013: 표 탐지(TD), 표 구조 인식(TSR), 기능 분석(FA)을 모두 다루는 최초의 데이터셋으로, 규모는 작으며 TD와 TSR을 위한 248개의 표와 FA를 위한 92개의 표로 구성됨.
  • 대규모 데이터셋: TableBank, SciTSR, PubTabNet, FinTabNet 등은 크라우드 소싱 주석을 사용하여 확장되었으나, 여전히 완전성, 경계 상자, 과세분화 문제를 가지고 있음.

모델링 접근법

  • 일반 방법: 객체 탐지, 이미지-텍스트 변환, 그래프 기반 접근법 등이 사용됨.
  • 문제점: 포괄적인 훈련 데이터의 부족으로 인해 기존 모델의 성능이 낮음.
  • 특화된 방법: 다양한 경우를 처리하기 위해 맞춤형 파이프라인과 전문화된 방법이 사용되었으나, 아직 보편적이고 성능이 높은 솔루션은 존재하지 않음.

3. 데이터셋 PubTables-1M

마이크로소프트는 PubTables-1M 개발을 위해 수백만 개의 공개 과학 기사를 제공하는 PMCOA 코퍼스 선택했습니다. 각 기사는 기사 내용을 시각적으로 보여주는 PDF 파일, 내용 설명과 요소의 계층적 구성을 제공하는 XML 파일로 구성되는데요. 각 표의 내용과 구조는 표준 HTML 태그를 사용합니다.

  • 하지만 이것도 역시 애초에 TE 모델링의 레이블로 의도하고 만든 데이터가 아니었기 때문에
    • 예를 들어서 두 문서가 같은 표를 가지고 있을 때에 두 문서 간의 직접적인 대응 관계를 알 수도 없고, 문서 내에서 표의 공간적 위치도 특별히 제공되지 않는 한계점이 발생했습니다.
  • 또 품질 측면에서도 완벽하지 못했는데
    • 예를 들어서 주석이 달린 텍스트 내용이 PDF에 표시된 텍스트 내용과 정확히 일치한다고 보장할 수도 없었고, 가끔 일부 라벨(row header 등)은 아예 주석 처리되지 않기도 했습니다.

논문에서는 이러한 문제점을 어떻게 해결해서 PubTables-1M 데이터셋을 구축했는지 아래와 같이 단계별로 나누어 설명합니다.


[3. 1.] Alignment

PDF와 XML의 텍스트 내용을 일치시켜서 대응관계를 만들어주는 단계

  1. PDF 문서에 있는 모든 문자에 대해 bounding box 처리를 합니다. ([xmin, ymin, xmax, ymax]의 공간적 위치로 표시)
  2. XML 태그에서 표의 텍스트를 추출합니다. (예) coffee 추출
  3. PDF 문서의 bounding box를 합쳐 일치하는 문자열이 있는지 확인하고, 만약 있다면 align을 시켜줍니다. 이 때 Needleman-Wunsch 알고리즘을 사용합니다. (예) 합쳐서 coffee가 되는 부분이 있는지 확인 -> 발견시 align
  4. 이 과정을 통해 PDF 문서의 텍스트와 XML/HTML에서 추출한 표 내용 텍스트를 서로 pairing(alinging) 하게 됩니다.
Needleman-Wunsh 알고리즘 (간단히)
: 두 시퀀스를 비교하여 최적의 정렬을 찾는 알고리즘으로, 주로 생물유전학에서 사용됨. 본 데이터셋에서는 PDF 문서의 텍스트 시퀀스와 XML/HTML 텍스트 시퀀스를 정렬하여, 두 문서 간의 텍스트 대응 관계를 확립하고 각 텍스트의 위치 정보를 연결하기 위해 사용되었음.

[3. 2.] Completion

Alignment 이후 표 전체와 각 행, 열에 bounding box를 쳐서
spatial annotation(공간 주석)을 '완성(Completion)'하는 단계

  • 표 (entire table)
    • 표의 경계 상자는 모든 텍스트 셀 경계 상자의 합집합(union)으로 정의됩니다.
  • 행 (row)
    • 가로 : 각 행의 xmin과 xmax(가로 길이)는 표의 xmin과 xmax로 정의하여 모든 행이 동일한 수평 길이를 갖도록 합니다.
    • 세로 : 각 행의 ymin과 ymax(세로 길이)는 해당 행에서 시작하거나 끝나는 모든 셀의 텍스트 셀 경계 상자의 합집합으로 정의됩니다.
  • 열 (column)
    • 가로 : 각 열의 ymin과 ymax는 표의 ymin과 ymax로 정의하여 모든 열이 동일한 수직 길이를 갖도록 합니다.
    • 세로 : 각 열 n의 xmin과 xmax는 해당 열에서 시작하거나 끝나는 모든 셀의 텍스트 셀 경계 상자의 합집합으로 정의됩니다.
  • 그리드 셀 (grid cell)
    • 그리드 셀은 해당 셀의 행 경계 상자와 열 경계 상자의 합집합(교차 부분 - intersection)으로 정의됩니다. 빈 셀에도 그리드 셀이 정의됩니다.

[3. 3.] Canonicalization

oversegmentation을 바로잡는 단계

canonicalization을 검색하면 '정규화, 표준화, 정상화'정도로 번역이 되는데요. 제가 느끼기에 제일 직관적인 번역은 '정상화'라고 생각했습니다. 논문에 따르면, 간단히 말해 canonicalization이란 특정 조건 하에서 인접한 셀을 병합하는 것을 의미합니다. 아래 사진을 보면 이해가 빠른데요.

이렇게 과세분화된 셀(a)을 서로 병합해서 Canonical(b) - 정상, 표준으로 되돌려놓는 작업을 의미합니다. 

알고리즘 파트는 간단히만 정리하고 넘어가도록 하겠습니다.

example : Header

  • Wang 모델에 따르면 모든 표의 header(머리말)은 트리 구조를 따릅니다.
  • 표의 각 열(column)은 열 헤더 트리의 고유한 리프 노드에, 각 행(row)은 행 헤더 트리의 고유한 리프 노드에 해당합니다.
  • 열이나 행 헤더가 부분적으로 주석 처리되거나 주변에 빈 셀이 있는 경우 이를 확장하여 추가적인 열이나 행을 포함시키는 확장 단계를 거칩니다.
  • 열 헤더와 행 헤더의 인접한 빈 셀들을 재귀적으로 병합하여 일관성을 유지합니다.
  • 마지막으로, 부모 셀과 자식 셀 사이에 빈 그리드 셀이 있는 경우, 빈 셀을 자식 셀과 병합하는 등의 구조 보완 단계를 거칩니다.

[3. 4.] Limitation

3.3. canonicalization의 한계를 언급

  • Canonicalization의 목표는 모든 표 구조 주석에 적용 가능한 것이지만, Algorithm 1은 PMCOA 데이터셋의 주석을 위해 특별히 설계된 것으로, 다른 데이터셋의 표를 Canonicalization하려면 추가적인 가정이 필요하며, 이는 이 연구의 범위를 벗어남을 밝힘.
  • Canonicalization은 오류 없는 주석을 보장하지 않음.
  • Canonicalization 이후에도 남아 있는 문제들은 다음 단계인 3.5. Quality control을 통해 해결하고자 함.

[3. 5.] Quality control

품질 관리를 통해 데이터의 명확성과 일관성을 높이는 단계

  • overlapping rows or overlapping columns -> 데이터 폐기 처리
  • 원본 XML 주석과 PDF에서 추출한 텍스트 간의 편집 거리를 비교 -> 셀 평균 편집 거리가 0.05를 넘는 표는 오류로 간주하여 폐기
  • PDF 텍스트를 정답 데이터로 간주 -> 각 단어가 표 내부의 그리드 셀과 겹치는 비율이 0.9 미만인 표 폐기
  • 객체 수가 100개를 초과하는 표 폐기
  • PubTables-1M은 셀 수준에서 주석을 검증하여 일관성을 보장하는 최초의 데이터셋임을 강조

[3. 6.] Dataset statistics and splits

데이터 통계와 수를 나타내는부분으로, 자세한 리뷰 생략

  • TSR 데이터셋
    • 훈련용 표: 758,849개
    • 검증용 표: 94,959개
    • 테스트용 표: 93,834개
  • TD 데이터셋
    • 훈련용 페이지: 460,589개
    • 검증용 페이지: 57,591개
    • 테스트용 페이지: 57,125개

4. 제안 모델 Proposed Model

본 논문은 표의 구성 성분을 6개의 객체 클래스로 분류해서 이미지에서 찾아낸 다음 경계 상자로 표시하는 모델링을 수행했습니다. 6개의 객체 클래스는 아래와 같습니다.


  1. table 표 전체
  2. table row 행
  3. table column 열
  4. table column header 행 헤더(머리말)
  5. table projected row header 열 헤더(머리말)
  6. table spanning cell 확장 셀
  7. The intersection of each pair of table column and table row objects can be considered to form a seventh implicit class, table grid cell. -> 모든 행렬의 교차 부분은 'grid cell'이라는 7번째 추가 object로 간주할 수 있음

Fig 4. 클래스 6개 예시


To demonstrate the proposed dataset and the object detection modeling approach, we apply the Detection Transformer (DETR) [2] to all three TE tasks. We train one DETR model for TD and one DETR model for both TSR and FA. For comparison, we also train a Faster R-CNN [16] model for the same tasks.

TD, TSR+FA 2가지 과업에 DETR을 이용해 학습을 시키고, Faster-R CNN과 성능을 비교합니다.

All models use a ResNet-18 backbone pretrained on ImageNet with the first few layers frozen. We avoid custom engineering the models and training procedures for each task, using default settings wherever possible to allow the data to drive the result.
  • 모든 모델은 ResNet-18 사전학습 백본 사용, 초반의 몇 레이어는 freeze
  • 모델 커스터마이징을 안 한 이유 : 데이터가 결과를 주도하도록 하기 위해 (이해는 하지만 개인적으로 아쉬운 부분)

5. 실험 Experiments

모델링 결과

Faster R-CNN, DETR 성능을 비교해 본 결과 DETR의 성능가 더 좋았으며, Canonical 데이터의 중요성을 강조하고 있습니다.

평가 지표 (metrics)
For assessing TSR performance, we report the table content accuracy metric (AccCont), which is the percentage of tables whose text content matches the ground truth exactly for every cell, as well as several metrics for partial table correctness, which use different strategies to give credit for correct cells when not all cells are correct. For partial correctness, we use the F-score of the standard adjacent cell content metric [5] and the recently proposed GriTS metrics [19].
  • TSR 성능 평가 : 각 셀의 텍스트 내용이 정답과 정확히 일치하는 표의 비율 AccCont
  • 부분적 정확도 평가 : 표준 인접 셀 내용 지표의 F-score와 최근 제안된 GriTS 지표
GriTS

GriTS(이하 그리츠) 수식을 확인해 보니, F-score와 같은 것이라고 해석해도 무방할 것 같습니다. Image Segmentation에서 자주 사용되는 Dice Score와도 일맥상통하구요. 

Compared to other metrics for TSR, this formulation better captures the two-dimensional structure and ordering of cells of a table when comparing tables. 

다른 지표랑 비교했을 때 그리츠는 표를 비교할 때 표의 이차원 구조와 셀의 순서를 더 잘 반영하고, 기타 등등 여러 장점이 있다고 하네요. 

AccCont

AccCont는 논문에 수식이 따로 없길래, gpt 4o한테 수식을 써달라고 했더니, 이렇게 써주네요 (ㅋㅋㅋㅋㅋ) 나름 직관적입니다. 일반적인 Accuracy(정확도) 개념과 거의 유사하다고 봐도 무방할 것 같습니다.


6. 결론 Conclusion

  • 비정형 문서의 표 추출을 위한 새로운 데이터셋인 PubTables-1M을 소개
  • 과세분화 문제를 해결하기 위해 새로운 정형화 절차를 제안, 이를 통해 모델 성능이 크게 향상됨을 입증
  • DETR을 사용하여 표 추출 작업에서 최첨단 성능을 달성할 수 있음을 보임

(7장 Future Work는 생략하겠습니다.)




마무리

https://github.com/microsoft/table-transformer

 

GitHub - microsoft/table-transformer: Table Transformer (TATR) is a deep learning model for extracting tables from unstructured

Table Transformer (TATR) is a deep learning model for extracting tables from unstructured documents (PDFs and images). This is also the official repository for the PubTables-1M dataset and GriTS ev...

github.com

테이블 트랜스포머 팀 최신 뉴스(깃허브)에 따르면, PubTables-1M과 다른 데이터셋을 가지고 학습한 pre-trained model TATR-v1.1을 공개했다고 밝히고 있습니다. (2023년 8월 22일자)

https://huggingface.co/bsmock/TATR-v1.1-All

 

bsmock/TATR-v1.1-All · Hugging Face

Model Card for TATR-v1.1-All This repo contains the model weights for TATR (Table Transformer) v1.1, trained on the PubTables-1M and FinTabNet.c datasets, using the training details in the paper: "Aligning benchmark datasets for table structure recognition

huggingface.co

해당 허깅페이스 페이지에 가면 모델 카드를 확인하실 수 있는데요.

추후 해당 모델을 사용해서 직접 Table Detection을 실행해본 뒤 추가 포스팅을 하도록 하겠습니다.



이번 테이블 트랜스포머 논문 리뷰를 통해 Table Detection을 수행할 때 필요한 데이터의 특성, 레이블링 방법, 생소한 평가지표까지 전체적인 흐름을 파악할 수 있었습니다. 본 논문은 2021년 발표된 '데이터셋' 중심 논문인 만큼, 다음 번에는 새로운 '모델 구조'를 제안하는 최신 논문을 리뷰하고 코드로 구현하는 작업을 수행해보고자 합니다.

수리링의 Table Detection 정복 과정을 지켜봐 주세요!

감사합니다 :-)

Transformers

지난번에 트랜스포머 논문을 처음부터 끝까지 열심히 읽고 리뷰를 포스팅했었습니다.

https://smartest-suri.tistory.com/48

 

딥러닝 | Attention is all you need - 트랜스포머(2017) 논문 리뷰

[참고] 본 포스팅은 수리링 본인이 Attention is all you need 논문을 처음부터 끝까지 직접 읽으며 분석하고 리뷰하여 작성했습니다. 불펌 절대 금지! 본문 내용에 잘못된 부분이 있다면 댓글 달아주

smartest-suri.tistory.com


오늘은 Transformer팀의 Huggingface 페이지를 방문해서 트랜스포머의 무궁무진한 발전가능성을 체험해보겠습니다.

https://huggingface.co/docs/transformers/task_summary

 

What 🤗 Transformers can do

Reinforcement learning models

huggingface.co

위 링크를 클릭하시면 Transformer huggingface 다큐멘테이션 페이지 중에서 Conceptual Guidelienes - What 🤗 Transformers can do 코너로 이동하는데요.

설정에서 언어를 KO로 바꾸시면 한국어로도 보실 수 있습니다. 번역이 어색해서 저는 영어로 봅니다.

트랜스포머는 자연어처리(NLP), 컴퓨터 비전, 오디오 및 음성 처리 작업에 사용할 수 있는 pre-trained된 최첨단(SoTA) 모델 라이브러리라고 밝히고 있습니다. 이 페이지에서 간단하게 트랜스포머의 멋진 기능을 체험해볼 수 있는 짧은 코드를 소개하고 있습니다. (보시면 아시겠지만. ... 짱 쉬워요)

이렇게 pipeline을 사용해서 pre-trained된 트랜스포머 모델의 여러 기능을 체험해볼 수 있도록 다양한 코드가 제공되고 있었어요. 저는 이 파이프라인 중에서 객체 탐지 기능을 골라 가지고 놀면서 기능 맛보기를 통해 트랜스포머와 더욱 친숙해지는 시간을 가져보았습니다.


파이프라인이란?

허깅페이스 파이프라인(Hugging Face Pipelines)은 자연어 처리(NLP) 작업을 쉽게 수행할 수 있도록 허깅페이스에서 제공하는 API입니다. 이 API를 사용하면 복잡한 모델 로드 및 전처리 작업 없이도 다양한 NLP 작업을 빠르고 간편하게 수행할 수 있습니다.

  •  pipeline은 transformers 라이브러리의 가장 기본 객체
  •  사용 전에 transformers 라이브러리 설치해야 함
!pip install transformers
from transformers import pipeline

객체 탐지 실험

여러 개의 pipeline 실험 중 객체 탐지를 골라서 포스팅하게 된 이유는, 제일 재밌었기 때문 + Table Detection 프로젝트를 앞두고 객체 탐지와 조금 더 친숙해지고자 하는 목적입니다.

1. 크리스마스 사진

트랜스포머 객체탐지 기능은 이미지 속에 있는 객체를 탐지해서 score(확률), label(객체 명), box(위치 pixel) 3가지 결과값을 반환합니다. 어떤 사진을 고를까 고민하다가 저는 이 정신없는 크리스마스 사진을 고르게 되었습니다.

출처 : 나무위키

일부러 좀 정신없는 사진을 골라봤어요. 트랜스포머도 과연 저처럼 이 사진이 정신없다고 생각할까요? 그리고 과연 이렇게 정신없는 사진의 객체를 몇개나, 얼마나 정확하게 탐지할 수 있을까요?

import requests
from PIL import Image

# 이미지 데이터 가져오기
url = "https://i.namu.wiki/i/--GbZ0ptaE0KF8OgUej9I_SN4erfOc_ueyHgtJipMB0scNAJRSio6uWMcFviEGKO0d0qSqwWhla7xGfiB5NYoQgAPSmh8TQW1AAuYljDuveZiAwd8kcbOV4mFFpCVz6CMZ9cBBym3rPK19df_Blbhw.webp"
image_data = requests.get(url, stream=True).raw
image = Image.open(image_data)

# 이미지 확인하기
import matplotlib.pyplot as plt
plt.imshow(image)
plt.axis('off')
plt.show()

먼저 이미지 데이터를 불러오고 matplotlib을 통해 이미지를 확인해 보았습니다. 원하는 다른 이미지가 있으시면 image url을 대체해서 사용하시면 됩니다. 이미지는 어떻게 생겼는지 위에서 이미 보여드렸으니 결과값은 생략하고 넘어갈게요.

# 객체 탐지
from transformers import pipeline
detector = pipeline(task = 'object-detection') # 객체 탐지 task 설정
preds = detector(url) # 객체 탐지 실행
preds = [{"score": round(pred["score"], 4),  
          "label": pred["label"],
          "box": pred["box"]} for pred in preds] # 탐지 결과 보기좋게 차례대로 딕셔너리/리스트화

# 결과 확인
print(f"총 {len(preds)}개의 객체가 탐지되었습니다!")
for pred in preds:
	print(pred)

위와 같이 간단하게 pipeline을 이용해서 객체 탐지를 실행합니다. 사진 속에 여러개의 객체가 탐지될 경우 알아보기 쉽게 score, label, box 결과값을 하나의 딕셔너리로 묶어준 다음, 리스트화 합니다. for문을 사용해서 preds 안에 어떤 결과가 있는지 하나씩 프린트를 해봤는데요.

짜잔! 이렇게 총 12개의 객체를 탐지한 것을 확인할 수 있었습니다. label을 확인해 보니, 시계도 있고 고양이도 있고 말도 있고 식물도 있고... 이렇게 텍스트로 결과를 확인하면 직관적이지 못하니, 가지고 있는 이미지 위에 score, label, box값을 그려서 결과를 확인해 보겠습니다.

from matplotlib.patches import Rectangle

plt.figure(figsize = (15,8))
fig, ax = plt.subplots(1)
ax.imshow(image)
for pred in preds:
    box = pred["box"]
    label = pred["label"]
    score = pred["score"]
    xmin, ymin, xmax, ymax = box["xmin"], box["ymin"], box["xmax"], box["ymax"]
    width, height = xmax - xmin, ymax - ymin
    # 사각형 만들기
    rect = Rectangle((xmin, ymin), width, height, linewidth=2, edgecolor='r', facecolor='none')
    # 사각형 추가하기
    ax.add_patch(rect)
    # 레이블 추가하기
    plt.text(xmin, ymin, f'{label} ({score})', bbox=dict(facecolor='yellow', alpha=0.5))

plt.axis('off')
plt.show()

matplotlib의 patches중 Rectangle 기능을 이용해서 간단하게 사각형을 그려보았어요. 결과는 다음과 같습니다.

벽장에 있는 시계(저는 있는줄도 몰랐음), 구석에 있는 말(역시 눈에 안띄어서 잘 몰랐음), 뒤에 있는 사람, 아래 있는 고양이, 식물, 테이블, 꽃병... 정말 잘 감지한 것을 확인할 수 있었습니다. 기대 이상으로 성능이 좋아서 무척 놀라웠어요. 

preds = sorted(preds, key = lambda x: x['score'], reverse = True)[:5]

이 번엔 요렇게 preds 리스트를 score value값을 기준으로 내림차순 정렬해서 top 5개만 남기고 플롯을 그려봤습니다.

역시 고양이가 최고..! 0.9995의 score로 가장 높은 탐지 스코어를 기록했습니다.


2. 겨울왕국 포스터

이번엔 새로운 사진으로 바꿔봤는데요. 겨울왕국 포스터를 가져와 봤습니다. url만 갈아 끼우면 되니 코드는 이번에는 생략하고 결과만 바로 보여드리겠습니다.

아쉽게도 이번엔 크리스마스만큼 좋은 결과를 보이지 못했네요. 4개의 객체를 탐지했는데 대체적으로 score가 낮은 편입니다. 왼쪽 아래에 있는 괴물도 person으로 탐지를 했고, 오른쪽 아래에 있는 순록(?)은 아예 탐지가 되지 않았습니다. 

이렇게 예측 결과가 좋지 못한 경우를 발견했을 때 그 이유는 무엇인지, 비슷한 다른 사례로 일반화가 가능한지, fine-tuning하여 발전시킬 수 있을지를 생각해 보면서 프로젝트로 디벨롭해 나가면 좋겠다는 생각이 들었습니다. 


3. 치맥 사진

이번엔 아래 치맥 사진을 이용해서 객체탐지를 실행해 보겠습니다. 저는 가장궁금했던 것이,

  1. 여러 개의 치킨 조각을 하나로 '치킨'이라는 음식으로 탐지할 수 있을까?
  2. 잔에 담긴 액체를 보고 '맥주'라고 추론할 수 있을까?
  3. 큰 기대는 없긴 한데, 후라이드랑 양념치킨 구분은 안되겠지..? 였어요.

출처 : 나무위키에 치맥 검색

이쯤 되면 똑같은 코드를 계속 치기 번잡하니 함수화를 해줘야 국룰입니다. url을 넣으면 몇개의 객체가, 어떤 객체가 탐지되었는지 print를 하고, 결과를 그려 plot을 보여주는 함수로 만들어 보았습니다.

def object_detect_plot(url):
    image_data = requests.get(url, stream=True).raw # 이미지 데이터 가져오기
    image = Image.open(image_data)
    detector = pipeline(task = 'object-detection')
    preds = detector(url)
    preds = [{"score": round(pred["score"], 4), "label": pred["label"], "box": pred["box"]} for pred in preds]

    print(f"총 {len(preds)}개의 객체가 탐지되었습니다!")
    for pred in preds:
        print(pred)
    
    plt.figure(figsize = (15,8))
    fig, ax = plt.subplots(1)
    ax.imshow(image)
    for pred in preds:
        box = pred["box"]
        label = pred["label"]
        score = pred["score"]
        xmin, ymin, xmax, ymax = box["xmin"], box["ymin"], box["xmax"], box["ymax"]
        width, height = xmax - xmin, ymax - ymin
        # 사각형 만들기
        rect = Rectangle((xmin, ymin), width, height, linewidth=2, edgecolor='r', facecolor='none')
        # 사각형 추가하기
        ax.add_patch(rect)
        # 레이블 추가하기
        plt.text(xmin, ymin, f'{label} ({score})', bbox=dict(facecolor='yellow', alpha=0.5))

    plt.axis('off')
    plt.show()
chicken_n_beer = "https://i.namu.wiki/i/2JQMZZIxjIeZpag74qgmIQvBrS9gcBy-w_iTkHgQ34V8pS63SaWqUTgnMZGxJykuwBdXXPLUr6IRv7jCsLnQlVI-t6L37ZTo3CLlGIaCjDnnThCMtCzm4l1QjC2wLva-mkj4CqNtE716a1mERKcn5A.webp"
object_detect_plot(chicken_n_beer)

결과는 아래와 같습니다.

  1. 여러 개의 치킨 조각을 하나로 '치킨'이라는 음식으로 탐지할 수 있을까? -> 개별 치킨을 도넛으로 인식함(ㅋㅋㅋㅋ)
  2. 잔에 담긴 액체를 보고 '맥주'라고 추론할 수 있을까? -> 그냥 컵으로 인식함
  3. 큰 기대는 없긴 한데, 후라이드랑 양념치킨 구분은 안되겠지..? 였어요. -> 도넛부터 해결을 해야....
  4. + 다이닝 테이블에 대한집착...

네.. 생각보다 결과가 좋지 못했습니다. fried chicken이라는 label 자체가 없는 것으로 판단이 되는데, 하나의 치킨 조각을 도넛으로 인식을 하고, 여러 개의 치킨이 담겨 있는 전체를 하나의 음식 객체로 탐지하지도 못하는 것 같아요.


결론

이미지 객체 탐지 (Image Object detection)를 실행해 본 결과, 성능이 무척 좋긴 하지만 명확한 한계점이 여러 가지 보였습니다. 이러한 한계점은 파인튜닝 또는 새로운 SoTA 모델 연구를 통해서 극복할 수 있겠죠? 딥러닝 관련 프로젝트 주제를 선정할 때 이렇게 기존 SoTA 모델의 파이프라인으로 여러 가지 실험을 해보면서 프로젝트 방향을 설정한다면, 좋은 아이디어가 빠르게 도출될 수 있을 것 같습니다.

재미있었던 트랜스포머 파이프라인 가지고놀기 실험은 이것으로 마무리 하겠습니다 :-)

다음 포스팅에서는 트랜스포머 모델 PyTorch 구현을 해보도록 할게요. 감사합니다!

 

 

 

 

Dice Score

U-Net 코드화 작업을 통해 image segmentation에서 흔히 사용되는 'dice score'라는 평가 지표와 친숙해지게 되었습니다. 다이스 스코어는 아래와 같은 수식을 통해 계산합니다.

Dice Score는 Precision과 Recall 점수를 나타내는 F1-Score와 비슷합니다. 민감도와 정밀도중에 무엇이 좋고 나쁜지는 까봐야 아는 것도 똑같고요.

도식화하면 위와 같은 그림으로 나타낼 수 있어요. 여기서 X와 Y는 각각 true value와 predicted value라고 생각하면 되겠습니다. Image segmentation에서 Dice score는 predicted value와 true value 간의 유사성을 측정할 수 있는 포괄적인 평가 방식인 거죠. 

예측한 마스크와 실제 마스크가 정확하게 일치하는 경우 dice score는 1의 값을 가지게 되고, 전혀 일치하지 않는 경우 0의 값을 가지게 됩니다. 따라서 계산된 dice score는 범위 [0, 1] 사이의 값을 가지게 되고, 마치 accuracy처럼 1에 가까운 값을 가지게 될수록 모델이 학습을 잘했다고 판단하게 됩니다.


문제 상황

그런데 코드 작업을 하면서, 아래와 같이 dice score가 범위 [0, 1]를 벗어난 값을 가지게 되는 것을 확인하게 되었습니다.

dice score가 -7부터 1.3까지.. 다이나믹 한 값을 가지는군요

https://www.kaggle.com/datasets/faizalkarim/flood-area-segmentation

 

Flood Area Segmentation

Segment the flooded area.

www.kaggle.com

저는 U-Net을 코드 구현하면서 kaggle의 Flood Area Segmentation 데이터를 가지고 작업중이었는데요. Kaggle에서 다른사람들이 쓴 코드는 어떤지 확인을 한 번 해 보았습니다.


다른 사람 코드 1

https://www.kaggle.com/code/gon213/flood-area-by-gontech

같은 Flood Area Segmentation 이미지셋으로 코딩하는 다른사람 코드

Dice score가 0, 1 사이로 제대로 나오고 있군요.

다른 사람 코드 2

https://www.kaggle.com/code/hugolearn/practice-image-segmentation-with-u-net/notebook

Carvana 데이터셋으로 코딩하는 다른사람 코드

요건 저랑 다른 데이터셋 (Carvana dataset) 사용하는 다른사람 코드인데, 보시면 dice score가 음수값이 나옵니다. 더 보면 -5가 나오기도 하고요... 그런데 다들 코드 잘썼다고 칭찬만 하고 이부분을 지적하는 사람이 없더라고요.


문제 해결

Dice score를 정확하게 계산하기 위해서 저는 아래와 같은 방법을 적용해서 문제를 해결했습니다 :) 참고로 제가 다루고 있는 데이터는 '홍수가 난 부분'과 '아닌 부분'으로 binary label을 가집니다.


1. 수식 변경

def dice_score(pred: torch.Tensor, mask: torch.Tensor, threshold: float = 0.5, smooth: float = 1e-6):
    # 시그모이드 적용 후 임계값을 통해 이진화
    pred = torch.sigmoid(pred)
    pred = (pred > threshold).float()
    
    # 마스크가 float 타입인지 확인
    mask = mask.float()
    
    # 교집합과 합집합 계산
    intersection = (pred * mask).sum(dim=[1, 2, 3])
    union = pred.sum(dim=[1, 2, 3]) + mask.sum(dim=[1, 2, 3])
    
    # Dice score 계산
    dice = (2. * intersection + smooth) / (union + smooth)
    
    # 배치의 평균 Dice score 반환
    return dice.mean().item()

처음에는 intersection과 union만 계산했는데, 0.5의 threshold와 smooth값을 추가해서 dice score를 정밀하게 계산했습니다.


2. 데이터 정규화

먼저 가지고 있는 Image와 Mask 데이터의 픽셀값이 0과 1 사이의 수치로 들어와 있는 것을 확인했습니다. 이후 Image data에만 Normalization transform을 적용해주었습니다. (기존에는 정규화를 아예 안했습니다.)

class Mydataset(Dataset):
    def __init__(self, root_dir='flood/', train=True, image_transforms=None, mask_transforms=None):
        super(Mydataset, self).__init__()
        self.train = train
        self.image_transforms = image_transforms
        self.mask_transforms = mask_transforms
        
        # 파일 경로 지정
        file_path = os.path.join(root_dir, 'train')
        file_mask_path = os.path.join(root_dir, 'masked')
        
        # 이미지 리스트 생성
        self.images = sorted([os.path.join(file_path, img) for img in os.listdir(file_path)])
        self.masks = sorted([os.path.join(file_mask_path, mask) for mask in os.listdir(file_mask_path)])
        
        # train, valid 데이터 분리
        split_ratio = int(len(self.images) * 0.8)
        if train: 
            self.images = self.images[:split_ratio]
            self.masks = self.masks[:split_ratio]  # train은 80%
        else:
            self.images = self.images[split_ratio:]
            self.masks = self.masks[split_ratio:]  # valid는 20%
            
    def __getitem__(self, index: int):
        original = Image.open(self.images[index]).convert('RGB') # index 번째의 이미지를 RGB 형식으로 열음
        masked = Image.open(self.masks[index]).convert('L') # 얘는 마스크를 L(grayscale) 형식으로 열음
        
        if self.image_transforms:  # 나중에 image augmentation에 사용됨
            original = self.image_transforms(original)
        if self.mask_transforms:   # 나중에 image augmentation에 사용됨
            masked = self.mask_transforms(masked)
            
        return {'img': original, 'mask': masked} # transform이 적용된 후 텐서를 반환
    
    def __len__(self):
        return len(self.images)  # 이미지의 파일 수를 반환함 -> train = True라면 train 데이터셋의 크기를, False라면 valid 데이터셋의 크기를 반환
            
            
 # ------------------------ data loader 정의하기 ------------------------ #
width = 360
height = 240
batch_size = 4
n_workers = 2

# 이미지만 정규화!
image_transforms = T.Compose([
    T.Resize((width, height)),
    T.ToTensor(),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# 마스크는 정규화 안할거임
mask_transforms = T.Compose([
    T.Resize((width, height)),
    T.ToTensor()
])

train_dataset = Mydataset(root_dir = "flood/", 
                          train = True, # train 용 지정
                          image_transforms = image_transforms, 
                          mask_transforms = mask_transforms)

valid_dataset = Mydataset(root_dir = "flood/", 
                          train = False, # valid 용 지정 
                          image_transforms = image_transforms, 
                          mask_transforms = mask_transforms)

train_dataset_loader = DataLoader(dataset = train_dataset, 
                                  batch_size = batch_size, 
                                  shuffle = True, 
                                  num_workers = n_workers)

valid_dataset_loader = DataLoader(dataset = valid_dataset, 
                                  batch_size = batch_size, 
                                  shuffle = True, 
                                  num_workers = n_workers)

이후 Dice Score 값이 [0, 1] 범위로 잘 계산되는 것을 확인할 수 있었습니다.


감사합니다 :)

https://smartest-suri.tistory.com/49

 

딥러닝 | U-Net(2015) 논문 리뷰

[주의] 본 포스팅은 수리링이 직접 U-Net 논문 원문을 읽고 리뷰한 내용을 담았으며, 참고 문헌이 있는 경우 출처를 명시하였습니다. 본문 내용에 틀린 부분이 있다면 댓글로 말씀해 주시고, 포스

smartest-suri.tistory.com

지난 번 포스팅에서 리뷰한 U-Net 논문을 파이토치를 이용한 코드로 구현한 과정을 정리해 보겠습니다.


1. [연습] Class 없이 한줄씩 구현

직관적인 이해를 위해서 파이토치 코드로 클래스 없이 한줄씩 유넷 구조를 구현해 보도록 하겠습니다. 

# 먼저 필요한 모듈을 임포트 해줍니다.

import torch
import torch.nn as nn
import torchvision.transforms.functional as TF

1-1. Contracting path - 인코딩 파트

  • 논문에서는 valid padding(패딩 없음)을 사용하지만 코드상의 편의를 위해서 모든 콘볼루션 레이어를 same padding(패딩 1)로 구현하겠습니다.
  • 이렇게 할 경우 나중에 skip-connection 파트에서 concatenate할 때 크기가 딱 맞아서 crop할 필요가 없습니다.
input_channels = 3 # 일단 RGB라고 생각하고 3으로 설정하겠습니다.

# 첫번째 블락
conv1 = nn.Conv2d(input_channels, 64, kernel_size = 3, padding = 1)
conv2 = nn.Conv2d(64, 64, kernel_size = 3, padding = 1)
pool1 = nn.MaxPool2d(kernel_size = 2, stride = 2)

# 두번째 블락
conv3 = nn.Conv2d(64, 128, kernel_size = 3, padding = 1)
conv4 = nn.Conv2d(128, 128, kernel_size = 3, padding = 1)
pool2 = nn.MaxPool2d(kernel_size = 2, stride = 2)

# 세번째 블락
conv5 = nn.Conv2d(128, 256, kernel_size = 3, padding = 1)
conv6 = nn.Conv2d(256, 256, kernel_size = 3, padding = 1)
pool3 = nn.MaxPool2d(kernel_size = 2, stride = 2)

# 네번째 블락
conv7 = nn.Conv2d(256, 512, kernel_size = 3, padding = 1)
conv8 = nn.Conv2d(512, 512, kernel_size = 3, padding = 1)
pool4 = nn.MaxPool2d(kernel_size = 2, stride = 2)

1-2. Bottleneck - 연결 파트

bottleneck

  • 연결 구간에 해당하는 Bottleneck 파트를 작성하겠습니다.
  • 여기까지 오면 최종 채널의 수는 1024가 됩니다.
conv9 = nn.Conv2d(512, 1024, kernel_size = 3, padding = 1)
conv10 = nn.Conv2d(1024, 1024, kernel_size = 3, padding = 1)

1-3. Expanding path - 디코딩 파트

  • 디코딩 파트에는 Skip-connection을 통한 사이즈와 필터의 변화에 주목해서 보시면 좋습니다.
# 첫 번째 블락
up1 = nn.ConvTranspose2d(1024, 512, kernel_size = 2, stride = 2)
# 위에 아웃풋은 512지만 나중에 코드에서 skip-connection을 통해 다시 인풋이 1024가 됨
conv11 = nn.Conv2d(1024, 512, kernel_size = 3, padding = 1)
conv12 = nn.Conv2d(512, 512, kernel_size = 3, padding = 1)

# 두 번째 블락
up2 = nn.ConvTranspose2d(512, 256, kernel_size = 2, stride = 2)
conv13 = nn.Conv2d(512, 256, kernel_size = 3, padding = 1) # Skip-connection 포함
conv14 = nn.Conv2d(256, 256, kernel_size = 3, padding = 1)

# 세 번째 블락
up3 = nn.ConvTranspose2d(256, 128, kernel_size = 2, stride = 2)
conv15 = nn.Conv2d(256, 128, kernel_size = 3, padding = 1) # Skip-connection 포함
conv16 = nn.Conv2d(128, 128, kernel_size = 3, padding = 1)

# 네 번째 블락
up4 = nn.ConvTranspose2d(128, 64, kernel_size = 2, stride = 2)
conv17 = nn.Conv2d(128, 64, kernel_size = 3, padding = 1) # Skip-connection 포함
conv18 = nn.Conv2d(64, 64, kernel_size = 3, padding = 1)

# 마지막 아웃풋에서는 1x1 사이즈 콘볼루션을 사용한다고 이야기함.
output = nn.Conv2d(64, 2, kernel_size = 1, padding = 1)

1-4. Forward-pass

포워드 학습 과정을 한줄씩 구현해 보겠습니다.

def unet_forward(x):
    # 인코더 파트
    x = TF.relu(conv1(x))
    x1 = TF.relu(conv2d(x))
    x = pool1(x1)

    x = TF.relu(conv3(x))
    x2 = TF.relu(conv4(x))
    x = pool2(x2)

    x = TF.relu(conv5(x))
    x3 = TF.relu(conv6(x))
    x = pool3(x3)

    x = TF.relu(conv7(x))
    x4 = TF.relu(conv8(x))
    x = pool4(x4)

    # 연결 bottleneck 파트
    x = TF.relu(conv9(x))
    x = TF.relu(conv10(x))

    # 디코더 파트
    x5 = up1(x)
    x = torch.cat([x5, x4], dim = 1) # skip-connection
    x = TF.relu(conv11(x))
    x = TF.relu(conv12(x))

    x6 = up2(x)
    x = torch.cat([x6, x3], dim = 1) # skip-connection
    x = TF.relu(conv13(x))
    x = TF.relu(conv14(x))

    x7 = up3(x)
    x = torch.cat([x7, x2], dim = 1) # skip-connection
    x = TF.relu(conv15(x))
    x = TF.relu(conv16(x))

    x8 = up4(x)
    x = torch.cat([x8, x1], dim = 1) # skip-connection
    x = TF.relu(conv17(x))
    x = TF.relu(conv18(x))

    # 아웃풋 파트
    output = output(x)

    return output

지금까지 클래스 없이 파이토치로 코드를 짜면서 유넷 구조를 직관적으로 이해해 보았습니다.



2. [실전] Class 이용해서 구현

이번엔 파이토치를 사용하는 만큼 클래스를 이용해서 실전 유넷 코드 구현을 해보겠습니다. 먼저 코드를 짜는 과정은 유튜브 [PyTorch Image Segmentation Tutorial with U-NET: everything from scratch baby (Aladdin Persson)]를 참고하여 작성했음을 미리 밝히겠습니다. 

https://youtu.be/IHq1t7NxS8k?si=776huHRjVsIlf_rS


2-1.  모든 블락의 2개 콘볼루션(Conv2d) 레이어를 정의하는 클래스

 

먼저 unet.py 파일을 만들고 클래스들을 작성하여 파일을 모듈화 하겠습니다.

class DoubleConv(mm.Module):
    def __init__(self, in_channels, out_channels):
        super(DoubleConv, self).__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(in_channels, out_channels, kernel_size = 3, stride = 1, padding = 1, bias = False),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace = True),
            nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias = False),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace = True)
        )

    def forward(self, x):
        return self.conv(x)
  • 1장과 마찬가지로 편의를 위해서 valid padding 대신 same padding을 사용합니다. Carvana 대회 1등한 팀도 same padding을 사용했다고 하네요. (저는 다른걸로 할거지만, 영상에서 Carvana 데이터셋을 이용해서 학습을 합니다)
  • Batch Normalization이 추가되었습니다. 논문에서 따로 Batch Normalization을 해준다는 언급은 없었는데요. 영상에 따르면 UNet이 2015년도 발표되었고, BatchNorm은 2016년에 고안된 아이디어라서 그렇다고 합니다. 찾아보니 유넷을 구현하는 많은 코드가 Batch Normalization을 추가하여 Gradient Vanishing/Exploding 문제를 보완하는 것 같습니다.
  • bias를 False로 설정해 주는 이유는 중간에 BatchNorm2d를 추가해주기 때문입니다. bias가 있어봤자 BatchNorm에 의해서 상쇄(cancel)되기 때문에 굳이 bias가 필요 없다고 영상에서 말하고 있습니다. 이부분은 나중에 BatchNorm 논문을 통해 따로 확인해 보겠습니다.

2-2. 유넷 전체 구조를 정의하는 클래스

해당 파트는 코드가 길어지기 때문에 주석을 이용해서 각 부분을 설명했습니다 :) 비교적 간단하지만 그래도 코드를 이해하시려면 유넷 전체 구조에 대한 이해가 필수적입니다.

  • 논문에서는 마지막 아웃풋의 채널이 2였습니다만, 저는 레이블이 0, 1로 binary인 데이터를 다룰 예정이기 때문에 output_channels의 default 값을 1로 두었습니다.
  • features 리스트는 각 블록에서 피처맵의 사이즈를 순서대로 정의하는 역할을 합니다. (contracting path의 경우 순서대로 64, 128, 256, 512 - expanding path의 경우 그 반대)
class UNET(nn.Module):
    def __init__(
            self, in_channels = 3, out_channels = 1, features = [64, 128, 256, 512]
    ):
        super(UNET, self).__init__()
        self.downs = nn.ModuleList() # Contracting path - 인코딩 파트의 모듈을 담을 리스트 선언
        self.ups = nn.ModuleList()   # Expanding path - 디코딩 파트의 모듈을 담을 리스트 선언
        self.pool = nn.MaxPool2d(kernel_size = 2, stride = 2) # 풀링은 모든 블럭에서 공통 사용됨

        # Contracting path (Down - 인코딩 파트) ------------------------
        for feature in features:
            self.downs.append(DoubleConv(in_channels, feature)) # 블록마다 더블콘볼루션 해주고 아웃풋은 feature맵 리스트 순서대로 할당(64, 128...)
            in_channels = feature # 다음 모듈의 인풋 사이즈를 feature로 업데이트

        # Bottleneck (인코딩, 디코딩 연결 파트) ---------------------------
        size = features[-1] # 512
        self.bottleneck = DoubleConv(size, size * 2) # 인풋 512 아웃풋 1024

        # Expanding path (Up - 디코딩 파트) ----------------------------
        for feature in features[::-1]: # 피처맵 사이즈 반대로!
            # 먼저 초록색 화살표에 해당하는 up-conv 레이어를 먼저 추가해 줍니다.
            self.ups.append(
                nn.ConvTranspose2d(
                    feature*2, feature, kernel_size = 2, stride = 2
                    # 인풋에 *2 해주는 이유 : 나중에 skip-connection을 통해서 들어오는 인풋 사이즈가 더블이 되기 때문!
                    # kernel과 stride size가 2인 이유는.. 논문에서 그렇게 하겠다고 했음 '_^ 
                )
            )
            # 이제 더블 콘볼루션 레이어 추가
            self.ups.append(DoubleConv(feature * 2, feature))

        # Output (아웃풋 파트) -----------------------------------------
        last_input = features[0] # 64
        self.final_conv = nn.Conv2d(last_input, out_channels, kernel_size = 1)

    ####################### **-- forward pass --** #######################
    def forward(self, x):
        skip_connections = []

        # Contracting path (Down - 인코딩 파트) ------------------------
        for down in self.downs: # 인코딩 파트를 지나면서 각 블록에서 저장된 마지막 모듈 하나씩 이터레이션
            x = down(x)
            skip_connections.append(x) # skip_connection 리스트에 추가
            x = self.pool(x)

        # Bottleneck (인코딩, 디코딩 연결 파트) ---------------------------
        x = self.bottleneck(x)
        skip_connections = skip_connections[::-1] # 디코딩 파트에서 순서대로 하나씩 뽑기 편하게 리스트 순서 반대로 뒤집어주기

        # Expanding path (Up - 디코딩 파트) ----------------------------
        for i in range(len(skip_connections)):
            x = self.ups[i * 2](x) # self_ups에는 순서대로 ConvTranspose2d와 DoubleConv가 들어가 있음. 
            # 0, 2, 4... 짝수번째에 해당하는 인덱스만 지정하면 순서대로 ConvTranspose2d와(up-conv) 모듈만 지정하게 됨
            skip_connection = skip_connections[i] # skip_connection 순서대로 하나씩 뽑아서
            # concatenate 해서 붙여줄(connection) 차례!
            # 그런데 만약 붙일때 shape이 맞지 않는다면... (특히 이미지의 input_shape이 홀수인 경우 이런 뻑이 나게됨)
            if x.shape != skip_connection.shape:
                x = TF.resize(x, size = skip_connection.shape[2:]) # 간단히resize로 맞춰주겠음
            concat_skip = torch.cat((skip_connection, x), dim = 1) # 이제 붙임!
            x = self.ups[i * 2 + 1](concat_skip)
            # 1, 3, 5... 홀수번째에 해당하는 인덱스만 지정하면 순서대로 DoubleConv 모듈만 지정하게 됨
            
        return self.final_conv(x)

2-3. 테스트

  • 간단한 랜덤 텐서를 생성해서 우리가 구현한 유넷 모델이 제대로 작동하는지 확인하겠습니다.
  • 모델 인풋과 아웃풋의 shape이 정확히 같은지 확인하는 작업이 들어가는데, image segmentation 작업의 특성상 output mask의 크기가 인풋과 동일해야 하기 때문에 그렇습니다.
  • assert 문법을 사용해서 인풋 아웃풋 shape이 다른 경우가 감지되면 AsserstionError를 발생시켜서 Debug에 활용하도록 합니다.
# 유넷 모델 테스트하는 함수 작성

def test():
    # 3장의 1채널(grayscale), width height가 각각 160인 랜덤 텐서 생성 (테스트용으로!)
    x = torch.randn((3, 1, 160, 160))
    # in_channels 1로 설정 (그레이스케일 이미지), out_channels 1로 설정 (binary output)
    model = UNET(in_channels = 1, out_channels = 1)
    # forward pass
    preds = model(x)
    # preds와 x의 shape 확인하기 - 두개가 같아야 함
    print(preds.shape)
    print(x.shape)
    assert preds.shape == x.shape 
        # True : 오케이
        # False : AssertionError

if __name__ == "__main__": # 메인 파일에서만 작동하고 모듈로 import된 경우에는 작동하지 않도록 함
    test()
  
# 실행 결과 ---------------------------
# torch.Size([3, 1, 160, 160])
# torch.Size([3, 1, 160, 160])

구동 결과 shape이 정확히 같아서 test 함수가 제대로 작동한 것을 확인했습니다.

 

여기까지 작성한 코드를 unet.py 파일로 저장해서 모듈화 해주었습니다.

이후 데이터를 로드에 필요한 dataset.py를 작성한 다음 학습에 필요한 utils.py train.py를 차례대로 작성하는 순서로 학습을 진행합니다. 해당 파트는 본 포스팅에서 제외하도록 하겠습니다.

지금까지 유넷 논문에서 살펴본 구조를 파이토치 코드로 구현하는 과정을 포스팅해 보았습니다 :-) 다음 포스팅에서는 트랜스포머 코드화 작업을 수행해 보도록 하겠습니다. 감사합니다!

[주의] 본 포스팅은 수리링이 직접 U-Net 논문 원문을 읽고 리뷰한 내용을 담았으며, 참고 문헌이 있는 경우 출처를 명시하였습니다. 본문 내용에 틀린 부분이 있다면 댓글로 말씀해 주시고, 포스팅을 출처 없이 불법 공유하지 말아주시기 바랍니다. 감사합니다.


U-Net: Convolutional Networks for BiomedicalImage Segmentation

https://arxiv.org/pdf/1505.04597

이미지 세그멘테이션(image segmentation)에서 빼놓을 수 없는 근본 모델 유넷. 유넷은 픽셀 기반으로 이미지를 분할하여 구분하는 모델로, 많은 최신 모델 속에서 그 구조가 사용되며 활약하고 있습니다.

유넷은 ISBI cell tracking challenge 2015 대회에서 등장한 모델로, 세포 이미지로부터 뛰어난 세그멘테이션 성능을 보여주며 우승을 차지했습니다. 놀라운 점은, 이 대회에서 제공된 학습 데이터가 고작 이미지 30장이었다고 합니다. 저는 논문 리뷰를 마치고서야 이 사실을 알게 되어서, 논문에서 계속 데이터 수가 부족했다고 우는 소리를 하는 이유가 있었구나.....! 대박적이다...! 라는 생각을 했습니다.

따라서 유넷 논문을 처음 공부하시는 분들께서는, 유넷이 어떻게 이러한 한계를 극복하며 뛰어난 segmentation을 구현할 수 있었는지에 주목하면서 리뷰를 해보시면 좋을 것 같습니다.

https://hpc.nih.gov/apps/UNet.html




Abstract

In this paper, we present a network and training strategy that relies on the strong use of data augmentation to use the available annotated samples more efficiently.

초록에서는 주어진 데이터를 더욱 효율적으로 사용하기 위해 data augmentation - 데이터 증강을 통한 네트워크와 학습 전략을 제시하겠다고 밝히고 있습니다. 

The architecture consists of a contracting path to capture context and a symmetric expanding path that enables precise localization. 

U넷의 네트워크 구조는 크게 2가지 path로 구성됩니다.

  1. 수축 경로 'contracting path' : capture context - 넓은 범위에서 이미지의 주요한 특징과 의미 추출
  2. 확장 경로 'expanding path' : 정교한 localization - 앞에서 추출한 의미를 위치정보와 결합하여 각 픽셀마다 어떤 객체에 속하는지를 구분
  3. (+) 'bottleneck' 연결 구간 : 수축 경로와 확장 경로를 연결 (논문에 언급되어 있지 않지만 흔히 보틀넥이라고 칭합니다.)
We show that such a network can be trained end-to-end from very few images and outperforms the prior best method (a sliding-window convolutional network) on the ISBI challenge for segmentation of neuronal structures in electron microscopic stacks.

구조를 그렇게 설계했더니 아주 적은 수의 이미지로도 end-to-end 학습이 가능했을 뿐더러 기존에 sliding-window 형식의 CNN구조를 사용하고 있던 베스트 모델보다도 성능이 훨씬 뛰어났다는 이야기를 하고 있습니다.

end-to-end 딥러닝이 뭔지 궁금하신 분께서는 아래 유튜브 동영상을 참고하시면 도움이 될거예요. end-to-end을 실현하려면 데이터가 많이 필요한 것이 핵심인데, 유넷은 이 부분을 augmentation으로 해결했다는 거죠. 어떤 증강기술을 사용했는지는 차차 설명하게 됩니다.


https://youtu.be/ImUoubi_t7s?si=JYQRmitCcbqp2CsD

이제는 이 교수님이 삼촌같이 느껴지네요...
Moreover, the network is fast. Segmentation of a 512x512 image takes less than a second on a recent GPU. 

게다가 처리속도도 아주 빠르다고 합니다.


여기까지 논문의 초록을 살펴봤는데요. 저는 초록에서 강조하고 있는 키워드가 아래와 같은 것으로 정리했습니다.

  • Data Augmentation
  • Contracting path & Expanding path
  • end-to-end 
  • Not a sliding window
  • Outperform, fast

그럼 본격적으로 본문을 살펴보면서 위와 같은 키워드들이 어떻게 설명되는지 살펴보도록 하겠습니다.



Introduction

The typical use of convolutional networks is on classification tasks, where the output to an image is a single class label. However, in many visual tasks, especially in biomedical image processing, the desired output should include localization, i.e., a class label is supposed to be assigned to each pixel.

전형적인 CNN 모델은 이미지를 입력하면, 이미지에 해당하는 하나의 class label이 출력되는 방식으로 '분류'작업을 수행합니다. 그런데 실제로 많은 과업에서 이렇게 이미지 전체에 해당하는 하나의 레이블이 필요한 경우보다는, localization을 포함한 결과가 필요한 경우가 많습니다. 즉, 이미지 전체가 아닌 이미지 내부의 각 픽셀에 해당하는 labeling이 필요한 거죠.

위의 예시에서 맨 왼쪽은 "이 사진은 고양이 사진이다!"라고 전체에 대한 레이블링을 수행하였습니다. 하지만 localization은 모든 픽셀에 대해서 레이블링을 수행합니다. 그래서 그 옆의 그림처럼 이 사진의 어떤 부분은 고양이고, 또 다른 부분은 잔디이고, 이렇게 다양한 결과값을 출력할 수가 있는 것입니다.

Moreover, thousands of training images are usually beyond reach in biomedical tasks.

특히 바이오메디컬(생의학) 분야에서는 데이터 수가 부족한 경우가 많다는 것도 고려할 필요가 있는데요. 레이블링을 의사같은 전문가가 해야하기 때문에, dog-cat 분류같이 아무나 할수 있는게 아니라는 이유에서 그렇다고 합니다. 대회에 참가해서 30장 데이터로 승부를 봐야 했던 유넷처럼 말이죠......... (아니 근데 아무리 그래도 30장은 좀 너무했다)

Hence, Ciresan et al. [1] trained a network in a sliding-window setup to predict the class label of each pixel by providing a local region (patch) around that pixel as input. First, this network can localize. Secondly, the training data in terms of patches is much larger than the number of training images. The resulting network won the EM segmentation challenge at ISBI 2012 by a large margin.

유넷 이전 다수의 모델은 sliding window를 적용했습니다. sliding window를 적용하면 하나의 이미지로부터 여러 개의 patch가 생기면서 데이터 수 부족 현상을 해결할 수 있었기 때문이죠.

출처 : https://www.mathworks.com/help/vision/ug/anchor-boxes-for-object-detection.html

Obviously, the strategy in Ciresan et al. [1] has two drawbacks. First, it is quite slow because the network must be run separately for each patch, and there is a lot of redundancy due to overlapping patches. Secondly, there is a trade-off between localization accuracy and the use of context. Larger patches require more max-pooling layers that reduce the localization accuracy, while small patches allow the network to see only little context. More recent approaches [11,4] proposed a classifier output that takes into account the features from multiple layers. Good localization and the use of context are possible at the same time.

그런데 sliding window를 적용하면 계산량이 많아지면서 전체 네트워크가 느려진다는 단점이 있습니다. 게다가, localization accuracy와 use of context - 즉 전체와 부분 사이의 tradeoff가 발생하는 문제점도 있었는데요.

드디어 그 유명한 유넷의 구조가 언급됩니다. 

In this paper, we build upon a more elegant architecture, the so-called “fully convolutional network” [9]. We modify and extend this architecture such that it works with very few training images and yields more precise segmentations; see Figure 1. 

유넷 페이퍼에서는 이러한 문제점들을 해결하기 위해 U모양의 "fully convolutional network"를 제안합니다. 적은 수의 트레이닝 이미지 데이터로도 학습이 잘 되고, 정확한 segmentation이 가능하도록 말이죠.

The main idea in [9] is to supplement a usual contracting network by successive layers, where pooling operators are replaced by upsampling operators. Hence, these layers increase the resolution of the output. 

핵심 아이디어는 초록에서 살펴본 바와 같이 contracting - expanding network구조를 사용하는 것입니다. 입력된 이미지는 contracting network의 연속적인 레이어 구조를 거치며 사이즈는 줄어들고 채널 수는 늘어납니다. 이어서 expanding network의 연속적인 레이어 구조에서 pooling 대신 upsampling을 거치며 사이즈가 다시 커지고, 채널 수는 줄어드는데요. 

In order to localize, high resolution features from the contracting path are combined with the upsampled output. A successive convolution layer can then learn to assemble a more precise output based on this information.

One important modification in our architecture is that in the upsampling part we have also a large number of feature channels, which allow the network to propagate context information to higher resolution layers. As a consequence, the expansive path is more or less symmetric to the contracting path, and yields a 
u-shaped architecture.

Feature 1 이미지에서 회색 화살표를 보시면 대칭 구조를 이루며 레이어들이 서로 짝을 이루고 있습니다. contracting path에서 추출한 Feature map의 정보가 업샘플링 과정에서 skip-connection 방식으로 결합되는 과정을 도식화한 것입니다.

논문에서 'skip-connection'이라는 용어가 직접적으로 언급되지는 않지만, 이러한 방식을 많은 사람들이 그렇게 명명하고 있습니다. 스킵 연결방식을 사용해서 expanding path의 콘볼루션 레이어는 contracting path로부터 정보를 제공받게 되고, 보다 정확한 출력을 조합하는 방법을 학습할 수 있게 됩니다. 깊은 레이어 구조를 거치며 다양한 정보가 손실될 가능성이 있는데, 그런 점을 skip-connection을 사용하여 보완해 학습이 잘 되도록 구조를 설계한 것이라고 보시면 되겠습니다.

The network does not have any fully connected layers and only uses the valid part of each convolution
, i.e., the segmentation map only contains the pixels, for which the full context is available in the input image. This strategy allows the seamless segmentation of arbitrarily large images by an overlap-tile strategy (see Figure 2). 

유넷 아키텍처는 기본적으로 fully connect layers로 이루어져 있지 않습니다. 대신, 앞에서 살펴본 바와 같이 모든 레이어가 Convolution(fully convolutional layers)으로 이루어져 있어요. 또 유넷 네트워크는 각 컨볼루션 연산에서 유효한 부분(valid part)만을 사용합니다. 이는 입력 이미지의 가장자리를 패딩 없이 처리하는 방식을 의미하는데요, 따라서 결과적으로 출력 이미지의 크기가 입력 이미지보다 작아지게 되고, 모든 출력 픽셀은 입력 이미지의 전체 컨텍스트(문맥 정보)를 갖게 됩니다.

저는 여기서 처음에 들었던 의문이.... '패딩 없이 처리'를 한다고 해서 어떻게 전체 컨텍스트 정보를 갖게 된다는 것인가? 라는 거였어요. 패딩을 사용 안하면 안하는거지, 그걸 굳이 저렇게 거창하게 말할 필요가 있나.. 싶었거든요. 이 부분은 바로 이어서 언급되는 '오버랩 타일 전략'을 살펴보면서 간단히 해결할 수 있었습니다.

(+) 그런데 실제로 코드로 구현하는 경우 계산의 편의를 위해 valid padding 대신 same padding을 사용하는 경우가 많은 것 같습니다.

유넷은 이미지의 가장자리에 패딩을 사용하지 않는 대신, 오버랩-타일(overlap-tile) 전략이라는 걸 사용합니다. 이 방식은 특히 큰 이미지를 분할할 때 유용한데요. 경계 영역에서의 정보 손실을 최소화할 수 있는 장점이 있다고 언급하고 있습니다. 아래 그림을 함께 보시죠.

잘 보면, 가장자리 부분이 내부 이미지를 상하좌우 대칭으로 미러링하여 채워져 있습니다.

To predict the pixels in the border region of the image, the missing context is extrapolated by mirroring the input image. This tiling strategy is important to apply the network to large images, since otherwise the resolution would be limited by the GPU memory.

U-Net에서 설명하는 타일링 전략(overlap-tile strategy)은 하나의 큰 이미지를 여러 개의 작은 타일로 나누어 처리하는 방식입니다. 이 때 경계 부분에서 패딩을 사용하는 대신, 입력 이미지를 반사하는 미러링(mirroring)하는 방법을 사용하고, 타일끼리 서로 겹치는 부분이 발생하기 때문에 '오버랩'이라는 단어가 앞에 붙게 되었어요. 이 오버랩 타일링 전략을 통해 유넷은 이미지 경계에서 패딩을 사용했을때 발생할 수 있는 정보 손실을 최소화할 수 있었고, 덕분에 정확한 경계 예측을 할 수가 있었어요.

생각해 보면 segmentation을 수행할 때 경계값을 명확하게 파악할 필요성이 있다고 납득이 되더라고요. 이 타일링 전략은, 앞에 서론에서 잠깐 살펴보았던 슬라이딩 윈도우(sliding window)의 문제점을 해결한 전략이라고 할 수 있겠습니다. 이러한 타일링 전략은 특히 큰 고해상도의 이미지에 유넷 네트워크를 적용할 때 중요했다고 하는데, 그렇지 않으면 해상도가 GPU 메모리에 의해 제한될 수가 있었다고 하네요.

https://youtu.be/O_7mR4H9WLk?si=9aNrE8Olud-7bq-Z

슬라이딩 윈도우와 타일링 전략, 미러링 부분에 추가 보충이 필요하신 분들께서는 위의 유투브 영상을 참고하시는 것을 추천합니다. 시각적으로 설명이 깔끔하게 잘 되어 있어서 저도 도움을 많이 받았습니다 :)


As for our tasks there is very little training data available, we use excessive data augmentation by applying elastic deformations to the available training images. This allows the network to learn invariance to such deformations, without the need to see these transformations in the annotated image corpus.

U-Net은 고작 30장밖에 되지 않는 데이터셋으로 성능을 뽑아야 했기 때문에, augmentation을 빡세게 굴렸다! 라고 밝히고 있습니다. 특히 그 중에서도 elastic deformation을 사용했다고 밝히고 있어요.

https://towardsdatascience.com/review-u-net-biomedical-image-segmentation-d02bf06ca760
https://towardsdatascience.com/review-u-net-biomedical-image-segmentation-d02bf06ca760

Pixel이 랜덤하게 다른 방향으로 뒤틀리도록 변형하는 'elastic deformation'을 통해 자연스럽고 현실세계에 있을법한 새로운 데이터를 만들어내는 의의가 있었다고 해요. 위의 그림을 보면 elastic deformation을 거쳤을 때 기본 이미지의 특성은 어느정도 유지되면서 이미지가  다양하게 변형되는 것을 볼 수 있습니다. 논문에 따르면 이런 방법의 변형이 특히 biomedical 생의학 분야에서 의미있다는 연구 결과가 있었다며 참고 문헌을 밝히고 있네요. (세포라는 게 아무래도 원래 찌글짜글 거리니까…..)

Another challenge in many cell segmentation tasks is the separation of touching objects of the same class; see Figure 3. To this end, we propose the use of a weighted loss, where the separating background labels between touching cells obtain a large weight in the loss function. The resulting network is applicable to various biomedical segmentation problems. In this paper, we show results on the segmentation of neuronal structures in EM stacks (an ongoing competition started at ISBI 2012), where we outperformed the network of Ciresan et al. [1]. Furthermore, we show results for cell segmentation in light microscopy images from the ISBI cell tracking challenge 2015. Here we won with a large margin on the two most challenging 2D transmitted light datasets.

세포 분할 작업에서 해결해야 하는 또 다른 도전 과제가 있는데요. 바로 같은 클래스의 서로 접촉하는 객체를 분리하는 것입니다. 위의 그림을 보면, 여러 개의 같은 세포가 서로 매우 가깝게 접촉해 있는 것을 확인할 수 있습니다. 얘네가 서로 다른 개체임에도 불구하고 label이 같아서 하나의 큰 뭉텅이로 간주될 위험성이 있단 말이죠.

유넷은 이러한 문제점을 해결하고자 접촉하는 세포 사이의 배경 레이블에 큰 가중치를 부여하는 weighted loss를 제안합니다. 탐지된 객체가 서로 가까이 붙어 있을 수록 그 사이의 배경이 큰 weight값을 갖게 되는 원리인데요. 위의 Fig. 3. 그림에서 d를 보시면 되고, 세포 사이의 거리가 가까울수록 배경이 빨간 값을 가지게 되는 점에 주목하시면 되겠습니다. 이 방법을 적용하면서 뛰어난 segmentation 성능을 확보하게 되었고, 이 덕분에 대회를 우승할 수 있었다고 밝히고 있었습니다.



Network Architecture

다음으로 유넷 네트워크의 구조에 대해서 조금 더 자세히 살펴보도록 하겠습니다. 이 부분은, 유넷 그림의 구조를 보면서 스스로 설명할 수 있어야 합니다.

Contracting path (left side)

The contracting path follows the typical architecture of a convolutional network. It consists of the repeated application of two 3x3 convolutions (unpadded convolutions), each followed by a rectified linear unit (ReLU) and a 2x2 max pooling operation with stride 2 for downsampling. At each downsampling step we double the number of feature channels.

Contracting path는 기본적인 콘볼루션 네트워크 구조로 되어 있습니다. (3x3) 필터를 사용해서 패딩 없이 콘볼루션을 계산한 뒤 ReLU를 걸어주는 과정2번 반복하고, (2x2) 사이즈와 stride 2의 max poolng을 적용하여 다운샘플링을 진행합니다. 이렇게 한 셋트의 다운샘플링을 진행할 때마다 채널의 수는 2배씩 증가하게 됩니다. 그림에선 다운샘플링을 4번 했네요.

이렇게 이미지의 사이즈가 줄어드는 과정에서 이미지의 특징이 추출되고, 채널이 늘어나는 과정에서 이미지의 다양한 특징을 확보하게 됩니다.

Expansive path (right side)

Every step in the expansive path consists of an upsampling of the feature map followed by a 2x2 convolution (“up-convolution”) that halves the number of feature channels, a concatenation with the correspondingly cropped feature map from the contracting path, and two 3x3 convolutions, each followed by a ReLU. The cropping is necessary due to the loss of border pixels in every convolution.

Expansive path에서는 (2x2) 필터 사이즈의 up-convolution을 통해 특징 맵(feature map)의 사이즈를 키우는 업샘플링(upsampling)을 수행하게 되는데, 이 과정에서 반대로 채널의 수는 다시 절반으로 점차 줄어들게 됩니다.

이후에 contracting path에서 해당 단계의 크롭된(cropped) 특징 맵과 연결(concatenation)한다는 이야기가 나오는데요. 이 부분은 구조도에서 회색 화살표를 확인하시면 됩니다. 위의 그림에서 빨간색 표시한 부분을 잘 보면, Contracting path의 (64 * 64 * 512)이 copy and crop으로 Extracting path의 (56 * 56 * 512)와 concatenate되어 (56 * 56 *  1024)가 됩니다. 64와 56이 사이즈가 맞지 않으니 64를 56으로 crop해주어야 했을 것이고, 이후 필터 512장과 512장이 concat으로 이어붙어 1024장이 된 것이죠.
논문에서는 언급되지 않은 용어이지만 흔히들 이 과정을 Skip-connection이라고 부르는데, element-wise summation이 아닌 concatenate를 사용하는 이유에 대해서는 아래 포스팅을 참고해보시면 도움이 되실 거예요.


https://at0z.tistory.com/164

 

Skip connection에서 add(summation) vs concatenation

U-Net architecture를 공부하다가 어떤 네트워크는 image size와 channel이 동일해서 add를 하기도 하고, 어떤 네트워크는 image size는 동일하지만 channel이 달라 (보통 2배 차이남) concatenation해 주기도 한다.

at0z.tistory.com


저는 여기서 앞서 살펴본 '미러링 타일 전략'과 개념 혼돈이 오면서 헷갈리는 시기가 있었습니다. upsampling과 downsampling 과정에서 콘볼루션을 계산할 때, 패딩을 사용하는 대신 미러링을 사용하는 것인가? 라는 오개념이 자리잡은 것인데요. 저와 비슷한 분을 위해 짚고 넘어가자면, 미러링은 이미지를 여러 개의 타일로 나누고 난 다음에, 그 각각의 타일 테두리에 미러링을 추가해 주어 경계 정보를 보완해주는 것으로, 그렇게 완성된 타일 이미지에 upsampling과 downsampling의 u-net 구조가 적용되는 것입니다. 즉, 

  1. 전체 이미지를 작은 타일로 나누어서 사용한다
  2. 이때 각각의 작은 타일의 가장자리에는 미러링이 적용된다
  3. 이렇게 미러링된 하나의 패치 이미지에 각각 u넷 구조가 적용된다

이렇게 순서를 이해하시면 되겠습니다 :)


 At the final layer a 1x1 convolution is used to map each 64- component feature vector to the desired number of classes. In total the network has 23 convolutional layers.

Skip-connection 이후에 두 번의 3x3 컨볼루션과 각각의 ReLU 활성화 함수를 적용하게 되는데요. 전체 네트워크는 총 23개의 컨볼루션 레이어로 구성되고, Expanding path의 마지막 레이어에서는 1x1 컨볼루션을 사용하여 결과값의 필터 수를 원하는 값으로 조정합니다. 

(노란색 부분) 마지막 1x1 콘볼루션

To allow a seamless tiling of the output segmentation map (see Figure 2), it is important to select the input tile size such that all 2x2 max-pooling operations are applied to a layer with an even x- and y-size.


seamless tiling을 위해서는 입력 타일 크기를 신중하게 선택할 필요가 있는데, 특히 모든 2x2 max-pooling 연산이 x와 y 크기가 짝수인 레이어에 적용되도록 해야 한다고 밝히고 있습니다. 이는 각 풀링 연산이 끝나는 레이어의 크기가 짝수여야 다음 연산에서도 크기가 정확히 맞아 떨어지기 때문인데요.

논문에서 표현하는 'seamless tiling'이란, U-Net이 큰 이미지를 처리할 때 '타일링 전략'을 사용해서 하나의 이미지를 여러 개의 작은 타일로 나누었던 것을, 마지막에 다시 이어 붙이는 작업이 매끄럽고 이쁘게 잘 되는 것을 의미한다고 보시면 돼요. 마치 원래부터 하나의 이미지였던 것처럼 이쁘게 잘 이어지기 위해서는 각 풀링 연산이 끝나는 레이어의 크기가 짝수가 되도록 잘 맞춰주라! 라는 겁니다. 



Training

The input images and their corresponding segmentation maps are used to train the network with the stochastic gradient descent implementation of Caffe [6].
  • 인풋 이미지와 그 이미지의 segmentation map 이미지가 네트워크 학습 데이터로 사용되었다고 합니다.
  • SGD(stochastic gradient descent)를 이용하여 학습을 진행했습니다.
Due to the unpadded convolutions, the output image is smaller than the input by a constant border width. To minimize the overhead and make maximum use of the GPU memory, we favor large input tiles over a large batch size and hence reduce the batch to a single image. Accordingly we use a high momentum (0.99) such that a large number of the previously seen training samples determine the update in the current optimization step.

 U-Net은 '타일링 전략'을 사용한다고 앞서 밝혔는데요. 콘볼루션을 계산할 때 패딩 없이 진행이 되기 때문에 이미지가 너무 작은 경우 경계값 정보가 많이 손실되어 학습이 제대로 이루어지지 않을 위험성을 가지고 있습니다. 따라서 여러 개의 작은 타일로 나누는 것보다 타일 크기를 크게 해서 전체 타일 수가 적은 것을 선호한다고 밝히고 있습니다.

그런데 큰 타일 크기를 사용하면 그만큼 SGD(Stochastic Gradient Descent) 배치 수는 줄어야 합니다. GPU 메모리가 한정되어 있기 때문이죠. 배치 수가 줄어든다면 그만큼 학습할 수 있는 데이터 다양성도 함께 줄어드는 단점이 있습니다. 다르게 말하자면 경사 하강법 과정에서 그래디언트 변동성이 커질 수 있게 되고, 이는 최적화 과정을 불안정하게 만들 수가 있는 것이죠.

따라서 논문에서는 이러한 문제점을 해결하기 위해 높은 모멘텀 momentum(0.99)을 사용한다고 밝히고 있습니다. 높은 모멘텀을 사용하면 이러한 변동성을 완화하고, 모델이 더 안정적으로 최적화를 할 수 있기 때문인데요. 즉 큰 타일 크기 사용으로 인한 불안정성을 높은 모멘텀이 보완해주는 효과가 있는 것입니다.

모멘텀은 Local Minimum에 빠지는 경우를 대처할 수 있다는 특징이 있으며, 높은 모멘텀은 현재 그래디언트 업데이트에 이전 단계들의 그래디언트 영향을 더 많이 반영합니다. 모멘텀에 대한 더 깊은 이해가 필요하시면, 아래 포스팅을 참고하시면 도움이 될 것입니다.


https://heytech.tistory.com/382

 

[Deep Learning] 최적화(Optimizer): (1) Momentum

본 포스팅에서는 딥러닝 최적화(optimizer) 기법 중 하나인 Momentum의 개념에 대해 알아봅니다. 먼저, Momentum 기법이 제안된 배경인 경사 하강법(Gradient Descent)의 한계점에 대해 다루고 알아보도록 하

heytech.tistory.com


Training에 사용한 U-Net의 Loss Function은 각 픽셀에 대한 에너지 함수(E)의 총합으로 구성되는데요. 에너지 함수는 다음과 같은 과정으로 계산됩니다.

𝑙은 특정 클래스의 레이블을 의미
특정 클래스 𝑙에 대한 로그 확률
loss function!

  1. 먼저 각 픽셀의 예측값에 소프트맥스(SoftMax) 함수 P를 적용해 예측값을 확률값으로 변환해 줍니다. 여기서 𝑙은 특정 클래스의 레이블을 의미합니다.
  2. 그런 다음, 특정 클래스 𝑙에 대한 로그 확률을 계산합니다. 예측 확률은 0과 1 사이의 값을 가지게 되므로, 예측 확률이 1에 가까워질수록 로그 값은 0에 가까워집니다. 반대로 예측 확률이 0에 가까울수록 로그 값은 매우 큰 음수가 되겠죠.
  3. 이제 각 픽셀의 실제 클래스 레이블과 예측된 확률 값을 비교하여 손실 값을 계산합니다. 이를 위해 교차 엔트로피 손실(Cross-Entropy Loss)을 사용합니다. 교차 엔트로피 손실 함수, 즉 '에너지 함수(E)'는 위와 같은데요. 특이하게 크로스 엔트로피에 픽셀 고유의 weight 𝑤(𝑥)을 곱함으로써 픽셀의 Loss값을 계산하는 것을 볼 수가 있습니다. 
  4. 𝑤(𝑥) 식은 아래 수식과 같이 나타냅니다. 경계선(border)라인에 더 강한 학습을 시키기 위해서 가우시안 분포(Gaussian distribution)을 가정하고 경계선 근처의 픽셀에 더 높은 가중치를 부여하여 학습이 집중되도록 했다고 합니다.

마지막으로 모든 픽의 에너지 함수(E)를 합산하여 전체 이미지에 대한 손실을 계산합니다.

마지막 부분에는 초기 가중치 설정의 중요성을 강조하며, 가중치를 Gaussian 분포로부터 표준편차가 루트 2/𝑁인 값으로 초기 설정하였다고 합니다. 예를 들어, 3x3 convolution과 이전 레이어에 64개의 feature 채널이 있는 경우, 𝑁은  9⋅64 = 576이 되는 거죠. 이 부분은 가볍게 읽고 지나가겠습니다.


3.1. Data Augmentation

Data augmentation is essential to teach the network the desired invariance and robustness properties, when only few training samples are available. In case of microscopical images we primarily need shift and rotation invariance as well as robustness to deformations and gray value variations. Especially random elastic deformations of the training samples seem to be the key concept to train a segmentation network with very few annotated images. We generate smooth deformations using random displacement vectors on a coarse 3 by 3 grid. The displacements are sampled from a Gaussian distribution with 10 pixels standard deviation. Per-pixel displacements are then computed using bicubic interpolation. Drop-out layers at the end of the contracting path perform further implicit data augmentation.

논문에서 언급한 data augmentation 방식은 총 4가지 입니다.

  • Shift
  • Rotation
  • Gray value
  • Elastic Deformation

이 중에서도 Elastic Deformation이 key 역할을 했다고 밝히고 있습니다. 이 부분은 앞에서 짚고 넘어간 바가 있으니 추가 설명은 생략하겠습니다.



Experiments

UNet의 우수한 성능으로 각종 대회에서 최고 결과를 도출했다는 파트로, 이부분은 자세한 정리를 생략하겠습니다.



Conclusion

드디어! 마지막 결론입니다.

The u-net architecture achieves very good performance on very different biomedical segmentation applications. Thanks to data augmentation with elastic deformations, it only needs very few annotated images and has a very reasonable training time of only 10 hours on a NVidia Titan GPU (6 GB). We provide the full Caffe[6]-based implementation and the trained networks. We are sure that the u-net architecture can be applied easily to many more tasks.

U-Net 아키텍처는 매우 다양한 생의학적 세그멘테이션 응용에서 매우 우수한 성능을 발휘하는데, Elastic deformation을 이용한 Data Augmentation형을 이용한 데이터 증강 덕분에 가능했다고 밝히고 있습니다. 유넷의 성능 확보에 정말 핵심적인 역할을 한 게 분명합니다.

마지막으로 U-Net 아키텍처는 더 많은 작업에 쉽게 적용될 수 있을 것이라고 자신감을 나타내며 논문이 마무리되는데, 실제로 U-Net은 Biomedical 분야뿐만 아니라 이미지 segmentation이 필요한 다양한 최신 모델에 두루 널리 쓰이며 사랑받고 있습니다.



본 논문 리뷰를 통해 다양한 최신 모델에서 자주 보이는 U-Net의 구조와 원리에 대해서 이해할 수 있어서 영광이었습니다 :-) 다음에도 좋은 논문 리뷰로 찾아뵙겠습니다. 감사합니다!


 

+ Recent posts