Pest Detector — 한국어 19분류 비전-언어 분류기
unsloth/Qwen3.5-9B 기반 LoRA 어댑터로, 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 파일도 정상적으로 로드되며, 서버도 시작됩니다. 그러나 출력은 <think>\n\n</think>\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)
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 를 참고하세요.
HTTP 서버로 서빙하기 (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":"<base64>"} |
시작:
pip install fastapi uvicorn python-multipart
HF_TOKEN=... ADAPTER=pfox1995/pest-detector-deploy LOAD_IN_4BIT=true PORT=8080 \
python3 server.py
클라이언트 사용 예:
curl -F file=@pest.jpg http://localhost:8080/classify
# → {"pred":"검거세미밤나방","raw":"검거세미밤나방","elapsed_s":2.3}
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)
RunPod 컨테이너가 재시작되면 컨테이너 디스크가 초기화되어 pip 패키지가 사라집니다 (/workspace 볼륨은 유지됨). 이 상황을 한 번에 복구해주는 스크립트:
# /workspace 에 이 저장소를 받아두었다고 가정
bash /workspace/restart_server.sh
스크립트가 자동으로 처리하는 단계:
- 의존성 스택 점검 (
unsloth,peft,fastapi,bitsandbytes,flash-linear-attention) → 없으면 설치 causal_conv1d점검 → 없으면 사전 빌드된 wheel 설치 (소스 빌드는 9개 GPU 아키텍처 컴파일 때문에 메모리 부족으로 실패함)- 기존
pesttmux 세션 종료 후 새로 시작 /health가 200을 반환할 때까지 대기 (약 90~100초)- 공개 프록시 URL 출력 — RunPod의
$RUNPOD_POD_ID환경변수에서 자동으로 추론
환경변수로 동작 변경 가능:
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 포트를 외부에 노출하려면:
- RunPod 대시보드 → 해당 Pod 선택 → Edit Pod → HTTP Ports 에서
8080추가 - 저장 → 자동으로
https://<POD_ID>-8080.proxy.runpod.net/가 활성화됨 - 첫 번째 요청은 약 12초 (Triton JIT 컴파일), 이후는 정상 상태 약 2~3초/이미지
⚠
Edit Pod는 내부적으로 컨테이너를 재시작합니다. 컨테이너 디스크가 초기화되므로 다시restart_server.sh를 실행해 주세요. 볼륨(/workspace) 의 파일과 모델 캐시는 유지됩니다. SSH 의 publicPort 도 바뀌므로 GraphQL 로 새 포트를 다시 조회해야 합니다.
출력을 망가뜨리는 9가지 함정 (각각 직접 부딪힌 것들)
출력이 이상하면 아래 항목을 순서대로 점검하세요. 모든 항목이 실제로 adgeadge 또는 <think>...assistant<think>... 반복을 일으킨 사례입니다.
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<think> 를 출력할 수 있습니다. 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 이 있습니다.)
시스템 프롬프트 및 상수 (학습 시점과 동일)
SYSTEM_MSG = (
"당신은 작물 해충 식별 전문가입니다. "
"사진을 보고 해충의 이름만 한국어로 답하세요. "
'해충이 없으면 "정상"이라고만 답하세요. '
"부가 설명 없이 이름만 출력하세요."
)
USER_PROMPT = "이 사진에 있는 해충의 이름을 알려주세요."
PEST_CLASSES = [
"검거세미밤나방", "꽃노랑총채벌레", "담배가루이", "담배거세미나방",
"담배나방", "도둑나방", "먹노린재", "목화바둑명나방", "무잎벌",
"배추좀나방", "배추흰나비", "벼룩잎벌레", "비단노린재", "썩덩나무노린재",
"알락수염노린재", "정상", "큰28점박이무당벌레", "톱다리개미허리노린재",
"파밤나방",
]
19개 클래스는 18종의 해충 + 정상 으로 구성됩니다. 학습 데이터 분포는 클래스 간 대체로 균형을 이룹니다.
이미지 전처리 (letterbox)
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 와 함께 사용
설치 명령:
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 로 로드하려면 한 줄만 바꾸면 됩니다:
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_attnLoRA 를 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 입니다.
알려진 분류 오류
아래는 배포 버그가 아닌 실제 모델의 분류 오류 입니다 — 모델이 올바른 한국어 클래스명을 출력하지만 정답이 아닌 경우입니다:
| 정답 → 예측 | 비율 (57샘플 벤치) | 이유 |
|---|---|---|
| 도둑나방 → 검거세미밤나방 | 2/3 | 둘 다 어두운 색 나방, 시각적으로 유사 |
| 비단노린재 → 목화바둑명나방 | 2/3 | 작고 얼룩무늬가 있는 곤충 |
| 담배가루이 → 정상 | 2/3 | 작은 흰 해충, 놓치기 쉬움 |
| 알락수염노린재 → 정상 | 1/3 | 위와 유사 |
| 벼룩잎벌레 → 목화바둑명나방 / 배추좀나방 | 1/3 씩 | 작고 어두운 곤충 |
19개 클래스 중 14개는 벤치에서 100 % (3/3) 적중합니다. 약한 클래스를 개선하려면 해당 종의 샘플을 추가하거나 더 변별력 있는 augmentation 으로 재학습하세요.
인용 / 참고
- 베이스 모델:
unsloth/Qwen3.5-9B - 데이터셋:
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 와 Himedia-AI-01/pest-detection-korean 페이지를 확인하세요.
- Downloads last month
- 50
Model tree for pfox1995/pest-detector-deploy
Evaluation results
- Accuracy (1595-sample validation) on Korean Pest Detection (19-class)self-reported0.914
- F1 (macro) on Korean Pest Detection (19-class)self-reported0.903
- F1 (weighted) on Korean Pest Detection (19-class)self-reported0.913
- Precision (macro) on Korean Pest Detection (19-class)self-reported0.909
- Recall (macro) on Korean Pest Detection (19-class)self-reported0.910