[LLM] MLX Framework를 이용한 LLM 미세조정 (fine-tuning)
앞서 커스텀 LLM 을 만들었지만, 명칭만 바뀌었을 뿐 실질적 내용이 바뀐 부분은 없었다. 현재 공개된 LLM은 기본 모델 조차 일반 사용 목적으로 부족함이 없기에 그냥 사용해도 괜찮지만, 꼭 필요한 내용을 담아야 하는 경우가 가끔 발생한다. RAG를 미세조정DB로 사용하는 방법도 있지만, 아무래도 성능 차이가 발생해 개인 이외의 목적으로 확장하는데 무리가 있다.
기본적으로 Python 및 PHP가 로컬에 설치되어 있다는 것을 가정했다. JSONL 형식의 훈련(Pre-trained) 데이터를 만들고, 미세 조정 모델까지 만들어보자.
A. 훈련(training) 데이터 형식
llama를 미세조정 하려면 훈련 데이터가 필요하다. 당연하겠지만, 훈련 데이터를 사용해 미세 조정된 모델을 만든다면 프롬포트만으로 더 높은 품질의 추론이 가능하다. 때문에 높은 품질의 서비스를 제공하기 위해서는 잘 정리/정의된 훈련 데이터가 핵심이라 해도 과언이 아니다. llama의 훈련 데이터는 JSONL 형식 이어야 한다. JSONL 은 한줄 단위 데이터로 구성되어 있는 JSON 파일을 말한다.
{"text":"<s>[INST] Instruction[/INST] Model answer</s>[INST] Follow-up instruction[/INST]"}
B. 프롬포트 생성
다양한 케이스가 있겠지만, 나는 최근 기사에 대한 올바른 답을 하기 원했다. llama3는 23년 2분기 까지 일부 데이터를 갖고 있다. 예를 들어 최근에 이슈가 된 SK 최태원 회장과 노소영 관장의 재판 결과를 llama 3에 물어보면 다음과 같은 추론 결과를 얻을 수 있다.
onelabs@OneLabsMLA:/opt/Ollama$ ollama run llama3:70b-text >>> SK 최태원 회장 노소영 관장 재판 결과 SK그룹의 최태원 회장이 2017년 공정거래법 위반과 관련된 재판에서 유죄 판결을 받았다는 사실은 알려져 있습니다. 그는 SK하이닉스에 대한 특혜를 주기 위해 정부 관료에게 뇌물을 준 혐의로 기소되어 이 사건으로 인해 징역 4년, 집행유예 5년을 선고받았습니다. 하지만 '노소영 관장'이라는 부분은 더 많은 정보가 필요합니다. 일반적으로 노소영은 SK그룹과 관련된 다양한 직위를 맡아온 것으로 알려져 있지만, 어떤 '관'에서 '관장'이었는지에 대한 구체적인 정보가 없습니다.
다음과 같은 답을 들으려면 어떤 과정을 거쳐야 할까?
C. 질문 생성
“질문지를 어떻게 만들 것인가?” 처음 접하는 사람은 난해 할 것이다. 명확한 건, 프롬포트의 품질이 추론의 결과로 이어지듯, 훌륭한 질문지와 답변이 뛰어난 미세조정 모델을 만들 수 있다. 질문을 만들기 어렵다면, 반대로 질문을 만들어 달라고 요청할 수 있다. 앞선 포스트에서 Ollama 에 RESTFul 로 질문을 하는 방법을 알아봤다. 같은 방법으로 질문지를 만들 수 있고, 생성된 답을 참고하여 답안지를 만들 수 있다. 이 내용을 instructions.json로 저장하자.
onelabs@OneLabsMLA:/opt/Ollama$ ollama run llama3:70b-text >>> SK 그룹 최태원 회장과 노소영씨에 대해 누구나 쉽게 물어볼 수 있는 질문 50가지를 JSON 형식으로 나열해. ... ...다음 중 하나로 시작해야 한다. : "누가", "누군가", "어떤", "어디에", "회사가", "나를 도와줘", "너는 알고 있니", "나에게 말해줄수 있니", "사용자", "회사는", "무엇에 대해" ... ...각 질문에 대한 답변이나 카테고리를 제공할 필요는 없다. 목록은 질문으로만 구성된 단일 차원 배열이어야 함. [ "누가 CEO인가요?", "누구나 이 기업의 주식을 구입할 수 있나요?", "어떤 서비스를 제공하나요?", "어디에 본사를 두고 있나요?", "회사가 언제 설립되었나요?", ...
다음 PHP 파일을 통해 JSONL을 생성할 수 있다.
<?php if ( ! file_exists( 'instructions.json' ) ) { die( 'Please provide an instructions.json file to get started.' ); } function query_ollama( $prompt, $model = 'llama3:70b', $context = '' ) { $ch = curl_init( 'http://localhost:11434/api/generate' ); curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode([ "model" => $model, "stream" => false, "prompt" => $context . $prompt ] ) ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); $response = curl_exec( $ch ); if ( $response === false ) { die( 'API call failed: ' . curl_error($ch) ); } $answer = json_decode( $response )->response; curl_close( $ch ); return trim( $answer ); } function create_valid_file() { if ( ! file_exists( 'train.jsonl' ) ) { die( 'No train.jsonl file found!' ); } // Remove 20% of the training data and put it into a validation file $train = file_get_contents( 'train.jsonl' ); $trainLines = explode( "\n", $train ); $totalLines = count( $trainLines ); $twentyPercent = round( $totalLines * 0.2 ); $valLines = array_slice( $trainLines, 0, $twentyPercent ); $trainLines = array_slice( $trainLines, $twentyPercent ); $train = implode( "\n", $trainLines) ; $val = implode( "\n", $valLines ); file_put_contents( 'train.jsonl', $train); file_put_contents( 'valid.jsonl', $val); } $json = file_get_contents( 'instructions.json' ); $instructions = json_decode( $json, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES ); $total = count( $instructions ); echo "------------------------------\n"; foreach ( $instructions as $i => $instruction ) { echo "(" . $i + 1 . "/" . $total . ") " . $instruction . "\n"; echo "------------------------------\n"; $answer = query_ollama( $instruction ); echo $answer; // for terminal output $result = [ 'text' => '<s>[INST] ' . $instruction . '[/INST] ' . $answer . '</s>' ]; $output = json_encode( $result ) . "\n"; $output = str_replace( '[\/INST]', "[/INST]", $output ); $output = str_replace( '<\/s>', "</s>", $output ); echo "\n\n------------------------------\n"; file_put_contents( 'train.jsonl', $output, FILE_APPEND ); } create_valid_file(); echo "Done! Training and validation JSONL files created.\n"
위와 같은 방법의 한계는 SK 재판과 같이 LLM이 알 수 없는 실시간성 데이터의 QNA를 만들어 낼 수 없다는 것이다. 그럼? 신문기사를 사용하면 좀더 편하게 만들 수 있다. 하지만 가비지 데이터가 많다는 단점이 있다. 참고로, 특정 질문에 대한 명확한. 응용된 답을 얻으려면 최소 50~100개의 질문/답변지가 필요하다.
D. mlx framework 설치
이 글은 ubuntu 환경에서 미세조정하는 방법을 설명하려 했지만, 워크스테이션의, 가동률이 높아 개발에 사용하는 Apple Silicon을 탑재한 개인 노트북에서 진행하기로 결정했다. M2 프로세서를 탑재한 랩톱을 위한 mlx framework 가 있다.
/opt/mlx ❯ python3 -m venv ~/.mlx /opt/mlx ❯ source ~/.mlx/bin/activate .mlx> git clone https://github.com/ml-explore/mlx-examples.git .mlx> cd lora/ .mlx> pip install torch .mlx> pip install -r requirements.txt Collecting mlx>=0.8.0 (from -r requirements.txt (line 1)) Downloading mlx-0.15.1-cp311-cp311-macosx_14_0_arm64.whl.metadata (5.0 kB) Collecting transformers (from -r requirements.txt (line 2)) Downloading transformers-4.41.2-py3-none-any.whl.metadata (43 kB) ...
E. 미세조정 개시
Training을 위한 기본 모델을 선택해야 한다. 안타깝게도 HF에 등록되어 있는 모든 모델이 미세조정을 지원하는 건 아니다. 대부분 “기본 모델” 또는 “기본 모델 + 데이터셋”으로만 구성되어 있는 경우만 이를 지원하는데, 이번 학습 테스트에 사용된 모델은 llama3-8b였다.
파라미터 설명
- –model : HuggingFace 의 모델명이어야하며, 로컬에 모델이 있는 경우 로컬 디렉터리를 지정해 사용할 수 있다.
- –data : 앞서 생성한 train.jsonl과 valid.jsonl 이 위치한 경로를 지정해야 한다.
- –batch_size : 기본값은 4이며, 2 또는 1로 지정하면 메모리 사용량을 줄일 수 있다. 낮을 수록 성능도 저하된다.
- –lora-layers : 기본값은 16이며, 8 또는 4로 낮출경우 품질이 저하되게 된다.
- –iters : 반복 횟수.
.mlx> python lora.py \ --train \ --model meta-llama/Meta-Llama-3-8B \ --data /opt/mlx/data \ --batch-size 2 \ --lora-layers 8 \ --iters 400 Loading pretrained model config.json: 100%|███████████████████████████████████████████████████████████████████████████████| 664/664 [00:00<00:00, 1.28MB/s] generation_config.json: 100%|█████████████████████████████████████████████████████████████████████| 138/138 [00:00<00:00, 841kB/s] Fetching 43 files: 2%|█▋ | 1/43 [00:00<00:18, 2.24it/s] model-00003-of-00037.safetensors: 37%|███████████████████▊ | 1.39G/3.81G [01:37<01:53, 21.4MB/s] model-00001-of-00037.safetensors: 20%|███████████▏ | 765M/3.76G [01:37<09:52, 5.06MB/s] model-00006-of-00037.safetensors: 35%|███████████████████▏ | 1.42G/4.00G [01:37<02:50, 15.1MB/s] model-00002-of-00037.safetensors: 12%|██████▋ | 482M/4.00G [01:34<16:53, 3.47MB/s] .. .. .. Loading datasets Training /opt/mlx/mlx-examples/lora/lora.py:231: RuntimeWarning: invalid value encountered in scalar divide return np.sum(all_losses) / ntokens Iter 1: Val loss nan, Val took 0.001s Iter 10: Train loss 2.062, It/sec 0.148, Tokens/sec 131.525 Iter 20: Train loss 1.928, It/sec 0.289, Tokens/sec 262.577 Iter 30: Train loss 1.686, It/sec 0.233, Tokens/sec 205.646 Iter 40: Train loss 1.396, It/sec 0.240, Tokens/sec 205.208 Iter 50: Train loss 1.116, It/sec 0.236, Tokens/sec 187.094 Iter 60: Train loss 0.926, It/sec 0.190, Tokens/sec 171.097 Iter 70: Train loss 0.702, It/sec 0.152, Tokens/sec 138.456 Iter 80: Train loss 0.552, It/sec 0.254, Tokens/sec 232.924 Iter 90: Train loss 0.414, It/sec 0.262, Tokens/sec 229.801 Iter 100: Train loss 0.321, It/sec 0.322, Tokens/sec 266.889 Iter 100: Saved adapter weights to adapters.npz. Iter 110: Train loss 0.265, It/sec 0.269, Tokens/sec 230.131 Iter 120: Train loss 0.185, It/sec 0.259, Tokens/sec 239.698 Iter 130: Train loss 0.147, It/sec 0.308, Tokens/sec 277.971 Iter 140: Train loss 0.119, It/sec 0.211, Tokens/sec 179.486 .. Iter 390: Train loss 0.072, It/sec 0.337, Tokens/sec 283.849 Iter 400: Train loss 0.062, It/sec 0.320, Tokens/sec 265.458 Iter 400: Val loss nan, Val took 0.000s Iter 400: Saved adapter weights to adapters.npz.
64GB의 메모리가 탑재된 M2 MacBook Pro 에서 진행했는데, CUDA 의 도움을 받을 수 없었기에 많은 시간이 소요되는 건 감안해야 한다. (너무 오래 걸린다면 4비트 양자화도 고려해볼만하다) 이 작업이 완료되면 미세조정된 모델이 “adapters.npz”라는 이름으로 생성된 것을 확인할 수 있다.
F. 테스트
테스트 방법은 간단하다. 동일한 질문을 2개의 모델에 던지면 된다. 우선 기본 모델 테스트는 mlx_lm.generate를 사용해야 하는데, llms 폴더에 위치하고 있다. 여기서 num-tokens 는 굳이 지정하지 않아도 되며, 기본값은 1,000이다.
“SK 최태원 회장 노소영 관장 재판 결과”
python -m mlx_lm.generate \ --model meta-llama/Meta-Llama-3-8B \ --max-tokens 1000 \ --prompt "SK 최태원 회장 노소영 관장 재판 결과"
미세조정 된 모델을 테스트하려면 어댑터 파일을 선언하면 된다. 앞서도 사용했던 lora.py를 사용하면 된다.
python lora.py \n --model meta-llama/Meta-Llama-3-8B \n --adapter-file ./fine-tuning/adapters/adapters.npz \n --num-tokens 1000 \n --prompt "SK 최태원 회장 노소영 관장 재판 결과" .mlx ❯ python lora.py --model meta-llama/Meta-Llama-3-8B \n --adapter-file ./adapters.npz \n --prompt "SK 최태원 회장 노소영 관장 재판 결과" Loading pretrained model Fetching 10 files: 100%|███████████████████████████████████████████████████████████████████████| 10/10 [00:00<00:00, 87746.95it/s] Total parameters 8175.620M Trainable parameters 1.704M Loading datasets Generating SK 최태원 회장 노소영 관장 재판 결과에 따라 노소영 아트센터 나비 관장에게 재산분할로 1조3808억원을 지급하라는 판결을 내렸다.
자, 기본 모델에서 답할 수 없었던 최신의 정보를 응답하는 것을 확인할 수 있다.
G. LLM 생성
생성된 보정파일은 실제 서비스에 사용할 수 없다. 베이스모델에 합쳐 하나의 완전체를 만들어야 한다.
.mlx ❯ python fuse.py \n --model meta-llama/Meta-Llama-3-8B \n --adapter-file ./adapters.npz \n --save-path ./Models/my-test-model Loading pretrained model Fetching 10 files: 100%|███████████████████████████████████████████████████████████████████████| 10/10 [00:00<00:00, 98689.51it/s]
이제 Models 폴더에 미세조정이 완료된 모델이 생성 되었음을 확인할 수 있다.