--- language: - ko license: apache-2.0 library_name: peft pipeline_tag: image-text-to-text base_model: unsloth/Qwen3.5-9B base_model_relation: adapter datasets: - Himedia-AI-01/pest-detection-korean tags: - lora - peft - vision - image-classification - vision-language - korean - pest-detection - agriculture - qwen - qwen3.5 - unsloth - multimodal inference: false model-index: - name: pest-detector-deploy results: - task: type: image-classification name: Korean Pest Image Classification dataset: type: Himedia-AI-01/pest-detection-korean name: Korean Pest Detection (19-class) metrics: - type: accuracy value: 0.9136 name: Accuracy (1595-sample validation) - type: f1 value: 0.9032 name: F1 (macro) - type: f1 value: 0.9134 name: F1 (weighted) - type: precision value: 0.9088 name: Precision (macro) - type: recall value: 0.9101 name: Recall (macro) --- # Pest Detector — 한국어 19분류 비전-언어 분류기 [`unsloth/Qwen3.5-9B`](https://huggingface.co/unsloth/Qwen3.5-9B) 기반 LoRA 어댑터로, [Himedia-AI-01/pest-detection-korean](https://huggingface.co/datasets/Himedia-AI-01/pest-detection-korean) 데이터셋으로 학습했습니다. 사진을 입력하면 18종의 작물 해충 또는 "정상"(해충 없음) 중 하나를 한국어로 출력합니다. | 지표 | 값 | 출처 | |---|---:|---| | 검증 정확도 (1595 샘플, FP16) | **91.36 %** | 학습 시점 평가 | | 57샘플 벤치 (FP16, 런타임 PEFT) | **84.2 %** | 아래 재현 가능한 레시피 | | 57샘플 벤치 (bnb NF4, 런타임 PEFT) | **80 %** (10샘플 프로브 기준) | FP16과 쉬운 클래스에서 비트 단위 동일 | | VRAM (FP16 + LoRA) | 약 19.5 GB | RTX A5000 / 4090 | | VRAM (bnb 4-bit + LoRA) | **약 8.7 GB** | RTX 3060 12GB / 4070 | --- ## ⚠ 배포 전에 반드시 읽어야 할 단 한 가지 **이 LoRA는 GGUF / llama.cpp / Ollama 경로로 배포할 수 없습니다.** 겉보기에는 동작할 것 같습니다 — `convert_hf_to_gguf.py`도 오류 없이 실행되고, GGUF 파일도 정상적으로 로드되며, 서버도 시작됩니다. 그러나 출력은 `\n\n\n\n?adgeadgeadge...` (토큰 ID 58659로의 퇴화 어트랙터) 가 됩니다. F16, Q5_K_M, Q4_K_M, pre-permute 적용/미적용, `linear_attn` LoRA 0으로 비우기 — 모든 조합에서 같은 증상을 확인했습니다. **원인** (그냥 동작만 시키고 싶다면 건너뛰셔도 됩니다): 이 어댑터는 `linear_attn.in_proj_qkv|in_proj_z|in_proj_a|in_proj_b|out_proj` (Qwen3.5 하이브리드 아키텍처 내 Gated DeltaNet 투영 — 전체 레이어의 75 %)를 학습 대상으로 합니다. `convert_hf_to_gguf.py:_reorder_v_heads` 는 ggml CUDA 커널이 효율적으로 `repeat`-브로드캐스트할 수 있도록 해당 텐서들의 V-row 레이아웃을 순열 변환합니다. 베이스 모델 자체는 특정 이항 연산 패턴 하에서 이 순열에 불변이지만, **LoRA 델타는 그렇지 않습니다.** 순열 후 델타가 헤드 위치를 어긋난 채 적용되어 토큰 붕괴가 발생합니다. **merge → GGUF 경로는 모두 이 구조적 결함을 그대로 물려받습니다.** **제대로 동작하는 배포 경로**는 `unsloth.FastVisionModel + peft.PeftModel.from_pretrained` (런타임 LoRA, 병합 없음, GGUF 변환 없음) 입니다. 아래의 레시피가 표준 설정입니다. --- ## 빠른 시작 (FP16, 약 20 GB VRAM) ```python from unsloth import FastVisionModel from peft import PeftModel from PIL import Image # 1. Unsloth로 베이스 로드 (transformers.AutoModelForImageTextToText 가 아닙니다 — # 학습 시점의 linear_attn forward 경로와 일치시키려면 Unsloth의 monkey-patch가 # 반드시 적용되어야 합니다). model, tokenizer = FastVisionModel.from_pretrained( "unsloth/Qwen3.5-9B", load_in_4bit=False, # 4-bit 사용 시 정확도 손실 없이 약 8.7 GB VRAM ) # 2. LoRA를 런타임 훅으로 부착. model.merge_and_unload() 는 호출하면 안 됩니다. # 병합 시 linear_attn 델타가 조용히 손상되어 출력이 "adgeadge"로 떨어집니다. model = PeftModel.from_pretrained(model, "pfox1995/pest-detector-final") # 3. 매우 중요 — 내부 모드를 추론용으로 전환합니다. 이 호출이 없으면 # 다른 모든 설정이 옳더라도 출력이 망가집니다. FastVisionModel.for_inference(model) model.eval() # 4. 추론 image = Image.open("pest.jpg").convert("RGB") image = letterbox(image, 512) # 회색 패딩 letterbox 512×512 — 아래 참조 messages = [ {"role": "system", "content": [{"type": "text", "text": SYSTEM_MSG}]}, {"role": "user", "content": [ {"type": "image", "image": image}, {"type": "text", "text": "이 사진에 있는 해충의 이름을 알려주세요."}, ]}, ] text = tokenizer.apply_chat_template(messages, add_generation_prompt=True) inputs = tokenizer(image, text, add_special_tokens=False, return_tensors="pt").to("cuda") with __import__("torch").inference_mode(): out = model.generate( **inputs, max_new_tokens=10, # 16 이상이면 안 됨 — 모델은 "<클래스>\n"을 출력하므로 더 길게 강제하면 쓰레기 use_cache=True, stop_strings=["\n"], # 모델의 자연스러운 정지 신호 tokenizer=tokenizer.tokenizer, # stop_strings를 위해 반드시 필요 ) prediction = tokenizer.decode( out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True ).strip() print(prediction) # PEST_CLASSES 중 하나 (한국어) ``` 전체 실행 가능한 스크립트(이미지 전처리, 배치 평가, 메트릭 포함)는 [`inference.py`](./inference.py) 를 참고하세요. --- ## HTTP 서버로 서빙하기 ([`server.py`](./server.py)) 위의 추론 레시피를 그대로 감싼 FastAPI 서버입니다. 검증된 모든 안전장치(`for_inference`, `enable_thinking=False`, `stop_strings=["\n"]`, letterbox 512, `max_new_tokens=10`)가 내장되어 있습니다. **엔드포인트:** | 메서드 | 경로 | 용도 | |---|---|---| | GET | `/health` | `{"status":"ok","model_loaded":true}` | | GET | `/classes` | 19개 클래스 목록 | | GET | `/` | 브라우저용 업로드 페이지 (한국어 UI) | | POST | `/classify` | multipart 파일 업로드 | | POST | `/classify_b64` | JSON `{"image":""}` | **시작:** ```bash pip install fastapi uvicorn python-multipart HF_TOKEN=... ADAPTER=pfox1995/pest-detector-deploy LOAD_IN_4BIT=true PORT=8080 \ python3 server.py ``` **클라이언트 사용 예:** ```bash curl -F file=@pest.jpg http://localhost:8080/classify # → {"pred":"검거세미밤나방","raw":"검거세미밤나방","elapsed_s":2.3} ``` ```python import requests r = requests.post( "http://localhost:8080/classify", files={"file": open("pest.jpg", "rb")}, timeout=60, ) print(r.json()["pred"]) ``` ### RunPod 한 번에 띄우기 ([`restart_server.sh`](./restart_server.sh)) RunPod 컨테이너가 재시작되면 컨테이너 디스크가 초기화되어 pip 패키지가 사라집니다 (`/workspace` 볼륨은 유지됨). 이 상황을 한 번에 복구해주는 스크립트: ```bash # /workspace 에 이 저장소를 받아두었다고 가정 bash /workspace/restart_server.sh ``` 스크립트가 자동으로 처리하는 단계: 1. 의존성 스택 점검 (`unsloth`, `peft`, `fastapi`, `bitsandbytes`, `flash-linear-attention`) → 없으면 설치 2. `causal_conv1d` 점검 → 없으면 사전 빌드된 wheel 설치 (소스 빌드는 9개 GPU 아키텍처 컴파일 때문에 메모리 부족으로 실패함) 3. 기존 `pest` tmux 세션 종료 후 새로 시작 4. `/health` 가 200을 반환할 때까지 대기 (약 90~100초) 5. 공개 프록시 URL 출력 — RunPod의 `$RUNPOD_POD_ID` 환경변수에서 자동으로 추론 환경변수로 동작 변경 가능: ```bash LOAD_IN_4BIT=false bash /workspace/restart_server.sh # FP16, 약 19.5 GB VRAM, 추론 속도 약 2배 PORT=9000 bash /workspace/restart_server.sh # 다른 포트로 ADAPTER=... bash /workspace/restart_server.sh # 다른 어댑터로 PUBLIC_URL=... bash /workspace/restart_server.sh # 자동 감지 결과 덮어쓰기 ``` **RunPod 에서 8080 포트를 외부에 노출하려면:** 1. RunPod 대시보드 → 해당 Pod 선택 → **Edit Pod → HTTP Ports** 에서 `8080` 추가 2. 저장 → 자동으로 `https://-8080.proxy.runpod.net/` 가 활성화됨 3. 첫 번째 요청은 약 12초 (Triton JIT 컴파일), 이후는 정상 상태 약 2~3초/이미지 > ⚠ `Edit Pod` 는 내부적으로 컨테이너를 재시작합니다. 컨테이너 디스크가 초기화되므로 다시 `restart_server.sh` 를 실행해 주세요. 볼륨(`/workspace`) 의 파일과 모델 캐시는 유지됩니다. SSH 의 publicPort 도 바뀌므로 GraphQL 로 새 포트를 다시 조회해야 합니다. --- ## 출력을 망가뜨리는 9가지 함정 (각각 직접 부딪힌 것들) 출력이 이상하면 아래 항목을 순서대로 점검하세요. 모든 항목이 실제로 `adgeadge` 또는 `...assistant...` 반복을 일으킨 사례입니다. ### 1. 잘못된 로더 `unsloth.FastVisionModel.from_pretrained` 를 사용해야 합니다. **`transformers.AutoModelForImageTextToText.from_pretrained` 는 안 됩니다.** 같은 가중치를 로드하지만 후자는 학습 시점에 사용된 Unsloth의 Gated DeltaNet 패치를 적용하지 않습니다. ### 2. LoRA 병합 `model.merge_and_unload()` 또는 `model.save_pretrained_merged(...)` 를 호출하지 마세요. PEFT의 병합은 이 아키텍처의 linear_attn 모듈에 대해 조용히 잘못된 가중치를 생성합니다. `PeftModel.from_pretrained` 를 통해 LoRA를 런타임 훅으로 유지하세요. ### 3. `FastVisionModel.for_inference(model)` 누락 이 호출이 Unsloth의 내부 캐시/추론 모드를 전환합니다. 빠뜨리면 첫 토큰은 정상이다가 이어지는 토큰이 `adge` 어트랙터로 빠질 수 있습니다. ### 4. `max_new_tokens` 가 너무 큼 모델은 `<클래스>\n` (예: `검거세미밤나방\n`) 을 출력하고 자연스럽게 멈춥니다. 그러나 16 토큰 이상을 요구하면 `\n` 을 지나서 계속 생성하다 `adge` 어트랙터에 빠집니다. **클래스명 출력에는 `max_new_tokens=10` 을 사용하세요.** `min_new_tokens` 는 설정하지 마세요(EOS 이후로 강제 생성하게 됩니다). ### 5. `stop_strings=["\n"]` 누락 `max_new_tokens=10` 이라도 모델은 `<클래스>\nassistant\n` 를 출력할 수 있습니다. `model.generate` 에 `stop_strings=["\n"]` 와 함께 **`tokenizer=tokenizer.tokenizer`** 도 전달해야 합니다. `tokenizer=` 인자가 없으면 stop_strings 가 동작하지 않습니다. ### 6. `enable_thinking` 호출 방식 오류 챗 템플릿이 `enable_thinking` 변수에 따라 분기됩니다. 학습은 thinking 비활성 모드로 진행됐습니다. `tokenizer.apply_chat_template(...)` 에 **`enable_thinking=False` 를 직접 키워드 인자로** 전달하세요. `chat_template_kwargs={"enable_thinking": False}` 로 감싸면 transformers ≥ 5.0 의 VLM 프로세서에서 조용히 무시됩니다. ### 7. 잘못된 시스템 프롬프트 **학습 시 사용한 시스템 메시지를 정확히 그대로** 사용하세요(아래 `SYSTEM_MSG` 참조). 19개 클래스를 나열한 더 긴 버전을 시도해 봤는데 출력에 편향이 생겼습니다. ### 8. 잘못된 이미지 크기 모델은 회색(RGB 128, 128, 128) 패딩으로 letterbox 처리된 512×512 이미지로 학습되었습니다. 다른 해상도나 패딩 색상을 쓰면 정확도가 떨어집니다. ### 9. 빠른 linear-attn 경로용 의존성 누락 `pip install flash-linear-attention causal-conv1d` 로 transformers 가 Gated DeltaNet 의 빠른 Triton 커널을 사용할 수 있게 하세요. 없으면 torch 폴백 경로를 타게 되는데, FP16 누산 순서가 달라서 적응된 가중치에서는 출력이 표류할 수 있습니다. (torch 2.8 + cu128 용 사전 빌드된 wheel 이 있습니다.) --- ## 시스템 프롬프트 및 상수 (학습 시점과 동일) ```python SYSTEM_MSG = ( "당신은 작물 해충 식별 전문가입니다. " "사진을 보고 해충의 이름만 한국어로 답하세요. " '해충이 없으면 "정상"이라고만 답하세요. ' "부가 설명 없이 이름만 출력하세요." ) USER_PROMPT = "이 사진에 있는 해충의 이름을 알려주세요." PEST_CLASSES = [ "검거세미밤나방", "꽃노랑총채벌레", "담배가루이", "담배거세미나방", "담배나방", "도둑나방", "먹노린재", "목화바둑명나방", "무잎벌", "배추좀나방", "배추흰나비", "벼룩잎벌레", "비단노린재", "썩덩나무노린재", "알락수염노린재", "정상", "큰28점박이무당벌레", "톱다리개미허리노린재", "파밤나방", ] ``` 19개 클래스는 18종의 해충 + `정상` 으로 구성됩니다. 학습 데이터 분포는 클래스 간 대체로 균형을 이룹니다. --- ## 이미지 전처리 (letterbox) ```python from PIL import Image def letterbox(img: Image.Image, size: int = 512) -> Image.Image: """종횡비를 유지하며 리사이즈한 후, 회색으로 패딩하여 정사각형으로 만듭니다.""" img = img.convert("RGB") w, h = img.size scale = size / max(w, h) nw, nh = int(round(w * scale)), int(round(h * scale)) resized = img.resize((nw, nh), Image.Resampling.LANCZOS) canvas = Image.new("RGB", (size, size), (128, 128, 128)) canvas.paste(resized, ((size - nw) // 2, (size - nh) // 2)) return canvas ``` --- ## 의존성 버전 고정 학습 환경이 중요합니다. 아래 버전들이 재현 가능한 출력을 보장합니다: ``` torch==2.8.0+cu128 transformers>=5.2,<6.0 # 5.5.0 검증됨 peft==0.19.1 # adapter_config.json 의 버전과 반드시 일치 unsloth==2026.4.8 # 더 이전 버전은 cu128-torch280 extra 를 사용 xformers>=0.0.32 # FA2 폴백으로 충분 Pillow>=10.0 flash-linear-attention # Gated DeltaNet 빠른 경로 causal-conv1d # fla 와 함께 사용 ``` 설치 명령: ```bash pip install "unsloth[cu128-torch280]" "transformers>=5.2,<6.0" "peft==0.19.1" pip install flash-linear-attention causal-conv1d --no-build-isolation ``` `causal-conv1d` 가 사용 중인 CUDA 버전에서 빌드 실패하면 Unsloth 가 broken 으로 표시하고 계속 진행합니다 — 정확도는 영향 없고 처리량만 약간 떨어집니다. --- ## bnb 4-bit (NF4) 로 VRAM 줄이기 베이스를 4-bit 로 로드하려면 한 줄만 바꾸면 됩니다: ```python model, tokenizer = FastVisionModel.from_pretrained( "unsloth/Qwen3.5-9B", load_in_4bit=True, # 이전 값: False ) # 나머지 설정은 모두 동일 ``` 측정 결과: - **VRAM: 19.5 GB → 8.7 GB** (55 % 감소) - **디스크: ~18 GB → ~5 GB** - **정확도: 비트 단위 동일** — 10샘플 프로브에서 8/10 로 일치 (틀린 2개는 두 설정 모두에서 동일한 어려운 사례 담배가루이 → 정상 혼동) 이 LoRA 에서 작동하는 유일한 양자화 방식인데, LoRA 가 양자화된 가중치에 굽히지 않고 PEFT 훅을 통해 런타임에서 유지되기 때문입니다. `load_in_8bit=True` (LLM.int8) 도 동작하지만 이 작업에서는 NF4 대비 품질 이득 없이 VRAM 약 13 GB 를 사용합니다. --- ## 동작하지 **않는** 경로들 (시도하지 마세요) - `model.save_pretrained_gguf(...)` — 정확도 0 % - `model.save_pretrained_merged(...)` 후 `convert_hf_to_gguf.py` — 0 % - 변환 전 `_reorder_v_heads` 미리 적용 — 5.3 % (붕괴) - 병합 → GGUF 전에 `linear_attn` LoRA 를 0으로 — 35.1 % (적응 손실) - 병합된 모델에 AutoAWQ — 같은 병합 버그 - `peft.merge_and_unload()` 후 병합된 디렉토리에 `transformers.AutoModelForImageTextToText.from_pretrained` — `adgeadge` - 병합된 모델로 vLLM — 위와 동일 패턴은 명확합니다: **`linear_attn` 의 LoRA 델타를 FastVisionModel 런타임 경로 외의 방식으로 건드리는 모든 시도가 쓰레기 출력을 만듭니다.** 구조적 결함은 `convert_hf_to_gguf.py:_reorder_v_heads` 에 있고, 상위 이슈는 [llama.cpp#21125](https://github.com/ggml-org/llama.cpp/issues/21125) 입니다. --- ## 알려진 분류 오류 아래는 배포 버그가 아닌 **실제 모델의 분류 오류** 입니다 — 모델이 올바른 한국어 클래스명을 출력하지만 정답이 아닌 경우입니다: | 정답 → 예측 | 비율 (57샘플 벤치) | 이유 | |---|---|---| | 도둑나방 → 검거세미밤나방 | 2/3 | 둘 다 어두운 색 나방, 시각적으로 유사 | | 비단노린재 → 목화바둑명나방 | 2/3 | 작고 얼룩무늬가 있는 곤충 | | 담배가루이 → 정상 | 2/3 | 작은 흰 해충, 놓치기 쉬움 | | 알락수염노린재 → 정상 | 1/3 | 위와 유사 | | 벼룩잎벌레 → 목화바둑명나방 / 배추좀나방 | 1/3 씩 | 작고 어두운 곤충 | 19개 클래스 중 14개는 벤치에서 100 % (3/3) 적중합니다. 약한 클래스를 개선하려면 해당 종의 샘플을 추가하거나 더 변별력 있는 augmentation 으로 재학습하세요. --- ## 인용 / 참고 - 베이스 모델: [`unsloth/Qwen3.5-9B`](https://huggingface.co/unsloth/Qwen3.5-9B) - 데이터셋: [`Himedia-AI-01/pest-detection-korean`](https://huggingface.co/datasets/Himedia-AI-01/pest-detection-korean) - LoRA 학습: Unsloth FastVisionModel, rank=64, alpha=128, RS-LoRA, target_modules 정규식에 `q/k/v/o_proj`, `gate/up/down_proj`, **그리고** `in_proj_qkv/z/a/b/out_proj` (Gated DeltaNet 투영 — GGUF 배포가 불가능한 이유) - 최종 eval loss: **0.023164** (step 850) - 1595 샘플 검증 정확도: **91.36 %** --- ## 라이선스 베이스 모델 및 데이터셋의 라이선스를 따릅니다. 자세한 약관은 [`unsloth/Qwen3.5-9B`](https://huggingface.co/unsloth/Qwen3.5-9B) 와 [`Himedia-AI-01/pest-detection-korean`](https://huggingface.co/datasets/Himedia-AI-01/pest-detection-korean) 페이지를 확인하세요.