courses
Unsloth의 NVIDIA 협업은 숨은 학습 오버헤드를 줄여 미세 조정을 더 빠르게 만드는 데 초점을 맞춥니다. 더 큰 GPU나 더 작은 모델에만 의존하는 대신, 반복적인 메타데이터 구성, 활성화 재로딩 지연, 비효율적인 토큰 라우팅 등 학습 과정 내부의 병목을 겨냥해 개선합니다. 사용자 입장에서는 지원되는 NVIDIA GPU에서 더 빠른 학습과 매끄러운 미세 조정 워크플로우를 의미합니다.
이 가이드에서는 이러한 새로운 Unsloth 성능 개선 사항을 살펴보고, 실용적인 비전-언어 작업에 Unsloth의 옵티마이즈된 미세 조정 워크플로우를 적용합니다. Qwen3.5 Vision 4B를 의료 OCR에 맞춰 미세 조정하여, 모델이 OCR 데이터셋의 의료 문서처럼 보이는 소규모 하위 집합을 사용해 문서 이미지에서 구조화된 텍스트를 추출하도록 학습시킵니다.
이 가이드에서 사용할 구성은 다음과 같습니다.
- Qwen3.5 4B (Vision)을 기본 모델로 사용
- 4-bit QLoRA로 VRAM 사용량 절감
- LoRA 어댑터로 효율적인 미세 조정
- Unsloth 그라디언트 체크포인팅으로 학습 중 메모리 절감
- 의료 OCR 데이터셋의 300개 샘플 하위 집합
- 비전 학습을 매끄럽게 하는 고정 크기 이미지 전처리
- 학습 전후 평가로 기본 모델과 미세 조정 모델 출력 비교
Unsloth의 NVIDIA 최적화 미세 조정 워크플로우 사용하기
미세 조정을 시작하기 전에, Unsloth의 NVIDIA 협업이 무엇을 개선하는지, 그리고 본 가이드와 어떻게 연결되는지 이해해 두면 좋습니다.
Unsloth는 NVIDIA 협업을 통해 기존 2–5배 미세 조정 속도 향상에 더해 LLM 학습이 약 25% 더 빨라진다고 보고합니다. 이는 모델의 학습 목적을 바꾸지 않고, 핵심 학습 과정 주변의 숨은 오버헤드를 줄여 얻는 이득입니다. 즉, 정확도를 유지하면서 미세 조정을 더 빠르고 효율적으로 만드는 것이 목표입니다.

향상된 학습 성능
이번 협업에서 보고된 성능 개선은 다음과 같습니다.
- 패킹된 시퀀스 메타데이터 캐싱으로 Qwen3-14B QLoRA SFT 벤치마크에서 배치당 14.3% 가속
- 더블 버퍼링 비동기 그라디언트 체크포인팅으로 8B 모델 8.4%, 14B 모델 6.7%, 32B 모델 4.6% 속도 향상
- GPT-OSS MoE 학습에서 약 10–15% 가속, 타깃 라우팅 경로에서 순전파 23% 및 역전파 13% 가속
Unsloth와 NVIDIA 협업의 가장 큰 성능 향상 중 일부는 패킹된 텍스트 전용 학습과 Mixture-of-Experts 모델에 적용됩니다. 이번 가이드에서는 Qwen3.5 Vision OCR 미세 조정에 초점을 맞추므로 해당 기법들은 사용하지 않습니다.
이 가이드에서는 NVIDIA RTX 3090 GPU를 사용합니다. 따라서 워크플로우는 NVIDIA GPU 가속과 Unsloth의 최적화된 미세 조정 경로를 중심으로 구성됩니다. 다른 트레이너와의 벤치마킹을 진행하지 않으므로, 이 가이드를 보고된 속도 향상의 독립적인 증거로 해석해서는 안 됩니다. 대신 Unsloth의 최적화된 미세 조정 워크플로우를 실제 비전-언어 작업에 적용해 봅니다.
그라디언트 체크포인팅
이번 워크플로우에서 가장 관련성이 높은 최적화는 Unsloth의 그라디언트 체크포인팅입니다. 이는 모든 활성화를 GPU 메모리에 저장할 필요를 줄여 학습 중 메모리 사용량을 낮춥니다. 이미지 입력과 텍스트 출력을 모두 처리해야 하는 비전-언어 미세 조정에서 특히 유용합니다.
1. 더 빠른 미세 조정을 위한 Unsloth 설정
이 가이드를 실행하려면 NVIDIA GPU에 접근할 수 있어야 합니다. RunPod, Vast.ai 또는 기타 클라우드 GPU 제공업체에서 임대할 수 있습니다. 보통 빠르고 안정적인 RunPod를 먼저 시도했으나, 당시에는 RTX 3090 옵션이 제한적이었습니다. 그래서 이번 워크플로우에는 Vast.ai RTX 3090 GPU 머신을 사용했습니다.
플랫폼별 비교는 the best GPU cloud providers 가이드를 참고하세요.

인스턴스를 실행한 뒤 Jupyter Notebook을 열고 새 노트북을 만들었습니다. Vast.ai에서는 시스템 수준 의존성에 영향을 주지 않고 노트북 환경에서 필요한 Python 패키지를 설치하기 위해 사용 가능한 메인 환경 커널을 선택했습니다.
필수 패키지 설치
먼저 Unsloth, PyTorch, 비전 모델 학습, 데이터셋 로딩, Hugging Face 연동을 위한 필수 패키지를 설치합니다.
!pip install --upgrade \
"torch>=2.8.0" "triton>=3.4.0" \
numpy pillow torchvision bitsandbytes \
unsloth "unsloth_zoo>=2026.4.6" \
"datasets>=4.0.0" huggingface_hub hf_transfer pandas \
transformers==5.2.0 torchcodec timm
이 패키지 목록은 공식 Unsloth 노트북 설정을 바탕으로 하며, Qwen3.5 Vision 로드, 이미지-텍스트 데이터 준비, Unsloth로 모델 미세 조정에 필요한 주요 라이브러리를 포함합니다.
CUDA 디바이스 구성
다음으로 CUDA 디바이스를 구성하고 올바른 NVIDIA GPU가 사용 가능한지 확인합니다. 이 가이드는 RTX 3090을 사용하므로, CUDA 활성화 여부, 선택된 GPU, CUDA와 PyTorch 버전, 그리고 본 실험에 충분한 VRAM이 있는지를 확인합니다.
import os
import platform
CUDA_DEVICE_INDEX = 0
TARGET_GPU_NAME = "3090"
# Must be set before CUDA / Unsloth are initialized. Restart the kernel if you change these.
os.environ["CUDA_VISIBLE_DEVICES"] = str(CUDA_DEVICE_INDEX)
# RunPod + Qwen3.5 Vision OCR can hit Torch Dynamo fullgraph recompile limits.
# This disables Unsloth's torch.compile path while keeping Unsloth model loading,
# LoRA, gradient checkpointing, collator, and 8-bit optimizer benefits.
os.environ["UNSLOTH_COMPILE_DISABLE"] = "1"
os.environ["TORCH_COMPILE_DISABLE"] = "1"
import torch
DEVICE = torch.device("cuda:0")
print("Python:", platform.python_version())
print("PyTorch:", torch.__version__)
print("CUDA available:", torch.cuda.is_available())
if not torch.cuda.is_available():
raise RuntimeError("CUDA is not available. Select a GPU instance before continuing.")
torch.cuda.set_device(0)
props = torch.cuda.get_device_properties(0)
gpu_name = torch.cuda.get_device_name(0)
total_gpu_memory_gb = props.total_memory / 1024**3
print("Selected device:", DEVICE)
print("GPU:", gpu_name)
print("CUDA version:", torch.version.cuda)
print("BF16 supported:", torch.cuda.is_bf16_supported())
print("Total GPU memory:", round(total_gpu_memory_gb, 2), "GB")
if TARGET_GPU_NAME not in gpu_name:
raise RuntimeError(f"Expected an RTX {TARGET_GPU_NAME}, but CUDA device 0 is: {gpu_name}")
if total_gpu_memory_gb < 20:
raise RuntimeError(f"Expected a 24 GB class 3090, but only found {total_gpu_memory_gb:.2f} GB VRAM.")
제 환경에서는 다음과 같은 GPU 구성이 반환되었습니다.
Python: 3.12.13
PyTorch: 2.12.0+cu130
CUDA available: True
Selected device: cuda:0
GPU: NVIDIA GeForce RTX 3090
CUDA version: 13.0
BF16 supported: True
Total GPU memory: 23.56 GB
이는 노트북이 충분한 VRAM을 갖춘 NVIDIA GeForce RTX 3090에서 실행 중임을 확인해 줍니다.
학습 설정과 프롬프트 정의
GPU를 확인한 후 모델, 데이터셋, 학습 설정, 출력 디렉터리, 이미지 크기, OCR 프롬프트를 정의합니다.
MODEL_NAME = "unsloth/Qwen3.5-4B"
DATASET_NAME = "naazimsnh02/medocr-vision-dataset"
SAMPLE_COUNT = 300
EVAL_INDEX = 0
MAX_LENGTH = 4096
MAX_STEPS = 30
PER_DEVICE_BATCH_SIZE = 4
GRADIENT_ACCUMULATION_STEPS = 2
LEARNING_RATE = 2e-4
SEED = 3407
OUTPUT_DIR = "outputs/qwen35_vision_medical_ocr"
ADAPTER_DIR = "qwen35-vision-medical-ocr-lora"
# Medical document images vary heavily in size. Fixed-size canvases avoid
# repeated Torch Dynamo recompiles during vision training.
# 768x1024 is a practical portrait-page compromise for a 24 GB 3090 smoke test.
FIXED_IMAGE_SIZE = (768, 1024)
# Official Unsloth Qwen3.5 Vision notebook uses False here for 16-bit LoRA.
# Set True only if you hit VRAM limits.
LOAD_IN_4BIT = True
SYSTEM_PROMPT = "You are a medical OCR transcription engine. Return only the exact text visible in the medical document image."
INSTRUCTION = "Extract all readable text from this medical document exactly. Preserve structure when possible. Return only the OCR text, with no explanation, no diagnosis, no medical advice, and no reasoning."
여기서는 Unsloth의 Qwen3.5 Vision 4B와 의료 OCR 비전 데이터셋을 사용합니다. 본 가이드에서는 300개 샘플을 선택해 30스텝 학습하여, 가볍게 실행하면서도 모델이 목표 OCR 형식에 적응하는 모습을 확인합니다.
768×1024의 고정 이미지 크기는 학습 중 이미지 입력을 일관되게 유지하는 데 도움이 됩니다. 의료 문서는 해상도와 종횡비가 크게 다를 수 있으므로, 고정 캔버스로 리사이즈하면 워크플로우가 매끄러워지고 비전-언어 미세 조정 중 형태 관련 이슈를 줄일 수 있습니다.
2. 모델 로딩
환경 준비가 끝났다면 Unsloth의 FastVisionModel을 사용해 Qwen3.5 Vision 4B 모델을 로드합니다.
import unsloth
from unsloth import FastVisionModel
torch.cuda.set_device(0)
model, tokenizer = FastVisionModel.from_pretrained(
MODEL_NAME,
load_in_4bit=LOAD_IN_4BIT,
use_gradient_checkpointing="unsloth",
)
print("Loaded:", MODEL_NAME)
print("4-bit:", LOAD_IN_4BIT)
print("Model device:", next(model.parameters()).device)
모델을 로드한 후 출력에서 올바른 모델이 로드되었고, 4-bit 모드가 활성화되었으며, 모델이 GPU에 배치되었음을 확인할 수 있습니다.
Loaded: unsloth/Qwen3.5-4B
4-bit: True
Model device: cuda:0
여기서 FastVisionModel.from_pretrained()는 비전-언어 모델을 로드하고, 더 빠르고 메모리 효율적인 미세 조정을 위한 Unsloth 최적화를 적용합니다. 또한 load_in_4bit을 활성화해 모델을 4-bit 정밀도로 로드하여 VRAM 사용량을 줄입니다. RTX 3090과 같은 24GB GPU를 사용할 때 유용합니다.
또한 use_gradient_checkpointing="unsloth"로 Unsloth 그라디언트 체크포인팅을 활성화했습니다.
이는 학습 중 메모리 사용량을 줄여 주며, 이미지와 텍스트 입력을 동시에 처리하는 비전-언어 모델에 특히 중요합니다.
3. LoRA 어댑터 추가
다음으로 모델에 LoRA 어댑터를 추가합니다. LoRA를 사용하면 전체 모델을 업데이트하는 대신 더 적은 수의 학습 가능한 파라미터만 미세 조정할 수 있습니다. 이를 통해 학습이 더 빠르고 메모리 효율적이며 단일 GPU에서도 실행이 수월해집니다.
model = FastVisionModel.get_peft_model(
model,
finetune_vision_layers=True,
finetune_language_layers=True,
finetune_attention_modules=True,
finetune_mlp_modules=True,
r=16,
lora_alpha=16,
lora_dropout=0,
bias="none",
random_state=SEED,
use_rslora=False,
loftq_config=None,
)
이번 가이드에서는 비전과 언어 양쪽 구성요소에 어댑터를 추가합니다. 이렇게 하면 모델이 의료 문서 이미지를 읽고 예상되는 구조화된 OCR 텍스트를 생성하는 방법을 더 잘 학습합니다. 이 단계가 끝나면 모델은 의료 OCR 데이터셋으로 학습할 준비가 됩니다.
4. 의료 OCR 데이터셋 로드
이제 Hugging Face에서 의료 OCR 데이터셋을 로드하고, 미세 조정을 위한 작은 하위 집합을 준비합니다.
from datasets import load_dataset
from PIL import Image
raw_dataset = load_dataset(DATASET_NAME, split="train")
MEDICAL_KEYWORDS = [
"doctor", "dr.", "clinic", "hospital", "patient", "medication",
"medications", "prescription", "signature", "department", "report",
"diagnosis", "lab", "laboratory", "blood", "hemoglobin", "mg", "dose",
"<s_ocr>",
]
데이터셋에는 문서 이미지와 해당 OCR 텍스트가 포함되어 있습니다. 이번 가이드에서는 의료 스타일의 OCR 예시만 사용하고자 하므로 간단한 키워드 기반 접근으로 데이터셋을 필터링합니다. doctor, clinic, patient, medication, prescription, diagnosis, 복용량 관련 단어 등 의료 문서에서 흔히 보이는 용어를 검색합니다.
def looks_medical(sample):
text = str(sample.get("text", "")).lower()
return any(keyword in text for keyword in MEDICAL_KEYWORDS)
medical_indices = []
for idx, sample in enumerate(raw_dataset):
if looks_medical(sample):
medical_indices.append(idx)
if len(medical_indices) >= SAMPLE_COUNT:
break
if not medical_indices:
raise RuntimeError("No medical-looking OCR samples found. Broaden MEDICAL_KEYWORDS or inspect the dataset text field.")
print(f"Selected {len(medical_indices)} medical-looking samples.")
이 방법은 의료 OCR 작업과 관련 있어 보이는 예시를 가볍게 선별하는 데 도움이 됩니다. 이번 실행에서는 의료 스타일의 샘플 300개를 선택합니다.
다음으로 각 이미지를 768×1024 고정 캔버스로 정규화합니다. 의료 문서 이미지는 크기와 종횡비가 제각각이므로, 이 단계가 학습 데이터를 더 일관되게 만들어 줍니다. 이미지는 원본 종횡비를 유지하며 리사이즈된 뒤, 흰색 배경 위에 배치됩니다.
def normalize_ocr_image(image, size=FIXED_IMAGE_SIZE):
image = image.convert("RGB")
target_w, target_h = size
scale = min(target_w / image.width, target_h / image.height)
new_w = max(1, int(image.width * scale))
new_h = max(1, int(image.height * scale))
resized = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
canvas = Image.new("RGB", size, "white")
left = (target_w - new_w) // 2
top = (target_h - new_h) // 2
canvas.paste(resized, (left, top))
return canvas
datasets.map을 사용하는 대신, 간단한 Python 리스트를 수동으로 구축합니다. 이는 일부 클라우드 노트북 환경에서 PIL 이미지를 재작성할 때 발생할 수 있는 멈춤 문제를 피하기 위함입니다.
dataset = []
for idx in medical_indices:
sample = raw_dataset[idx]
dataset.append(
{
"image": normalize_ocr_image(sample["image"]),
"text": sample["text"],
}
)
print("Examples:", len(dataset))
print("Columns:", list(dataset[0].keys()))
print("Fixed image size:", dataset[EVAL_INDEX]["image"].size)
print("Sample text:", dataset[EVAL_INDEX]["text"])
전처리 후 각 예시는 정규화된 이미지와 목표 OCR 텍스트의 두 필드를 포함합니다.
Examples: 300
Columns: ['image', 'text']
Fixed image size: (768, 1024)
Sample text: <s_ocr> doctor_name: Dr. A. Smith clinic_name: Meadowview Health clinic_address: 45 Oak Ave. patient_name: John Doe patient_age: 35 date: 2024-12-16 medications: - Hydrochlorothiazide 25 mg - Before meals signature: Dr. A. Smith </s>
리사이즈된 예시를 미리 볼 수도 있습니다.
dataset[EVAL_INDEX]["image"].resize((384, 512))
미리보기에서는 병원 정보, 의사 이름, 환자 정보, 약물, 서명 등이 포함된 의료 스타일 문서 이미지가 보입니다. 이는 데이터셋이 OCR 미세 조정 작업에 적합함을 확인해 줍니다.

5. 샘플을 비전 대화 형식으로 변환
데이터셋을 로드하고 이미지를 정규화했으므로, 이제 각 예시를 Qwen3.5 Vision이 기대하는 대화 형식으로 변환해야 합니다.
각 학습 샘플에는 다음 세 부분이 포함되어야 합니다.
- 모델의 역할을 의료 OCR 전사 엔진으로 정의하는 시스템 메시지
- 이미지와 OCR 지시문이 포함된 사용자 메시지
- 예상되는 OCR 출력이 포함된 어시스턴트 메시지
def build_ocr_messages(image=None, target_text=None, instruction=INSTRUCTION):
user_content = [
{"type": "image"},
{"type": "text", "text": instruction},
]
if image is not None:
user_content[0]["image"] = image
messages = [
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]},
{"role": "user", "content": user_content},
]
if target_text is not None:
messages.append(
{
"role": "assistant",
"content": [{"type": "text", "text": target_text}],
}
)
return messages
위 헬퍼 함수는 학습과 추론 모두에 사용할 메시지 구조를 생성합니다. 학습 중에는 어시스턴트 응답으로 목표 OCR 텍스트를 포함합니다. 추론 중에는 이미지와 지시문만 제공하고, 모델이 OCR 텍스트를 생성하도록 합니다.
다음으로 모든 데이터셋 샘플을 이 대화 형식으로 변환합니다.
def convert_to_conversation(sample):
return {
"messages": build_ocr_messages(
image=sample["image"],
target_text=sample["text"],
)
}
converted_dataset = [convert_to_conversation(sample) for sample in dataset]
converted_dataset[0]
변환 후 각 샘플은 메시지 리스트를 포함합니다. 첫 번째 예시는 시스템 프롬프트, 의료 문서 이미지, OCR 지시문, 예상되는 구조화된 OCR 전사를 담고 있습니다. 이 형식은 모델이 이미지와 지시문을 올바른 텍스트 출력으로 매핑하는 방법을 학습하도록 해 줍니다.
{'messages': [{'role': 'system',
'content': [{'type': 'text',
'text': 'You are a medical OCR transcription engine. Return only the exact text visible in the medical document image.'}]},
{'role': 'user',
'content': [{'type': 'image',
'image': <PIL.Image.Image image mode=RGB size=768x1024>},
{'type': 'text',
'text': 'Extract all readable text from this medical document exactly. Preserve structure when possible. Return only the OCR text, with no explanation, no diagnosis, no medical advice, and no reasoning.'}]},
{'role': 'assistant',
'content': [{'type': 'text',
'text': '<s_ocr> doctor_name: Dr. A. Smith clinic_name: Meadowview Health clinic_address: 45 Oak Ave. patient_name: John Doe patient_age: 35 date: 2024-12-16 medications: - Hydrochlorothiazide 25 mg - Before meals signature: Dr. A. Smith </s>'}]}]}
6. 미세 조정 전 기본 모델 평가
학습 전에 기본 모델을 OCR 예시 하나로 테스트해 봅니다. 이렇게 하면 미세 조정 전후 모델 출력을 비교할 기준점을 얻을 수 있습니다.
먼저 모델의 채팅 템플릿을 적용하는 헬퍼 함수를 정의합니다. 일부 토크나이저 버전은 enable_thinking=False를 지원하지만, 일부는 지원하지 않으므로 코드 호환성을 위해 폴백을 포함합니다.
def render_ocr_chat_template(tokenizer, messages):
try:
return tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
return tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
다음으로 생성 함수를 정의합니다. OCR 프롬프트를 구성하고, 이미지와 텍스트 지시문을 토크나이저에 전달하여 모델 출력을 생성한 뒤, 새로 생성된 토큰만 디코딩합니다.
def generate_ocr_text(model, tokenizer, image, instruction=INSTRUCTION, max_new_tokens=512):
messages = build_ocr_messages(instruction=instruction)
input_text = render_ocr_chat_template(tokenizer, messages)
inputs = tokenizer(
images=image,
text=input_text,
add_special_tokens=False,
return_tensors="pt",
).to(DEVICE)
with torch.inference_mode():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
use_cache=True,
do_sample=False,
temperature=None,
top_p=None,
)
prompt_length = inputs["input_ids"].shape[-1]
generated_tokens = outputs[:, prompt_length:]
return tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)[0]
이제 모델을 추론 모드로 전환하고 첫 번째 평가 이미지에 대해 OCR 텍스트를 생성합니다.
FastVisionModel.for_inference(model)
eval_image = dataset[EVAL_INDEX]["image"]
base_output = generate_ocr_text(model, tokenizer, eval_image)
print("Target:")
print(dataset[EVAL_INDEX]["text"])
print("\nBase model output:")
print(base_output)
기본 모델 출력은 읽을 수 있지만, 목표 구조를 정확히 따르지는 않습니다.
Target:
<s_ocr> doctor_name: Dr. A. Smith clinic_name: Meadowview Health clinic_address: 45 Oak Ave. patient_name: John Doe patient_age: 35 date: 2024-12-16 medications: - Hydrochlorothiazide 25 mg - Before meals signature: Dr. A. Smith </s>
Base model output:
Meadowview Health
45 Oak Ave.
Prescribed by: Dr. A. Smith
Date: 2024-12-16
Patient: John Doe, Age: 35
Hydrochlorothiazide 25 mg - Before meals
Signature: Dr. A. Smith
이는 유용한 시작점입니다. 기본 모델은 이미 문서의 많은 부분을 읽을 수 있지만, 데이터셋에서 사용하는 구조화된 형식이 아니라 자연스러운 OCR 스타일로 출력을 제공합니다. 미세 조정을 통해 목표 형식과의 정렬이 개선되고 응답의 일관성이 높아질 것입니다.
7. 모델 학습
데이터셋이 올바른 비전 대화 형식으로 준비되었으므로, 이제 TRL의 SFTTrainer와 Unsloth의 비전 데이터 콜레이터를 사용해 모델을 학습합니다.
from unsloth.trainer import UnslothVisionDataCollator
from trl import SFTTrainer, SFTConfig
FastVisionModel.for_training(model)
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
data_collator=UnslothVisionDataCollator(model, tokenizer),
train_dataset=converted_dataset,
args=SFTConfig(
per_device_train_batch_size=PER_DEVICE_BATCH_SIZE,
gradient_accumulation_steps=GRADIENT_ACCUMULATION_STEPS,
warmup_steps=5,
max_steps=MAX_STEPS,
learning_rate=LEARNING_RATE,
logging_steps=1,
optim="adamw_8bit",
weight_decay=0.001,
lr_scheduler_type="linear",
seed=SEED,
output_dir=OUTPUT_DIR,
report_to="none",
remove_unused_columns=False,
dataset_text_field="",
dataset_kwargs={"skip_prepare_dataset": True},
max_length=MAX_LENGTH,
),
)
trainer_stats = trainer.train()
먼저 FastVisionModel.for_training(model)로 모델을 학습 모드로 전환합니다. 그런 다음 변환된 OCR 데이터셋으로 트레이너를 생성합니다.
여기서 중요한 부분은 UnslothVisionDataCollator입니다. 비전-언어 작업이므로 트레이너는 의료 문서 이미지와 목표 OCR 텍스트를 모두 올바르게 다뤄야 합니다. 콜레이터는 이 멀티모달 예시를 감독 학습 미세 조정에서 모델에 전달할 수 있도록 준비합니다.
이번 가이드에서는 디바이스당 배치 크기 4, 그라디언트 누적 2로 총 유효 배치 크기 8을 사용해 30스텝 학습합니다. 가볍게 실행하면서도 모델이 구조화된 OCR 형식에 적응해 가는 모습을 확인할 수 있습니다.

학습 중 Unsloth는 예시 수, 스텝과 배치 수, 학습 가능한 파라미터 수, 메모리 절감 기능 등 설정 정보를 출력합니다. 이번 실행에서는 역전파에 대해 더블 버퍼링이 활성화되었다고 보고되어, 그라디언트 체크포인팅 중 대기 시간을 줄이는 데 도움이 됩니다.
8. 미세 조정 모델 평가
학습 후 모델을 다시 추론 모드로 전환하고, 미세 조정 전과 동일한 평가 이미지에 대해 OCR 텍스트를 생성합니다.
FastVisionModel.for_inference(model)
fine_tuned_output = generate_ocr_text(model, tokenizer, eval_image)
print("Target:")
print(dataset[EVAL_INDEX]["text"])
print("\nBase model output:")
print(base_output)
print("\nFine-tuned output:")
print(fine_tuned_output)
미세 조정 후 모델 출력은 데이터셋의 목표 구조에 훨씬 더 가까워졌습니다.
Target:
<s_ocr> doctor_name: Dr. A. Smith clinic_name: Meadowview Health clinic_address: 45 Oak Ave. patient_name: John Doe patient_age: 35 date: 2024-12-16 medications: - Hydrochlorothiazide 25 mg - Before meals signature: Dr. A. Smith </s>
Base model output:
Meadowview Health
45 Oak Ave.
Prescribed by: Dr. A. Smith
Date: 2024-12-16
Patient: John Doe, Age: 35
Hydrochlorothiazide 25 mg - Before meals
Signature: Dr. A. Smith
Fine-tuned output:
<s_ocr> doctor_name: Dr. A. Smith clinic_name: Meadowview Health clinic_address: 45 Oak Ave. patient_name: John Doe patient_age: 35 date: 2024-12-16 medications: - Hydrochlorothiazide 25 mg - Before meals signature: Dr. A. Smith </s>
이는 미세 조정된 모델이 기대하는 OCR 응답 형식을 학습했음을 보여 줍니다. 기본 모델도 가시 텍스트의 대부분을 추출할 수 있었지만, 미세 조정 후에는 학습 데이터의 구조화된 형식을 더 일관되게 따릅니다.
또 다른 데이터셋 예시로도 테스트해 볼 수 있습니다.
EVAL_INDEX_2 = 35
eval_image_2 = dataset[EVAL_INDEX_2]["image"]
fine_tuned_output = generate_ocr_text(model, tokenizer, eval_image_2)
print("Target:")
print(dataset[EVAL_INDEX]["text"])
print("\nFine-tuned output:")
print(fine_tuned_output)
두 번째 예시에서는 기대한 구조를 따르지만, Amlodipine 대신 Amoxicillin을 생성하는 작은 OCR 오류가 있었습니다.
Target:
<s_ocr> doctor_name: Dr. C. Rossi clinic_name: Riverside Clinic clinic_address: 45 Oak Ave. patient_name: Wei Li patient_age: 70 date: 2024-12-16 medications: - Acetaminophen 20 mg - Take twice daily - Amlodipine 20 mg - After meals signature: Dr. C. Rossi </s>
Fine-tuned output:
<s_ocr> doctor_name: Dr. C. Rossi clinic_name: Riverside Clinic clinic_address: 45 Oak Ave. patient_name: Wei Li patient_age: 70 date: 2024-12-16 medications: - Acetaminophen 20 mg - Take twice daily - Amoxicillin 20 mg - After meals signature: Dr. C. Rossi </s>
이는 형식 정렬은 개선되었지만, OCR 정확도는 여전히 데이터 품질, 이미지 선명도, 학습 규모, 미세 조정 스텝 수에 좌우된다는 점을 상기시켜 줍니다. 프로덕션 OCR 시스템에서는 더 크고 다양한 데이터셋으로 학습하고 폭넓은 예시에 대해 정확도를 평가해야 합니다.
9. 미세 조정 어댑터 저장
학습이 완료되면 LoRA 어댑터와 토크나이저를 로컬에 저장합니다.
model.save_pretrained(ADAPTER_DIR)
tokenizer.save_pretrained(ADAPTER_DIR)
print("Saved adapter to:", ADAPTER_DIR)
출력에서 어댑터가 저장되었음을 확인할 수 있습니다.
Saved adapter to: qwen35-vision-medical-ocr-lora
이는 기본 모델 전체 복사본이 아니라 미세 조정된 어댑터 가중치만 저장합니다. 이후 기본 Qwen3.5-4B 모델을 다시 로드하고 이 어댑터를 적용해 미세 조정된 OCR 동작을 재사용할 수 있습니다. 저장된 모델이 가벼워 보관, 공유, 배포가 쉬워집니다.
마무리
학습 과정은 단일 NVIDIA RTX 3090에서도 가볍고 실용적이었습니다. 비전-언어 미세 조정은 일반적으로 메모리를 많이 사용하지만, 실제 VRAM 사용량은 예상보다 훨씬 낮았습니다. 최대 VRAM 사용량은 약 14GB, 평균은 9GB에 가까웠는데, Qwen3.5 Vision 모델을 미세 조정한 점을 고려하면 인상적입니다.
모델도 빠르게 적응했습니다. 몇 스텝만 지나도 출력이 목표 OCR 구조에 훨씬 가까워졌습니다. 기본 모델도 문서를 읽을 수 있었지만, 미세 조정 후에는 데이터셋 형식을 더 일관되게 따랐습니다.
다만 설정 경험이 완벽하진 않았습니다. Unsloth 설치에는 많은 시행착오가 필요했습니다. 로컬 환경, 가상 환경, CUDA 버전, 클라우드 GPU 제공업체 등 환경이 달라질수록 올바르게 구성하기가 어렵습니다.
일부 경우에는 CUDA 호환성 문제가 환경을 깨뜨릴 수 있으며, 문제를 디버깅하는 데 예상보다 시간이 오래 걸릴 수 있습니다. 클라우드 GPU 플랫폼에서 Unsloth Docker 이미지를 사용하는 것으로 시작하더라도, 환경이 처음부터 깔끔하게 동작하지 않으면 시간이 소요될 수 있습니다.
또 하나의 중요한 교훈은 모델 템플릿의 중요성입니다. 데이터셋이 올바른 채팅 또는 비전 대화 형식으로 변환되지 않으면 모델이 제대로 학습되지 않을 수 있습니다. Qwen3.5 Vision의 경우 올바른 이미지-텍스트 메시지 구조를 사용하는 것이 필수입니다. 템플릿이 올바르지 않으면 학습은 진행되더라도 실제로 작업에 적응하지 못할 수 있습니다.
전반적으로 Unsloth는 제한된 GPU 접근성을 가진 사용자가 로컬 머신이나 임대한 GPU에서 효율적으로 모델을 미세 조정하기에 유력한 선택지입니다. 메모리 사용량을 줄이고, 작은 하드웨어를 더 유용하게 만들며, 실험 속도를 높일 수 있습니다. 그러나 정기적으로 모델을 미세 조정하고 학습하는 사용자에게는 설정 복잡도가 답답할 수 있습니다. 표준 Transformers 기반 학습은 대체로 더 안정적이고 설치가 쉬우며 환경 간 재현도 간단한 편입니다.
설치 과정의 마찰이 걸림돌이라면, 수동 환경 설정 없이 Unsloth의 로컬 웹 UI에서 Qwen3.5-9B를 미세 조정하는 방법을 보여 주는 Unsloth Studio 가이드를 참고하시길 권합니다.