|
|
Dataset
연구실을 a, b, c, d구역으로 나눠 물건을 놓아야 할 위치로 저장했고
로봇을 불렀을 때 올 자리를 A, B로 지정했다. (현재 로봇의 위치를 VLM이 정답으로 내뱉어야 하기 때문)
| (finetune) airlab@DESKTOP-MOM34OQ:~/fine-tune/DATA$ tree -d . ├── a구역 │ ├── DC어댑터 │ │ ├── A │ │ └── B │ ├── HDMI │ │ ├── A │ │ └── B │ ├── 글루건 │ │ ├── A │ │ └── B │ ├── 멀티미터 │ │ ├── A │ │ └── B │ ├── 배터리 │ │ ├── A │ │ └── B │ ├── 스테이플러 │ │ ├── A │ │ └── B │ ├── 시리얼통신케이블 │ │ ├── A │ │ └── B │ └── 케이블타이 │ ├── A │ └── B ├── b구역 │ ├── 드라이버 │ │ ├── A │ │ └── B │ ├── 망치 │ │ ├── A │ │ └── B │ ├── 서포터 │ │ ├── A │ │ └── B │ ├── 전동드릴 │ │ ├── A │ │ └── B │ └── 펜치 │ ├── A │ └── B ├── c구역 │ ├── 밴드 │ │ ├── A │ │ └── B │ ├── 붕대 │ │ ├── A │ │ └── B │ └── 연고 │ ├── A │ └── B ├── d구역 │ ├── SSD │ │ ├── A │ │ └── B │ └── USB │ ├── A │ └── B └── 미등록물건 ├── a구역 │ ├── 기타전선 │ │ └── A │ └── 빨대 ├── b구역 │ └── 가위 │ └── B ├── c구역 │ └── 솜 │ └── A └── d구역 └── 메모지 72 directories |
위의 구역마다 각 20-25장 사이로 로봇을 구동하여 찍었기 때문에 학습에는 부족할 것 같아
원본을 크게 해치지 않는 선에서 증강을 적용하여 데이터셋을 늘렸다.
validation은 학습 중 과적합을 알기 위해 사용했다.
https://kind-slip-86b.notion.site/Dataset-util-py-3838a2c2bfdb80a4a5bdfb5819d637ba?source=copy_link
1차 Fine-Tuning
3에퍽을 돌렸고 결과적으로 물건은 잘 인지하는데 장소에 대한 정확도가 떨어짐
output 형태는 위와 같이 나온다. 양자화는 모델 정확도가 좋으면 진행할 계획이다. (시간이 좀 걸림)
데이터셋 구성 수정
VLM은 LLM + ViT 조합으로 이미 물건들은 어떤 이름을 가지고 있는지 학습이 되어있을 것이다.
기존에 생각했던 구성은 물건+배경이 같이 찍힌 상태로 정답을
"현재 (배경)구역에 존재합니다. 이 (물건)은 x구역에 위치해야 합니다."
라는 식으로 학습을 진행했었는데, 이렇게 되면 기존 Vision AI와 다를 게 없다는 생각을 했다.
따라서 현재 VLM이 모르는 건 연구실 내부만 모르는 상태이기에 배경만 학습하도록 진행했다.
배경만 학습하여 현재 위치는 (A/B) 위치라는 것만 학습시키고 이후 답은
system role을 통해 조건부를 걸어주면 출력을 원하는대로 조정 가능하지 않을까 생각했다.
| CLASSES = ["윤지 자리", "민서 자리"] LABEL_MAP = { "윤지 자리": "윤지 자리입니다.", "민서 자리": "민서 자리입니다.", } QUESTIONS = [ "여기가 어디야?", "현재 위치가 어디야?", "어느 자리야?", ] |
위와 같은 식으로 .json을 설정한 뒤 학습을 진행했다.
| --lora_rank 32 --freeze_vision_tower True --freeze_llm True --freeze_merger False |
Test 진행 시 SYSTEM에 아래와 같은 조건을 걸어 원하는 출력을 얻도록 유도했다.
| SYSTEM_PROMPT = ( "당신은 연구실 물건 위치 안내 AI입니다.\n" "물품별 보관 구역:\n" "a구역: DC어댑터, HDMI, 글루건, 멀티미터, 배터리, 스테이플러, 시리얼통신케이블, 케이블타이\n" "b구역: 드라이버, 망치, 서포터, 전동드릴, 펜치\n" "c구역: 밴드, 붕대, 연고\n" "d구역: SSD, USB\n" "목록에 있는 물건이면 출력은 무조건:" "[클래스: 물건명] 현재 자리(윤지자리/민서자리)와 이동해야 할 구역(a/b/c/d구역) 안내." "목록에 없는 물건이라면 출력은 무조건:" "[클래스: 미등록] 시각적 특징 설명. 가장 유사한 구역 안내.\n" "이 조건 뒤에 따라올 문장에 대해 조건에 맞게 출력해주세요.\n" ) |
| messages = [ { "role": "system", "content": SYSTEM_PROMPT }, { "role": "user", "content": [ {"type": "image_url", "image_url": {"url": to_image_url("A.png")}}, {"type": "text", "text": "이거 어디에 둬야해?"}, ] }, ] |
system 조건에 맞게 출력을 안 하는 상태가 됐다.
학습했었던 json의 gpt 답변을 그대로 하는 출력이 나오게 된다.
혹시 system 조건을 안 받나 싶어서 기존의 qwen2.5-vl model (base)로 진행을 해봤다.
오히려 원하는 출력에는 맞게 대답을 한다.
LLM 전체가 데이터에 맞는 짧은 패턴으로 굳어져서 시스템 프롬프트를 무시하는 경우가 있다.
https://share.google/aimode/sdzUFyzePXDna8JhA
이전 문제점은 데이터셋에 자리를 학습하기 위해 학습 정답을 "OO 자리입니다."라고 설정하고 학습을 진행했는데
이렇게 되면 문장 끝에 EOS 토큰이 붙어 시스템 프롬프트로 출력 형식을 지정해도 그 뒤 물건 인식, 구역 안내 등
출력을 이어가지 못하는 문제점이 있었고 또한 자리 자체도 증강 + 비슷한 구역이기에 성능도 좋지 못했다.
Qwem2.5-VL 사용한 논문을 찾아보던 중,
https://arxiv.org/pdf/2607.10666
Answer-Conditioned Chain-of-Thought Distillation for Few-Shot Industrial Vision with Small VLMs
Qwen2.5-VL 3B + LoRA 파인튜닝, 적은 데이터로 도메인 특화를 시킨 논문이다.
논문에서 말하고자 하는 건 정답을 먼저 알려주고, 그 정답을 향한 추론 과정은 큰 모델이 자동 생성하도록 하는 것이다.
CoT를 도입해서 소형 VLM이 단순히 정답 암기를 하는 것이 아닌, 정답에 이르는 추론 과정을 함께 학습하도록 유도한 것이다.
구체적으로는 각 학습 이미지와 정답 레이블을 대형 VLM에 입력하면,
대형 VLM이 "왜 이것이 정답인지"에 대한 시각적 근거를 자동으로 생성한다.
이렇게 생성된 추론 과정이 포함된 데이터로 소형 모델을 fine-tuning하면, 소형 모델도 추론 능력을 갖추게 된다는 것이다...
따라서 위와 같은 방식을 썼을 때 기대한 효과는
배경 분석 -> 물건 인식 -> 구역 판단 순서로 추론하는 과정을 학습하고
미등록 물건에 대해서도 학습을 통한것이 아닌 시각적 특징을 분석하여 가장 유사한 구역을 추론하는 것이 가능하도록 만드는 것이다.
CoT 생성
https://platform.claude.com/dashboard
원본 이미지 976장에 대해 Cluade API 활용하여
각 이미지와 함께 정답 레이블(물건명, 현재 자리, 목표 구역)을 입력하고, 배경에서 현재 자리를 알 수 있는
시각적 특징, 물건을 식별할 수 있는 특징, 해당 구역으로 이동해야 하는 이유를 한국어로 생성하도록 요청했다.
| # generate_cot.py """ 원본 이미지 → Claude API → CoT 추론 생성 → cot.json 저장 실행 전: pip install anthropic 실행: ANTHROPIC_API_KEY=sk-... python generate_cot.py """ import os import json import base64 import time import anthropic DATA_DIR = "./DATA/DATA" OUTPUT_JSON = "./result/cot.json" TARGET_ZONES = ["a구역", "b구역", "c구역", "d구역"] FOLDER_TO_LOCATION = { "A": "윤지 자리", "B": "민서 자리", } ZONE_MAP = { "a구역": "A", "b구역": "B", "c구역": "C", "d구역": "D", } client = anthropic.Anthropic() def encode_image(image_path): with open(image_path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") def get_media_type(path): ext = os.path.splitext(path)[1].lower() return {"jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png"}.get(ext, "image/jpeg") def generate_cot(image_path, item_name, target_zone, current_location): """Claude API로 CoT 추론 생성""" img_data = encode_image(image_path) media_type = get_media_type(image_path) prompt = ( f"이 이미지를 보고 아래 정답이 맞는 이유를 시각적 근거 중심으로 설명해주세요.\n\n" f"정답:\n" f"- 물건: {item_name}\n" f"- 현재 위치: {current_location}\n" f"- 이동해야 할 구역: {target_zone}\n\n" f"설명 형식 (3~4문장):\n" f"1. 배경에서 {current_location}임을 알 수 있는 시각적 특징\n" f"2. 물건이 {item_name}임을 알 수 있는 시각적 특징\n" f"3. 따라서 {target_zone}으로 이동해야 하는 이유\n\n" f"간결하게 한국어로 작성해주세요." ) response = client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=600, messages=[{ "role": "user", "content": [ { "type": "image", "source": { "type": "base64", "media_type": media_type, "data": img_data, } }, {"type": "text", "text": prompt} ] }] ) return response.content[0].text.strip() def list_images(folder): exts = (".jpg", ".jpeg", ".png") if not os.path.isdir(folder): return [] return sorted(f for f in os.listdir(folder) if f.lower().endswith(exts)) def main(): os.makedirs(os.path.dirname(OUTPUT_JSON), exist_ok=True) # 기존 진행 상황 로드 (중간에 끊겨도 이어서 진행) if os.path.exists(OUTPUT_JSON): with open(OUTPUT_JSON, "r", encoding="utf-8") as f: cot_data = json.load(f) print(f"기존 진행 상황 로드: {len(cot_data)}개") else: cot_data = {} total = 0 skipped = 0 errors = 0 for target_zone in TARGET_ZONES: zone_dir = os.path.join(DATA_DIR, target_zone) if not os.path.isdir(zone_dir): continue for item_name in sorted(os.listdir(zone_dir)): item_dir = os.path.join(zone_dir, item_name) if not os.path.isdir(item_dir): continue for cur_folder in sorted(os.listdir(item_dir)): cur_dir = os.path.join(item_dir, cur_folder) if not os.path.isdir(cur_dir): continue cur_location = FOLDER_TO_LOCATION.get(cur_folder.upper(), cur_folder) images = list_images(cur_dir) for fname in images: img_path = os.path.join(cur_dir, fname) key = f"{target_zone}/{item_name}/{cur_folder}/{fname}" # 이미 생성된 거면 스킵 if key in cot_data: skipped += 1 continue try: reasoning = generate_cot( img_path, item_name, target_zone, cur_location ) cot_data[key] = reasoning total += 1 print(f"[{total}] {key}") print(f" → {reasoning[:80]}...") # 10개마다 중간 저장 if total % 10 == 0: with open(OUTPUT_JSON, "w", encoding="utf-8") as f: json.dump(cot_data, f, ensure_ascii=False, indent=2) print(f" [저장] {total}개 완료") # API rate limit 방지 time.sleep(0.5) except Exception as e: print(f" [에러] {key}: {e}") errors += 1 time.sleep(2) # 최종 저장 with open(OUTPUT_JSON, "w", encoding="utf-8") as f: json.dump(cot_data, f, ensure_ascii=False, indent=2) print(f"\n완료: 생성 {total}개 / 스킵 {skipped}개 / 에러 {errors}개") print(f"저장: {OUTPUT_JSON}") if __name__ == "__main__": main() |
데이터셋 중 일부(169장)는 Test/validation(과적합 모니터링)를 위해 빼두고 훈련했다.
| --lora_rank 32 --freeze_vision_tower True --freeze_llm True --freeze_merger False |
이를 통해 모델이 배경 인식, 물건 분류, 구역 안내, 좌표 출력을 하나의 추론 흐름으로 학습하도록 구성하였으며,
실제로 JSON 파싱 성공률 100%를 달성하여 출력 형식 문제는 해결했다.
다만 정확도가 낮아서 이는 하이퍼파라미터 설정에서 올려보도록 해야할 것 같다.
loss 수렴이 덜 된 것 같아서 에퍽은 늘리고 배치를 줄인 뒤 학습했다.
