2024년 2월 17일 토요일

불필요한 폴더 파일 정리 후 컴퓨터 용량 확보하기

요즘 컴퓨터 용량이 점점 줄어들어, 대대적으로 디스크 정리를 하였다(딥러닝, 오픈소스 등 작업할 때 로컬에 데이터 다운로드가 필수라 용량 부족하면 작업이 안된다).

순서는 다음과 같다.

1. 탐색기에서 디스크 정리한다.

2. du 란 disk usage 용량 보여주는 프로그램을 다운로드해, 윈도우즈 폴더에 풀어준다.

3. c:\Users\[user name\AppData\LocalLow\Temp를 삭제한다. 

4. cmd를 실행해, C:\Users\MAC\AppData\Roaming 로 이동한다. 그리고 다음 명령을 입력한다. 그럼, 현재 하위 폴더의 용량을 요약해 보여준다.
du -l 1 . 

5. 각 폴더 중 불필요한 폴더들은 삭제한다. 예를 들어, Apple Computer\itunes 폴더는 백업이 있어, 과도한 용량을 차지하므로, 해당 백업을 삭제하여, 30GB를 확보하였다. Temp 폴더 등도 삭제한다.

이렇게 하여, 전체 사용 가능 용량을 2배 이상 더 확보할 수 있다.


2024년 2월 14일 수요일

오픈소스 기반 LLM의 민주화, LLAMA-2 논문 분석 및 기술 요약하기

이 글은 개발에 많은 노력이 드는 LLM(Large Language Model) 기술을 개발하고 GitHub에 공개한 메타(페이스북)의 LLAMA-2(라마) 논문을 분석하고, 핵심을 요약한다. 이 지식은 LLM 기반 서비스 및 생성AI 개발 시 유용하다. 참고로, META AI 연구진(리더 Yann LeCun 교수)은 LLM 민주화를 위해 라마를 LLM 커뮤니티에 공개하고, 관련 실행 코드를 GITHUB에 업로드하였다. 
LLAMA-2

라마-2 설치 및 활용에만 관심이 있다면, 다음 링크를 참고한다.
라마2 기술에 대해 분석하기 전에 라마1 기술을 먼저 정리해 본다.

LLAMA-1

서론
7B에서 65B 매개변수를 가진 LLM 모델인 라마는 수조개의 토큰을 학습하고, 이를 이용해 다양한 AI 에이전트 서비스를 개발할 수 있다. LLAMA-13B는 대부분 벤치마킹에서 GPT-3(175B)보다 성능이 뛰어나다. 

라마는 LLM을 개발할 때 목표를 훈련 속도가 아닌 추론 속도가 가장 빠른 모델을 개발하는 것으로 한다. 예를 들어, LLAMA-13B는 경쟁 모델보다 10배 더 작음에도 불구하고, GPT-3보다 뛰어나다. 이 모델은 단일 GPU에 실행할 수 있다. 이러한 가성비를 통해, LLM 민주화에 도움이 될 것이라 믿는다. 참고로, 라마를 모델을 개발하는 데 5개월이 걸렸으며(아이디어, 문서 작업 등 제외), 한 모델을 학습하는 데 21일이 걸렸다. 

데이터 훈련 접근 방식
라마의 데이터 훈련 방식은 이전 LLM연구 논문인 OpenAI, Google, DeepMind 의 LLM 개발과정을 참조해 유사하게 진행되었다. 
라마의 훈련 데이터는 여러 소스가 혼합되어 있다. 다음 표는 이를 보여준다. 라마는 공개된 데이터만 사용하였다. 낮은 품질의 컨텐츠는 fastText 선형 분류기, n-gram 모델을 이용해 사전 필터링되었다. 학습에 문제가 있는 노이즈 데이터는 사전 필터링된다. 
학습 데이터셋

다음은 라마에서 사용된 학습 데이터의 예시이다.

문장 생성 학습 데이터셋
코딩 학습 데이터셋
대화 데이터셋
학습 모델 아키텍처
학습 모델의 핵심 컴포넌트인 트랜스포머를 사용하였다(Google, 2017). 학습 안정화를 위해 Zhang, Sennrich (2019)가 소개한 RMSNorm을 사용하였다. ReLU 함수는 SwiGLU 활성화 함수로 대체하였다(Shazeer, 2020). 이외, 위치 임베딩은 RoPE(로터리 임베딩. Su et al, 2021)을 사용한다. 

옵티마이저는 AdamW(Loshchilov and Hutter, 2017)을 사용해 훈련한다. 베타1=0.9, 베타2=0.95를 사용하여, 어텐션 스코어 계산을 위한 코사인 학습을 일정히 유지한다. 라마의 최대 학습률은 10%이다. 참고로, 0.1 가중치 감쇠를 사용하였다. 

선형 레이어는 PyTorch autograd 대신 레이어를 직접 사용해 메모리 사용량을 줄인다(Korthikantiet al, 2022). 64B 모델에서 380개 토큰/초/GPU가 처리된다. 80GB를 가진 A100 GPU의 경우, 21일 정도가 학습에 소요된다. 
7B, 13B, 33B, 65B 모델에서 훈련 토큰 대비 학습 손실

훈련 속도 개선을 위해, xformers 라이브러리 사용 시 어텐션 가중치를 저장하지 않았으며, 역방향 가중치 업데이트 시 체크포인트를 사용하였다(Rabe & Staats, 2021. Dao et al, 2022). 
이런 과정을 거쳐, 하이퍼파라메터를 최적화하였다.

학습 모델 성능 테스트
1. QA 데이터셋
학습된 모델을 이용해 제로샷(Zero-Shot. 학습 시 보지 못한 클래스 unseen class label을 맞추는 학습 방법. 학습 데이터의 라벨이 아닌 예측 데이터 특징 벡터를 학습함. 예를 들어, 고양이는 귀, 꼬리, 몸통 색상으로 구분한 특징벡터를 만들 수 있고, 이 값이 개와 다르면 고양이와 유사한 것임) 및 퓨샷(Few-Shot. 소량 학습 데이터만으로 학습하는 방식. 적은 데이터 학습을 위해 유사도 기반 학습함) 테스트를 수행하였고, BoolQ, PIQA등이 테스트에 사용되었다. 참고로, 제로샷 학습은 학습하지 않은 데이터를 이해하고, 올바른 결과를 출력하도록 학습하는 방법이다.
제로샷(Zero Shot) 테스트 결과
Zero Shot 테스트 예제 중 BoolQ (성능)

NaturalQuestions, TriviaQA 등 데이터셋에서도 테스트하여, 비교 모델에 비해 성능이 떨어지지 않는 다는 것을 확인하였다. 

2. 수학 문제 
Math 웹 페이지, GSM8k 데이터셋을 이용해, 수학 문제 풀이를 테스트해 보았다. 결과, GSM8k에서는 라바-65B모델이 Minerva-62B 보다 성능이 뛰어나다. 
GSM8k 데이터셋

3. 코드 생성 테스트
자연어로 설명된 입력에 대한 코드 생성 성능을 테스트했다. 코드는 파이썬으로 생성된다. 결과를 보았듯이 PaLM(Google이 개발한 LLM) 모델보다 성능이 뛰어나다.
코딩 성능 결과

4. 대량 멀티태스크 언어 이해 테스트
MMLU(Hendrycks, 2020)에 의해 소개된 대량 멀티태스크 언어 이해 테스트를 수행해 보았다. 이는 인문학, STEM, 사회과학을 포함한 다양한 지식 영역을 다룬다. 라마는 ArXiv, Gutenberg, Books3를 학습하여, Gopher, Chinchilla, PaLM 와 유사하거나, 어떤 부분은 뛰어나다.
MMLU 성능

5. 기타
이외, 미세조정, 편견 테스트, 독성 언어, 종교 편향, 젠더 테스트, TruthfulQA(Lin et al, 2021), 탄소 배출(Wh = GPU-h x (GPU 소비전력) x PUE. Wu et al, 2022) 등 테스트가 수행되었다.

마무리
라마-13B는 GPT-3보다 성능이 뛰어나며, 크기는 10배 이상 작다. LLaMA-65B는 Chinchilla-70B, PaLM-540B와 거의 유사한 성능을 보인다. 학습 데이터는 공개 커뮤니티에서 수집해 사용되었다.  라마 기술은 xformers 개발 팀, 데이터 정재 팀, 학습 모델 조율팀, 페이스북 AI 인프라 팀 등 많은 사람들의 도움으로 개발되었다.

LLAMA-2

서론
라마-2는 이전 모델의 사전 훈련 결과와 미세 조정을 통해 LLM 성능을 개선한다. 라마2는 챗봇에 최적화되었고, 대부분의 오픈소스 LLM에 비해 성능이 뛰어났다. 

학습 방법
라마-2는 라마-1의 미세조정을 통한 성능 개선 버전, 라마-2-Chat이란 챗봇에 특화된 버전을 다음과 같이 공개한다. 
라마-2는 사전훈련 모델을 이용해 시작된다. 이어, 라마-2-챗 초기버전을 개발한다. 강화학습 환경에서 학습 모델을 반복적으로 개선한다. RLHF(Reinforcement Learning with Human Feedback), 강화학습의 PPO 정책을 통해, 반복적으로 보상 모델링 데이터를 축적하고, 이를 통해, 라마-2 모델을 개선한다. 다음 그림은 이 과정을 보여준다. 
Reinforcement Learning with Human
 Feedback (RLHF)

아키텍처 및 사전 훈련
아키텍터 대부분은 라마-1과 유사하다. 토크나이저도 동일한 것을 사용했으며, BPE(byte pair encoding) 알고리즘을 사용해 처리되었다. 모든 숫자는 개별 숫자로 분할하고, 알수 없는 글은 UTF-8 문자로 분리한다. 어휘 크기는 32K 토큰이다. 

사전 훈련을 위해, 신뢰성있는 데이터 정리 프로세스를 수행하고, 데이터를 혼합했으며, 40% 더 증가된 데이터를 학습하였다. 

토큰 컨텍스트 길이는 2배로 늘렸으며, GQA(grouped-query attention)을 사용해, 추론 성능을 개선했다. 

학습 과정
학습에 사용된 것은 NVIDIA A100s이며, RoCE기반 솔류션을 사용해 GPU간 200Gbps로 데이터를 교환한다. 

성능 향상을 위해 감독된 파인 튜닝(SFT)를 다음과 같이 수행하였다.

미세 조정은 가중치 감소 0.1, 배치 크기 64, 시퀀스 길이 4096을 사용해, 코사인 유사도 학습을 진행한다. 그리고, 2세대만 미세 조정을 한다.

이후, 강화학습 기반 RLHF를 실행한다. 강화학습 보상 모델은 모델 응답과 프롬프트 입력을 사용해, 스칼라 점수로 모델 생성의 품질을 표현하도록 하였다. 인간의 선호도를 반영하여, 유용성과 안전성을 보상에 추가한다. 이런 이유로 보상 모델은 유용성 보상, 안전성 보상 2개로 구분되어 강화학습한다.

채팅 시 초기 명령을 잊어버리는 문제 해결을 위해 Ghost Attention(GAtt)를 사용한다. 다음은 그 결과이다.
개선된 모델

생성 결과의 안전성을 위해, 학습 과정은 RLHF로 진행된다. 이 결과, 다음과 같은 개선이 있었다. 

라마-2 와 MPT 등 모델 간 성능 비교는 다음과 같다.

마무리
라마-2는 다양한 방법을 통해 개선되었으나, 기본 아키텍처는 라마-1과 유사하다. 인간 감독을 포함한 강화학습기법을 사용한 것은 GPT에서 진행한 것과 동일하다. 라마-2의 챗 버전은 챗봇 서비스에 유용하다. 
챗봇 성능 개선 결과 일부

결론
라마-1, 라마-2 기술 분석을 위해, 논문을 조사해, 핵심적인 내용을 정리하였다. 이를 통해, 라마 LLM을 좀 더 잘 이해할 수 있다. 라마는 Meta AI 리더인 Yann LeCun 교수의 딥러닝 민주화 철학에 영향을 받았다. 라마는 윤리적 문제 등 다양한 이해당사자들의 입장을 고려해 개발되었으며, 꾸준히 발전되고 있다.

부록: 라마2 코딩 & 코드 라마(Code LLAMA)

부록: 라마3 코드 구조 분석
    LLAMA3 코드를 역설계해 보면, 구조는 다음과 같다. 보면, 알겠지만, 일반적인 트랜스포머 구조에 몇몇 부분이 강화된 것을 알 수 있다.

    페이스북 LLAMA3 아키텍처 구조

    모델 추론 핵심 부분은 다음과 같다. 
        def forward(self, tokens: torch.Tensor, start_pos: int):
            _bsz, seqlen = tokens.shape
            h = self.tok_embeddings(tokens)
            self.freqs_cis = self.freqs_cis.to(h.device)
            freqs_cis = self.freqs_cis[start_pos : start_pos + seqlen]

            mask = None
            if seqlen > 1:
                mask = torch.full((seqlen, seqlen), float("-inf"), device=tokens.device)

                mask = torch.triu(mask, diagonal=1)

                # When performing key-value caching, we compute the attention scores
                # only for the new sequence. Thus, the matrix of scores is of size
                # (seqlen, cache_len + seqlen), and the only masked entries are (i, j) for
                # j > cache_len + i, since row i corresponds to token cache_len + i.
                mask = torch.hstack(
                    [torch.zeros((seqlen, start_pos), device=tokens.device), mask]
                ).type_as(h)

            for layer in self.layers:
                h = layer(h, start_pos, freqs_cis, mask)
            h = self.norm(h)
            output = self.output(h).float()
            return output

    라마3 모델의 생성 부분은 다음과 같다.
    def generate(self, prompt_tokens: List[List[int]]) -> Tuple[List[List[int]], Optional[List[List[float]]]]:
            params = self.model.params
            bsz = len(prompt_tokens)
            assert bsz <= params.max_batch_size, (bsz, params.max_batch_size)

            min_prompt_len = min(len(t) for t in prompt_tokens)
            max_prompt_len = max(len(t) for t in prompt_tokens)
            assert max_prompt_len <= params.max_seq_len
            total_len = min(params.max_seq_len, max_gen_len + max_prompt_len)

            pad_id = self.tokenizer.pad_id
            tokens = torch.full((bsz, total_len), pad_id, dtype=torch.long, device="cuda")
            for k, t in enumerate(prompt_tokens):
                tokens[k, : len(t)] = torch.tensor(t, dtype=torch.long, device="cuda")
            if logprobs:
                token_logprobs = torch.zeros_like(tokens, dtype=torch.float)

            prev_pos = 0
            eos_reached = torch.tensor([False] * bsz, device="cuda")
            input_text_mask = tokens != pad_id
            if min_prompt_len == total_len:
                logits = self.model.forward(tokens, prev_pos)
                token_logprobs = -F.cross_entropy(input=logits.transpose(1, 2), target=tokens,
                    reduction="none", ignore_index=pad_id,
                )

            stop_tokens = torch.tensor(list(self.tokenizer.stop_tokens))

            for cur_pos in range(min_prompt_len, total_len):
                logits = self.model.forward(tokens[:, prev_pos:cur_pos], prev_pos)
                if temperature > 0:
                    probs = torch.softmax(logits[:, -1] / temperature, dim=-1)
                    next_token = sample_top_p(probs, top_p)
                else:
                    next_token = torch.argmax(logits[:, -1], dim=-1)

                next_token = next_token.reshape(-1)
                # only replace token if prompt has already been generated
                next_token = torch.where(
                    input_text_mask[:, cur_pos], tokens[:, cur_pos], next_token
                )
                tokens[:, cur_pos] = next_token
                if logprobs:
                    token_logprobs[:, prev_pos + 1 : cur_pos + 1] = -F.cross_entropy(
                        input=logits.transpose(1, 2), target=tokens[:, prev_pos + 1 : cur_pos + 1],
                        reduction="none", ignore_index=pad_id,
                    )
                eos_reached |= (~input_text_mask[:, cur_pos]) & (
                    torch.isin(next_token, stop_tokens)
                )
                prev_pos = cur_pos
                if all(eos_reached):
                    break

            if logprobs:
                token_logprobs = token_logprobs.tolist()
            out_tokens, out_logprobs = [], []
            for i, toks in enumerate(tokens.tolist()):
                # cut to max gen len
                start = 0 if echo else len(prompt_tokens[i])
                toks = toks[start : len(prompt_tokens[i]) + max_gen_len]
                probs = None
                if logprobs:
                    probs = token_logprobs[i][start : len(prompt_tokens[i]) + max_gen_len]
                # cut to after eos tok if any
                for stop_token in self.tokenizer.stop_tokens:
                    try:
                        eos_idx = toks.index(stop_token)
                        toks = toks[:eos_idx]
                        probs = probs[:eos_idx] if logprobs else None
                    except ValueError:
                        pass
                out_tokens.append(toks)
                out_logprobs.append(probs)
            return (out_tokens, out_logprobs if logprobs else None)

    레퍼런스

    추신
    연말 연구 과제 평가 행정 대략 마무리 후, 미루고 쌓아 놓은 기술, 논문, 코드 급하게 소화 중... 회사 연구일?이 오히려 진짜 연구에 방해되는 현상은 이 바닥 사람들이라면 다 아는 팩트(굳건하게 만들어진 시스템이라 어쩔 수 없어요). 월급 받는 직딩이니 일은 제대로 하고, 남는 시간에 찐 공부, 연구, 개발할 수 밖에.ㅎ - 2.17



    로컬 호스트 LLM 오픈소스 OLLAMA 기반 PDF 지식 챗봇 서비스 간단히 만들어보기

    요즘 LLM 모델을 사용하는 방법이 점차 간편해 지고 있어, 자체적으로 LLM을 구축해, 챗봇, 전문가 시스템 등을 본인 서버에서 제공하는 경우가 많아지고 있다. 이 글은 GPU있는 PC에서 직접 실행해 볼 수 있도록, 로컬 호스트 LLM(Large Language Model) 오픈소스 기반 PDF 지식 챗봇 서비스를 간단히 개발해 본다. 

    이를 위해, 기존 BIM pdf 파일을 검색해 학습하고, LLM에 증강 학습한 후, 이를 간단한 UI로 웹서비스 하는 과정을 간략히 보여주고, 구현한다.

    이 글은 로컬 LLM의 편한 개발을 지원하는 OLLAMA, LLM 프롬프트 엔지니어링 프레임웍인 LangChain, 텍스트 임베딩 벡터 데이터베이스 Chroma, 손쉬운 web app 개발 지원 도구인 Streamlit을 사용한다. 이를 이용해, 간단한 BIM 전문 지식을 PDF로 학습한 챗봇을 간단히 개발한다.  
    로컬 호스트 LLM 챗봇 아키텍처

    구현된 BIM 지식 챗봇 서비스

    LLM 에 관련된 깊은 내용은 아래 링크를 참고한다. 이 글은 여러 참고 자료를 이용해 작성된 것이다. 상세 내용은 레퍼런스를 참고바란다.
    설치
    설치를 위해서는 NVIDIA driver, CUDA, tensorflow, pytorch 등 기본 딥러닝 개발 환경이 설치되어 있어야 한다(최소 구동 GPU RAM 6GB).
    설치 순서는 다음과 같다. 
     
    1. 기본 패키지를 설치한다. LLM 모델 기반 서비스 개발 지원 라이브러리 LangChain, 앱 App UI 개발 지원 streamlit.io, 텍스트 임베딩 벡터 데이터베이스 Chroma DB 등을 설치한다. 
    pip install langchain streamlit streamlit_chat pypdf fastembed chardet
    pip install chromadb==0.4.15
    다양한 LLM 모델을 이용한 서비스 개발을 지원하는 랭체인(LangChain) 패키지
    간단한 코드로 웹 App 개발 지원 UI 라이브러리 streamlit.io 패키지

    혹은 pip와 유사한 패키지 설치 관리자인 poetry 설치 후, 다음 사용 패키지들을 pyproject.toml 이름으로 저장한 후, 설치한다.
    [tool.poetry]
    name = "Local LLM"
    version = "0.1.0"
    description = ""
    readme = "README.md"

    [tool.poetry.dependencies]
    python = ">=3.9,<3.9.7 || >3.9.7,<3.12"
    pypdf = "^3.17.1"
    streamlit = "^1.29.0"
    streamlit-chat = "^0.1.1"
    langchain = "^0.0.343"
    fastembed = "^0.1.1"
    openai = "^1.3.6"
    langchainhub = "^0.1.14"
    chromadb = "^0.4.18"
    watchdog = "^3.0.0"

    [build-system]
    requires = ["poetry-core"]
    build-backend = "poetry.core.masonry.api"

    poetry 설치는 다음과 같다. 
    poetry install
    패키지 설치 모습

    2. Ollama 웹사이트를 방문해, 로컬 LLM을 지원하는 OLLAMA를 설치한다. 
    ollama run llama2
    3. 다음 Ollama 명령을 터미널에서 실행하여, 많이 사용되는 몇몇 LLM 학습 모델을 다운로드 한다. 상세한 모델 종류는 여기를 참고한다.
    ollama pull mistral 
    ollama pull dolphin-phi
    ollama pull llama2

    다음 명령으로 다운로드된 모델 이미지를 확인한다.
    ollama list

    PDF 기반 전문가 서비스 구축을 위한 RAG 파이프라인 구축
    RAG(Retrieval-augmented generation)는 학습된 LLM 모델의 외부 데이터 소스를 활용하여, 검색 모델을 증강하는 기법이다. 이를 통해, 질문이 입력되면, QAChain을 통해, 벡터 스토리지에 해당 PDF 내용 임베딩이 있는 지 확인하고, 이 임베딩 컨텐츠를 LLAMA 서버에 전달하여, 적절한 답변을 얻는다. 
    Chroma DB 기반 RAG 처리 개념

    RAG는 LLM의 다음 문제를 해결한다.
    • 편향성: 훈련 데이터에만 편향된 답변 생성
    • 잘못된 정보 생성: 잘못되거나 유해한 정보 생성
    • 할루시네이션(Hallucination) 현상: 사실이 아닌 정보를 생성하는 문제
    • 비전문성 정보 생성: 학습되지 않은 질문에 대한 답변을 전문가처럼 생성하는 문제
    이를 위해, 전문지식이 포함된 PDF파일들을 입력하면, 이를 문서 청크로 분할해, 벡터 저장소에 청크 파일을 임베딩하여 데이터베이스에 저장한다. 

    다음은 그 구조를 보여준다. 
    PDF 텍스트 임베딩 벡터 데이터베이스 구축 후 LLM 기반 질의 답변 생성 구조

    다음 코드를 rag.py란 파일로 저장한다.
    from langchain.vectorstores import Chroma
    from langchain.chat_models import ChatOllama
    from langchain.embeddings import FastEmbedEmbeddings
    from langchain.schema.output_parser import StrOutputParser
    from langchain.document_loaders import PyPDFLoader
    from langchain.text_splitter import RecursiveCharacterTextSplitter
    from langchain.schema.runnable import RunnablePassthrough
    from langchain.prompts import PromptTemplate
    from langchain.vectorstores.utils import filter_complex_metadata

    class ChatPDF:
        vector_store = None
        retriever = None
        chain = None

        def __init__(self):
            self.model = ChatOllama(model="mistral")  # OLLAMA의 mistral 모델 이용
            self.text_splitter = RecursiveCharacterTextSplitter(chunk_size=1024, chunk_overlap=100) # PDF 텍스트 분할
            self.prompt = PromptTemplate.from_template(
                """
                <s> [INST] You are an assistant for question-answering tasks. Use the following pieces of retrieved context 
                to answer the question. If you don't know the answer, just say that you don't know. Use three sentences
                 maximum and keep the answer concise. [/INST] </s> 
                [INST] Question: {question} 
                Context: {context} 
                Answer: [/INST]
                """
            )

        def ingest(self, pdf_file_path: str):
            docs = PyPDFLoader(file_path=pdf_file_path).load()  # 랭체인의 PDF 모듈 이용해 문서 로딩
            chunks = self.text_splitter.split_documents(docs) # 문서를 청크로 분할
            chunks = filter_complex_metadata(chunks)  

            vector_store = Chroma.from_documents(documents=chunks, embedding=FastEmbedEmbeddings())  # 임메딩 벡터 저장소 생성 및 청크 설정
            self.retriever = vector_store.as_retriever(search_type="similarity_score_threshold",
                search_kwargs={
                    "k": 3,
                    "score_threshold": 0.5,
                },
            )  # 유사도 스코어 기반 벡터 검색 설정

            self.chain = ({"context": self.retriever, "question": RunnablePassthrough()} | self.prompt | self.model | StrOutputParser()) # 프롬프트 입력에 대한 모델 실행, 출력 파서 방법 설정

        def ask(self, query: str):  # 질문 프롬프트 입력 시 호출
            if not self.chain:
                return "Please, add a PDF document first."
            return self.chain.invoke(query) 

        def clear(self):  # 초기화
            self.vector_store = None
            self.retriever = None
            self.chain = None 

    웹 기반 App 개발
    다음과 같이 UI를 가진 앱을 app.py 파일명으로 코딩한다.
    import os, tempfile
    import streamlit as st
    from streamlit_chat import message
    from rag import ChatPDF

    st.set_page_config(page_title="ChatPDF") # 앞서 정의한 PDF 임베딩 벡터 데이터베이스 RAG 모듈 임포트

    def display_messages():   # 메시지 출력
        st.subheader("Chat")  
        for i, (msg, is_user) in enumerate(st.session_state["messages"]):
            message(msg, is_user=is_user, key=str(i))
        st.session_state["thinking_spinner"] = st.empty()

    def process_input():   # 챗 메시지 입력 
        if st.session_state["user_input"] and len(st.session_state["user_input"].strip()) > 0:
            user_text = st.session_state["user_input"].strip()
            with st.session_state["thinking_spinner"], st.spinner(f"Thinking"):
                agent_text = st.session_state["assistant"].ask(user_text)   # 사용자 입력에서 답변 획득

            st.session_state["messages"].append((user_text, True))
            st.session_state["messages"].append((agent_text, False))

    def read_and_save_file():  # file_upader UI에서 PDF 선택 시 호출
        st.session_state["assistant"].clear()  # LLM 어시스턴스 초기화
        st.session_state["messages"] = []
        st.session_state["user_input"] = ""

        for file in st.session_state["file_uploader"]:
            with tempfile.NamedTemporaryFile(delete=False) as tf:
                tf.write(file.getbuffer())
                file_path = tf.name

            with st.session_state["ingestion_spinner"], st.spinner(f"Ingesting {file.name}"):
                st.session_state["assistant"].ingest(file_path)  # PDF 파일을 어시스턴스에 전달
            os.remove(file_path)

    def page():
        if len(st.session_state) == 0:
            st.session_state["messages"] = []
            st.session_state["assistant"] = ChatPDF()  # PDF, 벡터 데이터베이스, LLM 모델 호출 역할하는 객체 설정

        # UI 정의
        st.header("Chat BIM")  # 타이틀  
        st.subheader("Upload a document")  # 서브헤더 
        st.file_uploader("Upload document", type=["pdf"],
            key="file_uploader", on_change=read_and_save_file,
            label_visibility="collapsed", accept_multiple_files=True,
        )  # 업로더 
        st.session_state["ingestion_spinner"] = st.empty()

        display_messages()  # 메시지 출력
        st.text_input("Message", key="user_input", on_change=process_input)  # 채팅 입력 버튼 생성

    if __name__ == "__main__":
        page()

    LLM 기반 챗봇 실행하기
    다음 명령을 실행한다. 
    streamlit run app.py

    실행되면, BIM 관련 PDF 문서를 업로드한다. 여기서는 Development of a Conceptual Mapping Standard to Link Building and Geospatial Information 논문 PDF파일을 사용하였다.

    관련 질문을 하면, 증강 학습된 BIM 내용에 근거해 잘 답변하는 것을 확인할 수 있다. 이로써, BIM 전문가가 대답하는 것 같은 챗봇을 개발해 보았다. 

    참고로, OLLAMA는 로컬에서 실행될 수 있도록, 경량화되어 다음과 같이 적은 GPU 메모리를 사용한다. 

    마무리
    이 글을 통해, 기존 BIM pdf 파일을 검색해 학습하고, LLM에 증강 학습한 후, 이를 간단한 UI로 웹서비스 하는 과정을 간략히 보여주고, 구현해 보았다. 이를 통해, 어느 정도 수준?의 BIM전문가 챗봇 시스템을 구현하였다. 

    앞서 언급된 라이브러리를 이용하면, 수많은 PDF파일을 미리 임베딩 벡터 데이터베이스로 만들어 놓고, 좀 더 전문적인 질의 답변을 도출할 수 있다. 이외, LangChain등의 다양한 기능을 사용하면, 좀 더 특화된 서비스 개발이 가능하다. 

    이와 같은 과정을 통해, 전문영역에 특화된 자체 서버에서 실행되는 로컬 LLM 서비스 개발이 가능하다.

    참고: OLLAMA기반 Image to Text LMM(Large Multi-modal Model) 사용
    OLLAMA가 설치되어 있다면, 다양한 멀티모달 LLM 모델을 다운로드하고 실행해 볼 수 있다.

    예를 들어, 고양이 사진을 주어, 이를 설명하게 하는 Image to Text를 간단히 실행해 볼 수 있다. 이는 LMM 모델인 LLaVA를 다운로드해 프롬프트를 입력하면 된다. 

    앞의 사진을 cat.jpg로 저장한 후, 다음 명령을 터미널에서 실행해 본다. 
    ollama run llava "describe this iamge: ./cat.jpg"

    그럼, 주어진 이미지를 설명하는 Text를 쉽게 얻을 수 있을 것이다.

    글 내용은 다음과 같다.

    "테이블 다리로 보이는 것 옆에 똑바로 앉아 있는 얼룩무늬 고양이의 이미지입니다. 고양이는 뚜렷한 어두운 줄무늬가 있는 밝은 주황색 털을 가지고 있는데, 이는 흔히 볼 수 있는 패턴입니다. 얼룩 고양이는 눈을 크게 뜨고 카메라를 정면으로 차분한 태도로 바라보고 있는 모습입니다. 
    배경에는 바닥에 다음과 같은 패턴의 러그가 깔려 있습니다. 베이지색과 기타 중성색이 포함됩니다. 전경에 테이블 다리가 있기 때문에 다이닝 룸과 같은 생활 공간처럼 보입니다."

    거의 일치한다.

    참고: Error: Your app is having trouble loading the streamlit_chat.streamlit_chat component
    Error: Your app is having trouble loading the streamlit_chat.streamlit_chat component 에러가 발생할 경우가 있다. 이 경우는 다음과 같이 패키지 설치 파일을 수정하고 재설치하면 해결 될 수 있다(참고-링크). 
    streamlit-chat = "0.0.2.2"

    참고: 커스텀 데이터 학습 파인튜닝 방법
    다음은 사용자 데이터 학습, 파인튜닝 방법이다. 
    참고: OLLAMA 메뉴얼
    다음을 참고한다.

    참고: LLAMA2 패키지 메뉴얼
    레퍼런스

    2024년 2월 7일 수요일

    대중화된 멀티모달 생성AI 모델, Stable Diffusion 아키텍처 분석과 동작 원리 이해

    이 글은 Stable Diffusion(스테이블 디퓨전) 아키텍처를 분석하고, 핵심 개념과 모델을 이해한다. 스테이블 디퓨전은 기존에 연구된 멀티모달(Multi Modal) 딥러닝 아키텍처인 CLIP(OpenAI), 노이즈 확산(디퓨전) 및 역확산 시뮬레이션 모델, 컴퓨터 비전에서 사용되던 U-Net, 오토인코더를 통한 데이터 압축과 잠재공간(Latent Space)차원 연산 등을 적극 사용해, Text to Image 생성AI(Gen AI) 기술을 구현한다. 

    스테이블 디퓨전은 text-to-image 생성AI 모델로 RunwayML, Stavility AI의 지원을 받은 뮌헨하이델베르그 대학 CompViz 그룹에서 개발해 동작 원리와 소스 전체를 공개하였다(2022.8. GitHub). 이 모델은 대중의 폭팔적인 관심을 끌며 대중에 생성AI를 각인시켰고, 이 기술을 이용한 멀티모달 생성AI(Gen AI)기술 투자의 기폭제가 되었다. 2023년에는 빅테크 기업 OpenAI, Microsoft, Google, Meta(Facebook), NVIDIA가 생성AI에 큰 투자를 했고, 그 결과 우리는 Microsoft CoPilot(LLM. Text-Image), OpenAI의 ChatGPT4(LLM. Text-Image), SORA(Text-Video), Google Gemini, Facebook LLAMA(LLM), ImageBind(Text-Image-Sound-Video)와 같은 생성AI 기술을 사용할 수 있게 되었다.

    스테이블 디퓨전은 다음 그림과 같이 디퓨전 모델, U-Net, 오토인코더(Autoencoder), 트랜스포머(Transformer) 어텐션 모델(Attention)을 사용해, 학습한 모델을 역으로 계산해 주어진 텍스트 조건에서 이미지가 생성될 수 있도록 한다.
    스테이블 디퓨전 아키텍처 기반 Text To Image 생성(Inference) 및 학습 과정

    이 모델은 기존 자연어 처리 분야에서 개발된 트랜스포머, 컴퓨터 비전 딥러닝 모델 기술을 적극 사용한다. 이와 관련된 내용을 깊게 이해하고 싶다면 다음을 참고한다. 
    이 글은 멀티모달, 디퓨전과 관련된 다양한 문헌을 참고해 정리된 것이다. 관련 내용은 이 글의 레퍼런스를 참고한다.

      아키텍처 구조
      스테이블 디퓨젼은 기존에 개발된 디퓨젼(Diffusion), 벡터 임베딩(Vector Embedding), U-NetVAE(Variational Autoencoders), 오토인코더, 트랜스포머와 CLIP을 활용해 개발된 것이다. 스테이블 디퓨전 2는 기존 CLIP을 그대로 사용하지 않고, 입력된 텍스트를 조건으로 파라메터로 조정할 수 있도록 하였다(컨디셔닝 기법 적용). 이로 인해, 입력 텍스트에 따라 이미지 스타일을 변화시킬 수 있다. 다음 그림은 스테이블 디퓨전의 핵심기술요소만 보여준다. 
      스테이블 디퓨전 아키텍처

      스테이블 디퓨전에 텍스트가 입력되면, 텍스트 인코더가 특징을 가진 임베딩 벡터로 변환한다. 아울러, 이미지-노이즈 데이터셋으로 학습된 디퓨전 모델이 사용되어, 텍스트 임베딩 벡터와 압축된 이미지 벡터가 잠재공간(Latent Space)에 표현된다. 트랜스포머를 이용해, 유사도가 제일 큰(잠재공간에서 가까운 텍스트-이미지 쌍) 텍스트-이미지 임베딩 벡터 쌍을 계산한다. 이 결과로 이미지 특징 벡터를 얻고, 오토인코더로 특징 벡터를 이미지로 변환한다. 

      참고로, 유명한 딥러닝 플랫폼 개발사인 MosaicML에서 공개한 스테이블 디퓨전 개발 과정(2023.4.26. 참고-MosaicML의 Stable Diffusion 소스코드)은 의미있는 정보를 제공한다. 이 사례에서 Stable Diffusion 개발 시 GPU 사용 비용은 NVIDIA A100 GPU x 100개 x 1.6일, x 4.9일 동안 550,000 학습량이다. 총 23,835 A100 시간이 필요하다. 100TB 데이터셋이 학습에 사용되었다. 이를 비용으로 환산하면 $47,700 (약 6천만원) 정도가 된다. 즉, 그라운드 제로에서 스테이블 디퓨전 모델을 학습한다는 것은 어느 정도 규모와 자원이 있어야 한다는 것을 의미한다(이 과정을 Automatic1111의 WebUI와 같은 도구를 이용해 스테이블 디퓨전 모델을 커스텀 데이터셋으로 전이학습하는 것과 혼동하지 말자).

      디노이즈와 디퓨전
      스테이블 디퓨전(Diffusion)에서 디퓨전은 텍스트에서 이미지를 생성하는 변환기 역할을 한다. 디퓨전 모델을 사용하여, 기존 생성AI 모델인 GAN(Generative Adversarial Network)과 같이 암시적으로 데이터를 학습하지 않고, 점진적이고 안정적으로 노이즈를 지워 새로운 샘플 이미지를 생성한다. GAN방식 대신 디퓨전을 사용하고자 했던 개발자들의 핵심 질문은 다음과 같다.

      어떻게 무작위 이미지(데이터)에서 안정적이고 고품질 이미지를 생성, 학습할 수 있을 까? 어떻게 랜덤하게 분산된 데이터를 분산되기 전의 모습으로 되돌릴 수 있을까?

      이 질문은 어떻게 생성해야할 이미지의 통계적 분포를 효과적으로 학습할 수 있는가를 묻고 있는 것이다. 기존의 GAN은 생성자, 판별자를 통해 암시적으로 이 분포를 학습하였으나, 오버피팅(Overfitting), 모드 붕괴(Mode Collapse. 다양한 입력 데이터 특징이 학습되지 않아, 치우친 결과만 얻는 현상)와 같은 불안정한 부분이 있었다. GAN은 학습을 통해 안정적으로 해를 수렴한다고 보장하기 어렵다. GAN은 입력 데이터의 통계적 분포가 정규분포같이 다양성이 있지 않으면 붕괴(발산)될 수 있다. 
      GAN의 모드 붕괴 현상 (MNIST 데이터 학습이 계속되어도 입력에 대해 동일한 결과만 얻음)

      스테이블 디퓨전은 여기에 다음 질문을 덧붙인다. 

      어떻게 생성될 이미지의 조건을 텍스트로 조정할 수 있을까? 

      이건 멀티모달리티(Multi-modality) 조건을 어떻게 구현할 수 있는가? 즉, 텍스트 입력 조건에 따라 연관된 다른 형태의 데이터를 어떻게 생성할 수 있느냐는 것이다.

      우선, 첫번째 질문은 디퓨전(확산) 현상과 관계된다. 디퓨전은 노이즈의 확산(디퓨전)을 의미한다. 이와 유사한 연구가 오래전 물리학 분야에서 있었다. 노이즈가 이미지 전체에 확산되는 과정은 분자운동에 의해 충돌하며 퍼지는 엔트로피 현상과 유사하다(브라운 운동으로 알려져 있음). 물리학자는 이 현상을 확산 물질 밀도가 변화하는 속도를 시간과 위치에 의한 편미분함수로 정의할 수 있음을 증명했다. 이 함수는 미세입자 동역학 해석을 위해 개발된 랑주뱅 동역학 모델(Langevin equation, 1908)에서 설명되었다(아래 자료 참고. 특정 상황에서는 열 방정식으로 해석). 
      스테이블 디퓨전은 이 개념을 생성AI에 사용한다. 디퓨전 모델은 다음의 순방향 노이즈 확산 방정식으로 시작한다.
      여기서, 
      x(t) = 잡음
      t = 확산시간
      σ(t) = 잡음강도
      Δt = 확산단위시간
      r≈N(0,1) = 표준 정규 확률 변수

      방정식의 의미는 단위시간이 지날수록 이전 잡음 수준에 비례하고, 표준정규확률에 따라 잡음강도가 비례하여, 노이즈가 증가한다는 것이다(당연하다). 만약, 1차원 공간에서 이를 그대로 코딩한다면, 다음과 같을 것이다.  

      x[i + 1] = x[i] + random_normal * noise_strength
       
      이를 역방향으로 되돌릴 수 있다면, 임의의 노이즈된 데이터에서 원본 이미지를 생성할 수 있다. 

      이를 위해, 전방향 확산 방정식을 미분방정식으로 변환하여 역확산 방정식을 유도한다(딥러닝 모델의 가중치는 함수의 미분값을 반복적으로 조정하는 과정임을 떠올리자). 이를 수식으로 표현하면 다음과 같다(유도과정 참고).

      수식은 전체 고정된 T 시간을 가정해, 유한하게 계산되도록 한다. 수식 의미는 다음과 같다. 

      t + Δt 의 잡음 x()는 이전 시간의 잡음 x(t)에 비례한다(첫번째항). 아울러, 잡음강도 σ(T - t) 제곱에 비례하는 데, 이는 잡음 확률을 나타내는 함수 log p(x) 함수의 미분값과 Δt에 비례한다(두번째항). 또한, 잡음강도 σ(T - t), Δt, 정규확률분포 r에 비례한다. 두번째 항을 간략히 표현하면, s(x, t) 함수로 정리된다. 여기서, s(x, t) 함수를 diffusion score 함수라 한다.

      다음 그림은 x함수 값의 전방향, 역방향 확산 과정을 t시간에 따라 출력한 것이다.  
      전방향(좌), 역방향(우) 확산 함수 x(t) 결과(Score-Based Generative Modeling through Stochastic Differential Equations)

      전확산, 역확산 시뮬레이션은 다음과 같이 구현 될 수 있다. 
      def noise_strength_constant(t):  # 노이즈 강도 함수 리턴값은 1로 설정
          return 1

      def forward_diffusion_1D(x0, noise_strength_fn, t0, nsteps, dt):
          x = np.zeros(nsteps + 1)  # 시간에 대한 노이즈 샘플 흐름을 담기 위한 벡터 생성
          x[0] = x0  # 초기 x0 샘플 설정
          t = t0 + np.arange(nsteps + 1) * dt  # 샘플링되는 t시간 계산

          for i in range(nsteps):  # 디퓨전 시뮬레이션(Euler-Maruyama 식 사용)
              noise_strength = noise_strength_fn(t[i])  # t시간 잡음 강도 
              random_normal = np.random.randn()    # 정규분포 잡음
              x[i + 1] = x[i] + random_normal * noise_strength   # 각 시간마다 잡음 계산

          return x, t  # 잡음 벡터, 해당 시간 시점 리턴

      여기서, 
      x0: 초기 샘플 데이터값
      noise_strength_fn: 노이즈 강도 함수. 시간 t입력.
      t0: 초기 시간 단계
      nsteps: 확산 단계
      dt: 시간 미분치
      x: nstep * dt 시간의 노이즈 샘플값(벡터) 리턴
      t: nstep * dt 시간 리턴
      이다. 

      순방향 확산을 앞서 정의된 함수로 시뮬레이션해본다. 
      nsteps = 100 # 확산 최대 단계 N
      t0 = 0    # 초기 시간
      dt = 0.1  # 단위 시간
      noise_strength_fn = noise_strength_constant  # 노이즈 강도
      x0 = 0   # 초기 샘플링 값
      num_tries = 7  # 가시화할 단계수

      # 확산 그래프 출력 
      plt.figure(figsize=(15, 5))
      for i in range(num_tries):  # 확산 시뮬레이션
          x, t = forward_diffusion_1D(x0, noise_strength_fn, t0, nsteps, dt)
          plt.plot(t, x, label=f'Trial {i+1}')  # 확산값 출력

      plt.xlabel('Time', fontsize=20)
      plt.ylabel('Sample Value ($x$)', fontsize=20)
      plt.title('Forward Diffusion Visualization', fontsize=20)
      plt.legend()
      plt.show()

      결과는 다음과 같다.

      이제 역방향 확산 함수를 구현한다. 

      앞서 설명한 바와 같이, 순방향은 정규분포를 이용할 수 있지만, 역방향은 이를 그대로 사용할 수 없어, 다음 편미분 방정식으로 근사화해 구현한다. 
      여기서, s(x, t)는 score 함수로 알려져 있다. 이를 구현할 수 있다면, 역방향 확산이 가능하다. 시작점 x0=0이고, 잡음 강도가 일정하다면, s() 함수는 다음과 같다. 

      이를 코드로 다음과 같이 구현한다.
      def score_simple(x, x0, noise_strength, t):
          score = - (x - x0) / ((noise_strength**2) * t)  # s(x,t) 스코어 함수
          return score

      def reverse_diffusion_1D(x0, noise_strength_fn, score_fn, T, nsteps, dt):
          x = np.zeros(nsteps + 1)    # 잡음 경로 벡터 리스트
          x[0] = x0   # 잡음 초기값
          t = np.arange(nsteps + 1) * dt  # 잡음 생성 시점 계산

          # Euler-Maruyama 식 사용해 역확산 시뮬레이션
          for i in range(nsteps): 
              noise_strength = noise_strength_fn(T - t[i]) # 노이즈 강도
              score = score_fn(x[i], 0, noise_strength, T - t[i]) # s(x,t) 스코어 함수
              random_normal = np.random.randn()   # 랜덤값
              x[i + 1] = x[i] + score * noise_strength**2 * dt + noise_strength * random_normal * np.sqrt(dt) # 잡음 역확산
          return x, t

      이제, 시뮬레이션하여, 출력해 본다. 
      nsteps = 100
      t0 = 0
      dt = 0.1
      noise_strength_fn = noise_strength_constant
      score_fn = score_simple
      x0 = 0
      T = 11
      num_tries = 7

      plt.figure(figsize=(15, 5))
      for i in range(num_tries):
          x0 = np.random.normal(loc=0, scale=T) # 타임T, 강도1 랜덤값
          x, t = reverse_diffusion_1D(x0, noise_strength_fn, score_fn, T, nsteps, dt)

          plt.plot(t, x, label=f'Trial {i+1}')  

      plt.xlabel('Time', fontsize=20)
      plt.ylabel('Sample Value ($x$)', fontsize=20)
      plt.title('Reverse Diffusion', fontsize=20)
      plt.legend()
      plt.show()

      역확산 효과가 잘 시뮬레이션되었다.

      이런 개념을 이용하면, 순방향 디퓨전, 역방향 디퓨전 방식을 이용해, 다양하고 복잡한 이미지(노이즈)에서 원본 이미지로 변환하거나, 반대로 역변환할 수 있다(참고. Score-Based Generative Modeling, 2011)

      확률적 미분 디퓨전 방정식(SDE. Stochastic Differential Equation) 모델(Fareed Khan, 2024. Score-Based Generative Modeling, 2011)

      이 역변환 디퓨전 방정식은 디노이징 디퓨전 모델(Denoising Diffusion Model)이라 불린다. 디노이징 디퓨전 모델은 DDPM(Denoising Diffusion Probabilistic Models)기술로 알려져 있고(Jonathan Ho et al, 2020), 스테이블 디퓨전에서 적극 사용된다(참고 - 소스코드). 
      DDPM(Denoising Diffusion Probabilistic Models. Jonathan Ho et al, 2020)

      디퓨전 함수를 이미지 데이터에 적용하면, t 시간에 따른 노이즈 양을 조정할 수 있다. 다음 그림은 그 과정을 보여준다. 디퓨전 함수에 t0를 입력하면 원본 이미지, T를 입력하면, 노이즈 이미지가 계산된다.
      노이징(Noising) 및 디노이징(Denoising) 과정 개념(Jay Alammar, 2022)

      U-Net과 디퓨전
      전방향 역방향 디퓨전 함수의 대상은 이미지가 되어야 한다. 픽셀 공간을 다른 픽셀 값으로 변환하는 것이기 때문에, 이미지의 입력 픽셀이 라벨링된 출력 픽셀로 계산되어야 한다. 그러므로, 이 기능은 이미지 세그먼테이션에 많이 사용된 U-Net 모델을 사용한다. 디퓨전 함수와 U-Net을 통해, 노이즈-이미지 간 변환 및 역변환 맵핑을 하도록 설계한다.

      디퓨전은 시간의 함수이므로, 주어진 데이터의 해를 점진적으로 학습(가중치 조정)할 수 있게 되고, 이는 스테이블한(안정적인) 학습 모델을 얻을 수 있다는 것을 의미한다. 디퓨전 함수를 이용해 각 에폭(Epoch) 당 학습할 다양한 잡음 있는 데이터셋을 만든다. s(x, t)에서 t값을 임의로 할당해, 입력 이미지에 다양한 변형을 하여, 미니배치 데이터를 생성한다. 다음 그림은 이를 보여준다. 
      UNet에 입력되는 학습 데이터셋(Steins, 2023)

      각 학습 단계는 다음과 같은 순서로 진행된다. 
      1. t 시간 단계 임베딩 벡터 변환
      2. t 시간에 따른 노이즈 이미지 생성
      3. 1, 2를 입력데이터, 노이즈를 라벨링 데이터로 하여, U-Net 모델 학습
      입력 이미지-텍스트 학습 데이터를 노이즈 이미지 라벨으로 학습하고, 역계산하는 방식으로 이미지를 생성한다. 다음 그림은 이를 보여준다.
      디퓨전에서 사용하는 U-Net 구조와 역할(Steins, 2023)

      참고로, U-Net은 입력 픽셀 값을 압축해 일반화(추상화)하여, 라벨링된 픽셀 값으로 계산하는 학습 모델로 딥러닝 컴퓨터 비전 분야에서 이미지 세그먼테이션에서 사용되었다. 다음은 32x32 이미지를 목표 라벨 이미지로 학습하기 위한 U-Net 모델을 보여준다. 일반화된 이미지 특징 학습으로 인해, 이미지 세부 특징을 놓칠 수 있어, 각 계층마다 ResNet 잔차연결을 사용한 것을 확인할 수 있다.
      U-Net과 ResNet 모델 구조(Seachaos, 2023)

      잠재공간과 오토인코더
      입력 이미지를 그대로 디퓨전과 U-Net으로 계산한다면, 이미지 크기에 따라 매우 큰 GPU 메모리와 계산 성능이 필요할 것이다. 이를 잠재공간(Latent Space)에서 표현하면, 메모리와 연산량을 크게 줄일 수 있다.

      잠재공간은 데이터 특징이 압축된 벡터를 표현하는 다차원 공간이다. 이 공간은 입력 데이터가 압축된 벡터로 차원을 가진다. VAE(Variational Autoencoder. 변분 오토인코더)를 사용하면, 데이터를 압축해 잠재공간에 위치시킬 수 있다. 잠재 공간에서 학습한다면, 원본 이미지보다 훨씬 작은 임베딩 벡터 데이터만 계산되므로, 개선된 연산 속도과 효율적인 GPU 메모리 사용이 가능하다. 이를 위해, VAE는 다음 그림과 같이 인코더-디코더 레이어를 배치하고, 잠재공간으로 압축된 텐서 출력의 평균μ, 분산σ이 정규분포를 가지는 확률분포 N(μ, σ)가 학습되도록 한다.
      VAE 오토인코더 모델과 잠재공간(Latent Space)

      오토인코더 구현은 단순히 신경망 층으로 인코더와 디코더를 만들고, 인코더의 결과가 확률분포 N(μ, σ)가 되도록 학습하는 것이다. 다음은 파이토치로 구현한 핵심 코드 일부를 보여준다. 
      class VAE(nn.Module):
          def __init__(self, input_dim=784, hidden_dim=400, latent_dim=200, device=device):
              super(VAE, self).__init__()

              self.encoder = nn.Sequential(
                  nn.Linear(input_dim, hidden_dim),
                  nn.LeakyReLU(0.2),
                  nn.Linear(hidden_dim, latent_dim),
                  nn.LeakyReLU(0.2)
                  )  # 인코더. 선형 레이어 + ReLU의 조합이다.
              
              # 잠재공간 차원(평균, 편차)로 인코더 출력을 맵핑 
              self.mean_layer = nn.Linear(latent_dim, 2)
              self.logvar_layer = nn.Linear(latent_dim, 2)
              
              self.decoder = nn.Sequential(
                  nn.Linear(2, latent_dim),
                  nn.LeakyReLU(0.2),
                  nn.Linear(latent_dim, hidden_dim),
                  nn.LeakyReLU(0.2),
                  nn.Linear(hidden_dim, input_dim),
                  nn.Sigmoid()
                  )  # 디코더는 인코더 반대로 처리함.
           
          def encode(self, x):  # 인코더 계산
              x = self.encoder(x)
              mean, logvar = self.mean_layer(x), self.logvar_layer(x)
              return mean, logvar

          def reparameterization(self, mean, var):  # 정규분포 계산
              epsilon = torch.randn_like(var).to(device)      
              z = mean + var*epsilon
              return z

          def decode(self, x):  # 디코더 계산
              return self.decoder(x)

          def forward(self, x):  
              mean, logvar = self.encode(x)  # 인코더 학습
              z = self.reparameterization(mean, logvar)  # 정규분포 계산
              x_hat = self.decode(z)            # 디코더 계산
              return x_hat, mean, logvar      # 계산결과, 평균, 분산

      def loss_function(x, x_hat, mean, log_var):  # 손실 함수 정의
          reproduction_loss = nn.functional.binary_cross_entropy(x_hat, x, reduction='sum')  # 이진 교차엔트로피 손실
          KLD = - 0.5 * torch.sum(1+ log_var - mean.pow(2) - log_var.exp())  #  Kullback-Leibler(KL) 손실 계산

          return reproduction_loss + KLD  # 두 확률분포의 거리를 최소화하려는 의도

      오토인코더를 이용해, 다음 그림과 같이 압축된 잠재공간차원의 특정 위치는 입력된 데이터를 맵핑할 수 있다.  
      잠재공간에 맵핑된(인코딩된) 데이터(Alexej Klushyn, 2019.12, Learning Hierarchical Priors in VAEs)

      잠재공간에서 디퓨젼을 수행하는 과정은 앞의 픽셀공간에서 디퓨전을 실행하는 것과 유사하다(디퓨전 방정식은 특정 데이터 유형과 무관하게 동작됨). 노이즈-이미지 간 디퓨전은 오토인코더 모델을 이용한다. 오토인코더는 인코더-디코더 쌍을 가지고 있다. 그러므로, 전방향으로 노이즈 라벨을 예측하고, 역방향으로 노이즈에서 이미지를 생성하는 것이 가능하다. 

      텍스트 인코더도 같은 방식으로 잠재공간차원에 맵핑한다. 참고로, 변환된 텍스트 벡터를 임베딩 벡터라 한다(정확히는 벡터가 아닌 텐서값).

      이제, 오토인코더로 잠재공간에 압축되어 위치된 입력 데이터에 텍스트 조건정보를 추가해 파라메터화하고, 이에 따라 이미지가 생성될 수 있도록 U-Net모델을 학습한다. 이를 컨디셔닝이라 한다. 컨디셔닝은 학습 시 특정 조건을 줄 수 있는 파라메터(예. 텍스트)를 포함한다. 
      잠재공간에서 디퓨전 컨디셔닝 처리(Steins, 2023)

      입력 데이터(이미지)와 텍스트를 잠재 공간차원에서 거리가 가깝도록 유사도를 조정해야(학습해야) 하므로, 앞에 글에서 언급한 트랜스포머의 어텐션 모델을 사용한다.

      컨디셔닝은 트랜스포머 모델을 통해, 각 U-Net의 레이어 모듈에 출력과 연결되도록 한다. 이를 통해, 신경망은 입력 이미지와 텍스트에 대해 다양한 x(t)에 대한 노이즈 배치 데이터를 예측하도록 학습될 것이다. 

      학습이 끝나면, 텍스트와 노이즈를 입력해 잠재공간 차원에서 노이즈 역확산 방정식(DDPM. Denoising Diffusion Probabilistic Models. 디노이징 확률 모델)을 계산한다. 이를 통해, 텍스트 입력 조건에 따른 이미지를 생성하도록 한다. 각 U-Net 층에 트랜스포머의 어텐션 모델이 연결하여, 일반적 수준에서 세부적 수준까지 생성되는 이미지를 조건에 따라 생성하도록 한다.

      트랜스포머와 CLIP
      스테이블 디퓨전은 트랜스포머를 이용해, 멀티모달인 텍스트-이미지 데이터쌍의 유사도를 계산하는 CLIP(Contrasive Language-Image Pre-Traning. 참고)을 사용한다. CLIP을 이용하면, 'lovely cat'과 고양이 이미지 임베딩 벡터를 잠재 공간 차원에서 거리가 가까워지도록 조정할 수 있다. 즉, 다음과 같은 W 가중치를 학습할 수 있다. 
      i ≈ t·W
      여기서, 
      i=image embedding vector
      t=text embedding vector
      W=weight vector

      이때, 목적 함수는 cosine similarity(유사도)가 된다(트랜스포머 모델에서 사용된 어텐션 스코어 행렬값). CLIP을 이용해, 멀티모달 데이터셋인 텍스트-이미지를 잠재공간에 표현해, 멀티모달(Multi Modal. 쉽게 말해 이종 데이터셋) 조건에서 학습할 수 있도록 한다. 텍스트 임베딩 벡터를 이미지 생성 과정에 포함하려면, 노이즈 예측하는 U-Net 부분을 텍스트 조건에 따라 조정되도록 수정해야 한다. 다음 그림은 이 과정을 보여준다.  
      스테이블 디퓨전에서 트랜스포머 어텐션을 이용한 U-Net 컨디셔닝(Robin Rombach et al, 2022)

      앞의 그림의 QKV는 트랜스포머 어텐션 모델의 Query, Key, Value 입력을 의미한다. 여기서, Query는 조건화를 위해 텍스트 임베딩이 입력되어야 한다. K, V는 학습된 이미지 컨텍스트가 입력되어야 한다. 그럼, 어텐션 모델이 입력되는 이미지, 텍스트의 유사도를 가깝게 만들기 위해 학습 가중치를 조정할 것이다. U-Net 각 층마다 어텐션 모델을 적용하여, 개요에서 상세 수준까지 스타일을 조정할 수 있도록 컨디셔닝을 적용한다. 

      이제 컨디셔닝 된 U-Net 모델을 설계했으므로, 잠재공간에 표현된 압축 이미지 임베딩 벡터와 노이즈 양을 입력받아, ResNet (잔차연결 모델)을 이용해, 예측된 노이즈 이미지를 생성하도록 학습한다. 

      이제, 학습될 배치 데이터(입력 이미지, 이미지를 설명하는 텍스트)가 스테이블 디퓨전 모델에입력될 때 마다, 각 ResNet에서 출력되는 이미지 특징 벡터와 입력된 텍스트 벡터 간의 유사도가 가까워지도록 W값이 조정될 것이다. 각 ResNet의 레이어 층별로 어텐션 스코어가 계산되므로, 텍스트-이미지 생성 시 텍스트의 묘사에 따라서, 이미지 전체 및 디테일 생성에 조건을 줄 수 있다.

      디퓨전(확산) 모델에 따라, 입력 데이터에 대한 다양한 노이즈 이미지가 계산되도록 학습되므로, 학습 결과를 역방향 디퓨전 모델로 역계산하여, 디노이징하면 텍스트 입력에 따른 이미지를 생성할 수 있다. 
      역확산 모델에 따른 단계별 디노이징 예(Lgnacio Aristimuno, 2023.11, An Introduction to Diffusion Models and Stable Diffusion)

      다음 그림은 앞의 과정을 거쳐 개발된 스테이블 디퓨전 아키텍처를 보여준다. 그림에서 디노이징 단계(denoising step)은 스위치(switch)에 의해 역계산되는 것을 이해할 수 있다.
      스테이블 디퓨전 아키텍처(Robin Rombach et al, 2022)

      마무리
      이 글은 멀티모달 생성AI를 대중에 널리 알리게 된 딥러닝 모델인 스테이블 디퓨전의 동작 원리, 모델을 가급적 상세히 분석, 정리해 보았다. 이 과정을 통해, 멀티모달 데이터 생성 과정, 메커니즘, 활용 방법 및 한계를 확인할 수 있다. 

      앞서 보았듯이 스테이블 디퓨전 개발자들은 오토인코더를 통해 계산된 잠재공간차원에서 U-Net을 조건화하여 모델을 학습하도록 하였다. 입력 텍스트에 대한 조건화된 U-Net학습은 트랜스포머의 어텐션 모델을 적용하여 처리했다. 이런 멀티 모달리티 기능은 OpenAI의 CLIP 아키텍처를 재활용한 것이다. 잠재공간에서 조건화되고 학습될 수 있는 U-Net을 구현한다는 것은 다른 발전 가능성을 내포하고 있다. 잠재공간에 입력될 임베딩 벡터에 비디오 영상 프레임과 시간을 함께 입력해 학습할 수 있을 것이다. 조건화된 파라메터에 CLIP에서 자동생성된 비디오 캡션을 사용하거나, 생성 데이터의 형태와 스타일을 제어할 수 있는 파라메터를 텍스트처럼 입력하고 학습할 수도 있다. 

      CLIP으로 촉발된 멀티 모달리티와 스테이블 디퓨전 아키텍처의 확장 가능성은 생성AI의 기술 발전을 증폭시키고 있다.
      스테이블 디퓨전까지 생성 AI 발전 과정(History and literature on Latent (Stable) Diffusion)
      스테이블 디퓨전 모델 출력을 제어하는 컨트롤넷(ControlNet, GitHub. 2023.11)
      Text to Video 모델 아키텍처(MagicVideo, 2022.12)와 OpenAI SORA(2024.2)

      다음 글은 여기서 정리된 내용을 바탕으로 스테이블 디퓨전을 실행 가능한 코드 수준으로 구현하고, 입력 데이터가 변환 및 역변환 확산되는 과정을 직접 확인해 보도록 한다.

      스테이블 디퓨전 기술 개발 주역 Computer Vision & Learning Group (ommer-lab.com)

      레퍼런스