---
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) 페이지를 확인하세요.