Repository này xây dựng bộ dữ liệu Visual Question Answering (VQA) tiếng Việt cho ảnh nội soi phế quản và fine-tune mô hình BoKelvin/GEMeX-VQA-Model-Simple.
Với một ảnh và câu hỏi, mô hình được huấn luyện để trả về:
<answer> câu trả lời
<reason> lý do dựa trên hình ảnh
<location> [[x1, y1, x2, y2]]
README trình bày toàn bộ quy trình theo thứ tự:
Ảnh + annotation
│
▼
llm_input*_with_colors.json
│ generate_bronchoscopy_reports_gpt.py
▼
*_reports.json
│ generate_bronchoscopy_vqa_gpt.py
▼
*_vqa.json
│ gộp và làm phẳng
▼
all_vqa.json
│ convert_bronchoscopy_vqa_to_gemex.py
▼
bronchoscopy_train_data.json
│ split_bronchoscopy_by_patient.py
├──────────────┬───────────────┐
▼ ▼ ▼
train 70% validation 15% test 15%
│ │ │
▼ ▼ ▼
QLoRA train chọn mô hình báo cáo cuối
Số liệu của phiên bản hiện tại:
| Thành phần | Số lượng |
|---|---|
| Cặp hỏi–đáp | 54.684 |
| Ảnh | 6.781 |
| Bệnh nhân/ca | 445 |
| Train | 38.287 câu |
| Validation | 8.196 câu |
| Test | 8.201 câu |
Dữ liệu được chia theo bệnh nhân, không chia ngẫu nhiên từng câu hỏi. Manifest hiện tại xác nhận không có bệnh nhân hoặc ảnh trùng giữa train, validation và test.
.
├── Cabenh/ # ảnh, annotation và llm_input
├── Cabenh_reports/ # báo cáo nguyên tử và VQA theo ca
├── all_vqa.json # dữ liệu VQA đã gộp
├── generate_bronchoscopy_reports_gpt.py
├── generate_bronchoscopy_vqa_gpt.py
├── GEMeX-Colab/
│ ├── data/
│ │ ├── bronchoscopy_train_data.json
│ │ ├── bronchoscopy_train_70.json
│ │ ├── bronchoscopy_validation_15.json
│ │ ├── bronchoscopy_test_15.json
│ │ └── bronchoscopy_split_manifest.json
│ ├── images/ # ảnh dùng cho train/evaluation
│ ├── llava-med/ # mã nguồn LLaVA-Med/GEMeX
│ ├── convert_bronchoscopy_vqa_to_gemex.py
│ ├── split_bronchoscopy_by_patient.py
│ ├── evaluate_bronchoscopy.py
│ └── install_colab.sh
└── gemex_outputs/
├── patient_split_local_v1/ # adapter đã train
└── evaluation/ # prediction và metric
Các lệnh bên dưới giả sử terminal đang đứng ở thư mục gốc:
cd /home/ailab/Documents/KC4.0_Final_annots_data_png
Khuyến nghị:
Kiểm tra GPU:
nvidia-smi
Kiểm tra Python:
python --version
Không đưa API key, token Hugging Face hoặc thông tin định danh bệnh nhân vào Git.
Tạo môi trường ảo:
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip setuptools wheel
Cài các phiên bản thư viện đã dùng trong dự án:
bash GEMeX-Colab/install_colab.sh
python -m pip install requests pillow
Script cài đặt chính gồm:
transformers==4.44.0accelerate==0.33.0peft==0.12.0bitsandbytes==0.49.2sentencepiece==0.2.0open_clip_torch==2.26.1bert-score==0.3.13GEMeX-Colab/llava-medNếu model yêu cầu đăng nhập Hugging Face:
huggingface-cli login
Kiểm tra import:
python - <<'PY'
import torch
import transformers
import peft
import bitsandbytes
print("PyTorch:", torch.__version__)
print("Transformers:", transformers.__version__)
print("CUDA available:", torch.cuda.is_available())
if torch.cuda.is_available():
print("GPU:", torch.cuda.get_device_name(0))
PY
Giải nén bundle vào /content/gemex, sau đó:
bash /content/gemex/GEMeX-Colab/install_colab.sh
Sau khi cài, restart runtime nếu Colab vẫn dùng phiên bản thư viện cũ.
Nếu chỉ muốn train lại trên dữ liệu hiện có, có thể bỏ qua mục này và bắt đầu từ Mục 6.
Mỗi ca trong Cabenh/ cần có:
.png.llm_input*.json, ưu tiên bản _with_colors.json.Ví dụ:
Cabenh/BH/HHNBM-112/
├── imgs/
│ └── <object_id>.png
└── llm_input_HHNBM-112_ver1_with_colors.json
Trước khi gọi API, chạy dry-run để xem script tìm thấy bao nhiêu file:
python generate_bronchoscopy_reports_gpt.py \
Cabenh \
--output-root Cabenh_reports \
--dry-run
Hai script sinh dữ liệu gọi endpoint tương thích OpenAI:
POST <BASE_URL>/chat/completions
Ví dụ với 9Router chạy local:
export NINEROUTER_BASE_URL=""
export NINEROUTER_API_KEY="YOUR_API_KEY"
Có thể thay --base-url, --model và tên biến key nếu dùng nhà cung cấp khác.
Chạy thử một file trước:
python generate_bronchoscopy_reports_gpt.py \
Cabenh \
--output-root Cabenh_reports \
--model gen-VQA \
--limit 1 \
--image-detail low
Nếu output hợp lệ, chạy toàn bộ:
python generate_bronchoscopy_reports_gpt.py \
Cabenh \
--output-root Cabenh_reports \
--model gen-VQA \
--image-detail low \
--max-retries 5 \
--base-sleep 5
Output có dạng:
Cabenh_reports/<cohort>/<patient>/..._reports.json
Script tự bỏ qua object_id đã có trong output và lưu sau mỗi ảnh. Nếu tiến trình bị ngắt, chạy lại đúng lệnh để tiếp tục.
Không thêm
--overwritekhi muốn resume. Tùy chọn này tạo lại output từ đầu.
Nếu chỉ muốn sinh report từ annotation, không gửi ảnh lên API:
python generate_bronchoscopy_reports_gpt.py \
Cabenh \
--output-root Cabenh_reports \
--model gen-VQA \
--no-images
Smoke test trên một file report:
REPORT_FILE="$(find Cabenh_reports -type f -name '*_reports.json' | head -n 1)"
python generate_bronchoscopy_vqa_gpt.py \
"$REPORT_FILE" \
--model gen-VQA \
--max-retries 5
Chạy toàn bộ thư mục:
python generate_bronchoscopy_vqa_gpt.py \
Cabenh_reports \
--model gen-VQA \
--max-retries 5 \
--base-sleep 5
Mỗi ảnh có thể sinh tối đa:
Script không ép đủ số câu nếu finding không đủ tin cậy. Output được lưu cạnh report với hậu tố _vqa.json. Cơ chế resume dựa trên object_id, vì vậy có thể chạy lại lệnh sau khi lỗi mạng hoặc dừng máy.
Đếm file:
find Cabenh_reports -type f -name '*_reports.json' | wc -l
find Cabenh_reports -type f -name '*_vqa.json' | wc -l
Kiểm tra JSON có đọc được:
python - <<'PY'
import json
from pathlib import Path
files = list(Path("Cabenh_reports").rglob("*_vqa.json"))
errors = []
objects = 0
for path in files:
try:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, list):
raise TypeError("top-level value must be a list")
objects += len(data)
except Exception as exc:
errors.append((str(path), str(exc)))
print("VQA files:", len(files))
print("Image objects:", objects)
print("Invalid files:", len(errors))
for item in errors[:20]:
print(item)
PY
Artifact đã gộp của dự án là:
all_vqa.json
Nếu sinh lại dữ liệu từ đầu, có thể làm phẳng toàn bộ *_vqa.json thành một
record cho mỗi câu hỏi bằng lệnh sau. Lệnh ghi ra
all_vqa_rebuilt.json để không vô tình đè artifact hiện có:
python - <<'PY'
import json
from pathlib import Path
groups = (
"open_ended_questions",
"closed_ended_questions",
"single_choice_questions",
"multi_choice_questions",
)
rows = []
for source in sorted(Path("Cabenh_reports").rglob("*_vqa.json")):
objects = json.loads(source.read_text(encoding="utf-8"))
for obj in objects:
for group in groups:
for index, question in enumerate(obj.get(group, []), start=1):
clues = question.get("visual_clue") or []
rows.append(
{
"case_id": obj.get("case_id", ""),
"object_id": obj.get("object_id", ""),
"image_path": obj.get("image_path", ""),
"source_file": str(source.resolve()),
"question_group": group,
"question_index_in_group": index,
"question": question.get("question", ""),
"question_format": question.get("question_format", ""),
"type": question.get("question_type", ""),
"question_type": question.get("question_type", ""),
"answer": question.get("answer", ""),
"reason": question.get("reason", ""),
"options": question.get("options", {}),
"visual_clue": clues,
"visual_regions": [
clue.get("loc_token")
for clue in clues
if clue.get("loc_token")
],
"visual_locations": [
clue.get("anatomical_location")
for clue in clues
if clue.get("anatomical_location")
],
}
)
output = Path("all_vqa_rebuilt.json")
output.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
print("Saved:", output)
print("VQA records:", len(rows))
PY
Sau khi kiểm tra số lượng, schema và đường dẫn ảnh, mới đổi bản rebuilt thành
all_vqa.json. Nên sao lưu artifact cũ trước khi thay thế.
Mỗi dòng logic tương ứng một câu hỏi, với các trường quan trọng:
{
"case_id": "...",
"object_id": "...",
"image_path": "...",
"question_group": "open_ended_questions",
"question": "...",
"question_format": "open-ended",
"question_type": "mucosa_status",
"answer": "...",
"reason": "...",
"options": {},
"visual_clue": []
}
Kiểm tra số lượng và đường dẫn ảnh:
python - <<'PY'
import json
from pathlib import Path
rows = json.loads(Path("all_vqa.json").read_text(encoding="utf-8"))
missing = [row["image_path"] for row in rows if not Path(row["image_path"]).exists()]
print("VQA records:", len(rows))
print("Missing image paths:", len(missing))
print("First missing paths:", missing[:10])
PY
Training bundle dùng đường dẫn tương đối:
images/Cabenh/<cohort>/<patient>/imgs/<image>.png
Trong khi all_vqa.json có thể chứa đường dẫn tuyệt đối. Tạo một bản trung gian đã chuẩn hóa:
python - <<'PY'
import json
from pathlib import Path
source = Path("all_vqa.json")
target = Path("GEMeX-Colab/data/all_vqa_training_paths.json")
rows = json.loads(source.read_text(encoding="utf-8"))
for row in rows:
parts = Path(row["image_path"]).parts
try:
start = parts.index("Cabenh")
except ValueError as exc:
raise ValueError(f"Path does not contain Cabenh: {row['image_path']}") from exc
row["image_path"] = str(Path("images").joinpath(*parts[start:]))
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
print("Saved:", target)
print("Records:", len(rows))
PY
Đảm bảo ảnh tương ứng nằm trong GEMeX-Colab/images/. Workspace hiện tại đã có 6.781 ảnh tại đây.
(
cd GEMeX-Colab
python convert_bronchoscopy_vqa_to_gemex.py \
--input data/all_vqa_training_paths.json \
--output data/bronchoscopy_train_data.json
)
Một record sau chuyển đổi có dạng:
{
"conversations": [
{"from": "human", "value": "<image>\nCâu hỏi..."},
{
"from": "gpt",
"value": "<answer> ... <reason> ... <location> [[x1,y1,x2,y2]]"
}
],
"row_id": 0,
"case_id": "...",
"object_id": "...",
"q_type": "open_ended_questions",
"question_type": "mucosa_status",
"type_prompt": "...",
"image": "images/Cabenh/..."
}
Nếu script báo Missing image paths > 0, không bắt đầu train. Kiểm tra lại bước chuẩn hóa và thư mục ảnh.
Chia dữ liệu theo bệnh nhân với seed cố định:
python GEMeX-Colab/split_bronchoscopy_by_patient.py \
--input GEMeX-Colab/data/bronchoscopy_train_data.json \
--output-dir GEMeX-Colab/data \
--seed 42
Output:
GEMeX-Colab/data/bronchoscopy_train_70.json
GEMeX-Colab/data/bronchoscopy_validation_15.json
GEMeX-Colab/data/bronchoscopy_test_15.json
GEMeX-Colab/data/bronchoscopy_split_manifest.json
Kiểm tra dòng cuối phải là:
Leakage checks passed: True
Có thể kiểm tra lại manifest:
python - <<'PY'
import json
from pathlib import Path
path = Path("GEMeX-Colab/data/bronchoscopy_split_manifest.json")
manifest = json.loads(path.read_text(encoding="utf-8"))
for name, stats in manifest["splits"].items():
print(
name,
"records=", stats["records"],
"patients=", stats["patients"],
"images=", stats["unique_images"],
)
print("Leakage passed:", manifest["leakage_checks"]["passed"])
PY
Không chia ngẫu nhiên từng câu hỏi hoặc từng ảnh. Nhiều ảnh của cùng một bệnh nhân phải nằm trong cùng một split.
Script fine_tune_bronchoscopy.sh dùng:
BoKelvin/GEMeX-VQA-Model-Simple.openai/clip-vit-large-patch14.2e-5, cosine scheduler.MAX_STEPS=1 \
OUTPUT_DIR="$PWD/gemex_outputs/smoke_test" \
bash GEMeX-Colab/llava-med/fine_tune_bronchoscopy.sh
Smoke test đạt yêu cầu khi:
1/1.train_loss hữu hạn và khác 0.grad_norm hữu hạn.Nếu smoke test lỗi, không chạy full training.
OUTPUT_DIR="$PWD/gemex_outputs/patient_split_local_v1" \
bash GEMeX-Colab/llava-med/fine_tune_bronchoscopy.sh \
2>&1 | tee "$PWD/gemex_outputs/train_console.log"
Không dùng lại một output directory cũ cho thí nghiệm có cấu hình khác. Nên đặt tên theo phiên bản:
gemex_outputs/patient_split_seed42_v1
gemex_outputs/patient_split_seed42_lr1e-5_v2
Giảm context length:
MODEL_MAX_LENGTH=768 \
OUTPUT_DIR="$PWD/gemex_outputs/patient_split_ctx768_v1" \
bash GEMeX-Colab/llava-med/fine_tune_bronchoscopy.sh
Chọn GPU:
CUDA_VISIBLE_DEVICES=0 \
OUTPUT_DIR="$PWD/gemex_outputs/patient_split_local_v1" \
bash GEMeX-Colab/llava-med/fine_tune_bronchoscopy.sh
Trong output directory, tối thiểu cần giữ:
adapter_model.safetensors
adapter_config.json
tokenizer.model
tokenizer_config.json
special_tokens_map.json
added_tokens.json
trainer_state.json
README.md
Các checkpoint-* chứa optimizer và scheduler cần thiết nếu muốn resume training. Chỉ xóa chúng sau khi chắc chắn không cần tiếp tục train và adapter cuối đã được lưu ở thư mục cha.
Đặt các biến dùng chung:
PROJECT_ROOT="$PWD"
ADAPTER_DIR="$PROJECT_ROOT/gemex_outputs/patient_split_local_v1"
EVAL_DIR="$PROJECT_ROOT/gemex_outputs/evaluation"
mkdir -p "$EVAL_DIR"
Chạy thử 10 mẫu:
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_validation_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/validation_predictions.jsonl" \
--max-samples 10
Chạy lại mà không có --max-samples:
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_validation_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/validation_predictions.jsonl" \
2>&1 | tee -a "$EVAL_DIR/validation_console.log"
Evaluator ghi từng prediction vào JSONL và flush ngay. Nếu bị ngắt, chạy lại đúng lệnh; các index đã hoàn thành sẽ được bỏ qua.
Sau khi prediction hoàn tất:
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_validation_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/validation_predictions.jsonl" \
--metrics-only \
--bertscore \
--bertscore-device cuda
Metric được lưu tại:
gemex_outputs/evaluation/validation_predictions.jsonl.metrics.json
Chỉ dùng validation để chọn checkpoint, prompt, parser và hyperparameter. Không điều chỉnh mô hình dựa trên test.
Chỉ chạy test sau khi đã khóa:
max_new_tokens.PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_test_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/test_predictions.jsonl" \
--max-samples 10
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_test_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/test_predictions.jsonl" \
2>&1 | tee -a "$EVAL_DIR/test_console.log"
Không chạy validation và test đồng thời trên cùng GPU.
Theo dõi tiến độ:
wc -l "$EVAL_DIR/test_predictions.jsonl"
tail -f "$EVAL_DIR/test_console.log"
Tổng test hiện tại có 8.201 mẫu. Khi wc -l bằng 8.201, prediction đã đủ.
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_test_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/test_predictions.jsonl" \
--metrics-only \
--bertscore \
--bertscore-device cuda
Output:
gemex_outputs/evaluation/test_predictions.jsonl.metrics.json
| Metric | Ý nghĩa |
|---|---|
| Answer Exact Match | Câu trả lời sau chuẩn hóa phải trùng hoàn toàn |
| Answer Token F1 | Mức chồng lấp token giữa dự đoán và đáp án |
| Closed-ended precision/recall/F1 | Chất lượng phân loại yes/no |
| Specificity | Khả năng nhận đúng mẫu âm |
| Single-choice accuracy | Tỷ lệ chọn đúng một đáp án |
| Multi-choice exact-set | Tập lựa chọn phải trùng hoàn toàn |
| Multi-choice micro/macro F1 | Đúng theo từng lựa chọn |
| Jaccard | Mức giao nhau của hai tập lựa chọn |
| ROUGE-L | Độ giống chuỗi cho câu hỏi mở |
| CIDEr | Độ tương đồng nội dung với đáp án tham chiếu |
| BERTScore | Độ gần nhau về ngữ nghĩa |
| Location IoU | Mức chồng lấp bounding box |
| [email protected] | Tỷ lệ mẫu có IoU từ 0,5 trở lên |
Không nên chỉ nhìn Exact Match cho câu hỏi mở. Cần đọc đồng thời Token F1, ROUGE-L, CIDEr, BERTScore và một mẫu lỗi do chuyên gia kiểm tra.
Để so sánh công bằng, GPT và mô hình fine-tuned phải chạy trên đúng cùng test split. Tạo raw test set từ row_id:
python - <<'PY'
import json
from pathlib import Path
raw = json.loads(Path("all_vqa.json").read_text(encoding="utf-8"))
test = json.loads(
Path("GEMeX-Colab/data/bronchoscopy_test_15.json").read_text(encoding="utf-8")
)
selected = [raw[item["row_id"]] for item in test]
output = Path("vqa_llm_eval/test_patient_split_raw.json")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(selected, ensure_ascii=False, indent=2), encoding="utf-8")
print("Saved:", output, "records:", len(selected))
PY
Sau đó gọi một endpoint tương thích OpenAI:
export OPENAI_API_KEY="YOUR_API_KEY"
python vqa_llm_eval/run_openai_compatible_vqa.py \
--input vqa_llm_eval/test_patient_split_raw.json \
--output vqa_llm_eval/results/gpt4o_mini_test.jsonl \
--model gpt-4o-mini \
--image-detail low \
--sleep 0.5
Chạy thử --limit 20 trước khi chạy toàn bộ để kiểm tra quyền truy cập, prompt, parser và chi phí:
python vqa_llm_eval/run_openai_compatible_vqa.py \
--input vqa_llm_eval/test_patient_split_raw.json \
--output vqa_llm_eval/results/gpt4o_mini_smoke.jsonl \
--model gpt-4o-mini \
--image-detail low \
--limit 20
Khi báo cáo, cần lưu model ID thực tế, provider, model snapshot, temperature, prompt version, token và chi phí. Không so sánh GPT trên một phần all_vqa.json với BoKelvin trên patient-level test split.
MODEL_MAX_LENGTH từ 1.024 xuống 768 hoặc 512.Kiểm tra process GPU:
nvidia-smi
Nếu convert hoặc evaluation báo thiếu ảnh:
find GEMeX-Colab/images -type f -name '*.png' | wc -l
Kiểm tra trường image phải có dạng tương đối bắt đầu bằng images/.
--base-sleep.--overwrite.--max-tokens nếu output bị cắt.all_vqa.json trước khi kiểm tra.Không xóa file prediction JSONL. Chạy lại đúng lệnh; evaluator tự tiếp tục từ các index chưa có.
File metric chỉ phản ánh các prediction có tại thời điểm tính. Chỉ tạo metric cuối khi số dòng prediction bằng số record của split.
Mỗi lần chạy nên ghi lại:
Tính checksum:
sha256sum \
GEMeX-Colab/data/bronchoscopy_split_manifest.json \
GEMeX-Colab/data/bronchoscopy_train_70.json \
GEMeX-Colab/data/bronchoscopy_validation_15.json \
GEMeX-Colab/data/bronchoscopy_test_15.json \
gemex_outputs/patient_split_local_v1/adapter_model.safetensors
Trước khi train:
Leakage checks passed: True.Trước khi validation:
<answer>, <reason>, <location>.Trước khi test:
Sau test:
Repository này xây dựng bộ dữ liệu Visual Question Answering (VQA) tiếng Việt cho ảnh nội soi phế quản và fine-tune mô hình BoKelvin/GEMeX-VQA-Model-Simple.
Với một ảnh và câu hỏi, mô hình được huấn luyện để trả về:
<answer> câu trả lời
<reason> lý do dựa trên hình ảnh
<location> [[x1, y1, x2, y2]]
README trình bày toàn bộ quy trình theo thứ tự:
Ảnh + annotation
│
▼
llm_input*_with_colors.json
│ generate_bronchoscopy_reports_gpt.py
▼
*_reports.json
│ generate_bronchoscopy_vqa_gpt.py
▼
*_vqa.json
│ gộp và làm phẳng
▼
all_vqa.json
│ convert_bronchoscopy_vqa_to_gemex.py
▼
bronchoscopy_train_data.json
│ split_bronchoscopy_by_patient.py
├──────────────┬───────────────┐
▼ ▼ ▼
train 70% validation 15% test 15%
│ │ │
▼ ▼ ▼
QLoRA train chọn mô hình báo cáo cuối
Số liệu của phiên bản hiện tại:
| Thành phần | Số lượng |
|---|---|
| Cặp hỏi–đáp | 54.684 |
| Ảnh | 6.781 |
| Bệnh nhân/ca | 445 |
| Train | 38.287 câu |
| Validation | 8.196 câu |
| Test | 8.201 câu |
Dữ liệu được chia theo bệnh nhân, không chia ngẫu nhiên từng câu hỏi. Manifest hiện tại xác nhận không có bệnh nhân hoặc ảnh trùng giữa train, validation và test.
.
├── Cabenh/ # ảnh, annotation và llm_input
├── Cabenh_reports/ # báo cáo nguyên tử và VQA theo ca
├── all_vqa.json # dữ liệu VQA đã gộp
├── generate_bronchoscopy_reports_gpt.py
├── generate_bronchoscopy_vqa_gpt.py
├── GEMeX-Colab/
│ ├── data/
│ │ ├── bronchoscopy_train_data.json
│ │ ├── bronchoscopy_train_70.json
│ │ ├── bronchoscopy_validation_15.json
│ │ ├── bronchoscopy_test_15.json
│ │ └── bronchoscopy_split_manifest.json
│ ├── images/ # ảnh dùng cho train/evaluation
│ ├── llava-med/ # mã nguồn LLaVA-Med/GEMeX
│ ├── convert_bronchoscopy_vqa_to_gemex.py
│ ├── split_bronchoscopy_by_patient.py
│ ├── evaluate_bronchoscopy.py
│ └── install_colab.sh
└── gemex_outputs/
├── patient_split_local_v1/ # adapter đã train
└── evaluation/ # prediction và metric
Các lệnh bên dưới giả sử terminal đang đứng ở thư mục gốc:
cd /home/ailab/Documents/KC4.0_Final_annots_data_png
Khuyến nghị:
Kiểm tra GPU:
nvidia-smi
Kiểm tra Python:
python --version
Không đưa API key, token Hugging Face hoặc thông tin định danh bệnh nhân vào Git.
Tạo môi trường ảo:
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip setuptools wheel
Cài các phiên bản thư viện đã dùng trong dự án:
bash GEMeX-Colab/install_colab.sh
python -m pip install requests pillow
Script cài đặt chính gồm:
transformers==4.44.0accelerate==0.33.0peft==0.12.0bitsandbytes==0.49.2sentencepiece==0.2.0open_clip_torch==2.26.1bert-score==0.3.13GEMeX-Colab/llava-medNếu model yêu cầu đăng nhập Hugging Face:
huggingface-cli login
Kiểm tra import:
python - <<'PY'
import torch
import transformers
import peft
import bitsandbytes
print("PyTorch:", torch.__version__)
print("Transformers:", transformers.__version__)
print("CUDA available:", torch.cuda.is_available())
if torch.cuda.is_available():
print("GPU:", torch.cuda.get_device_name(0))
PY
Giải nén bundle vào /content/gemex, sau đó:
bash /content/gemex/GEMeX-Colab/install_colab.sh
Sau khi cài, restart runtime nếu Colab vẫn dùng phiên bản thư viện cũ.
Nếu chỉ muốn train lại trên dữ liệu hiện có, có thể bỏ qua mục này và bắt đầu từ Mục 6.
Mỗi ca trong Cabenh/ cần có:
.png.llm_input*.json, ưu tiên bản _with_colors.json.Ví dụ:
Cabenh/BH/HHNBM-112/
├── imgs/
│ └── <object_id>.png
└── llm_input_HHNBM-112_ver1_with_colors.json
Trước khi gọi API, chạy dry-run để xem script tìm thấy bao nhiêu file:
python generate_bronchoscopy_reports_gpt.py \
Cabenh \
--output-root Cabenh_reports \
--dry-run
Hai script sinh dữ liệu gọi endpoint tương thích OpenAI:
POST <BASE_URL>/chat/completions
Ví dụ với 9Router chạy local:
export NINEROUTER_BASE_URL=""
export NINEROUTER_API_KEY="YOUR_API_KEY"
Có thể thay --base-url, --model và tên biến key nếu dùng nhà cung cấp khác.
Chạy thử một file trước:
python generate_bronchoscopy_reports_gpt.py \
Cabenh \
--output-root Cabenh_reports \
--model gen-VQA \
--limit 1 \
--image-detail low
Nếu output hợp lệ, chạy toàn bộ:
python generate_bronchoscopy_reports_gpt.py \
Cabenh \
--output-root Cabenh_reports \
--model gen-VQA \
--image-detail low \
--max-retries 5 \
--base-sleep 5
Output có dạng:
Cabenh_reports/<cohort>/<patient>/..._reports.json
Script tự bỏ qua object_id đã có trong output và lưu sau mỗi ảnh. Nếu tiến trình bị ngắt, chạy lại đúng lệnh để tiếp tục.
Không thêm
--overwritekhi muốn resume. Tùy chọn này tạo lại output từ đầu.
Nếu chỉ muốn sinh report từ annotation, không gửi ảnh lên API:
python generate_bronchoscopy_reports_gpt.py \
Cabenh \
--output-root Cabenh_reports \
--model gen-VQA \
--no-images
Smoke test trên một file report:
REPORT_FILE="$(find Cabenh_reports -type f -name '*_reports.json' | head -n 1)"
python generate_bronchoscopy_vqa_gpt.py \
"$REPORT_FILE" \
--model gen-VQA \
--max-retries 5
Chạy toàn bộ thư mục:
python generate_bronchoscopy_vqa_gpt.py \
Cabenh_reports \
--model gen-VQA \
--max-retries 5 \
--base-sleep 5
Mỗi ảnh có thể sinh tối đa:
Script không ép đủ số câu nếu finding không đủ tin cậy. Output được lưu cạnh report với hậu tố _vqa.json. Cơ chế resume dựa trên object_id, vì vậy có thể chạy lại lệnh sau khi lỗi mạng hoặc dừng máy.
Đếm file:
find Cabenh_reports -type f -name '*_reports.json' | wc -l
find Cabenh_reports -type f -name '*_vqa.json' | wc -l
Kiểm tra JSON có đọc được:
python - <<'PY'
import json
from pathlib import Path
files = list(Path("Cabenh_reports").rglob("*_vqa.json"))
errors = []
objects = 0
for path in files:
try:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, list):
raise TypeError("top-level value must be a list")
objects += len(data)
except Exception as exc:
errors.append((str(path), str(exc)))
print("VQA files:", len(files))
print("Image objects:", objects)
print("Invalid files:", len(errors))
for item in errors[:20]:
print(item)
PY
Artifact đã gộp của dự án là:
all_vqa.json
Nếu sinh lại dữ liệu từ đầu, có thể làm phẳng toàn bộ *_vqa.json thành một
record cho mỗi câu hỏi bằng lệnh sau. Lệnh ghi ra
all_vqa_rebuilt.json để không vô tình đè artifact hiện có:
python - <<'PY'
import json
from pathlib import Path
groups = (
"open_ended_questions",
"closed_ended_questions",
"single_choice_questions",
"multi_choice_questions",
)
rows = []
for source in sorted(Path("Cabenh_reports").rglob("*_vqa.json")):
objects = json.loads(source.read_text(encoding="utf-8"))
for obj in objects:
for group in groups:
for index, question in enumerate(obj.get(group, []), start=1):
clues = question.get("visual_clue") or []
rows.append(
{
"case_id": obj.get("case_id", ""),
"object_id": obj.get("object_id", ""),
"image_path": obj.get("image_path", ""),
"source_file": str(source.resolve()),
"question_group": group,
"question_index_in_group": index,
"question": question.get("question", ""),
"question_format": question.get("question_format", ""),
"type": question.get("question_type", ""),
"question_type": question.get("question_type", ""),
"answer": question.get("answer", ""),
"reason": question.get("reason", ""),
"options": question.get("options", {}),
"visual_clue": clues,
"visual_regions": [
clue.get("loc_token")
for clue in clues
if clue.get("loc_token")
],
"visual_locations": [
clue.get("anatomical_location")
for clue in clues
if clue.get("anatomical_location")
],
}
)
output = Path("all_vqa_rebuilt.json")
output.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
print("Saved:", output)
print("VQA records:", len(rows))
PY
Sau khi kiểm tra số lượng, schema và đường dẫn ảnh, mới đổi bản rebuilt thành
all_vqa.json. Nên sao lưu artifact cũ trước khi thay thế.
Mỗi dòng logic tương ứng một câu hỏi, với các trường quan trọng:
{
"case_id": "...",
"object_id": "...",
"image_path": "...",
"question_group": "open_ended_questions",
"question": "...",
"question_format": "open-ended",
"question_type": "mucosa_status",
"answer": "...",
"reason": "...",
"options": {},
"visual_clue": []
}
Kiểm tra số lượng và đường dẫn ảnh:
python - <<'PY'
import json
from pathlib import Path
rows = json.loads(Path("all_vqa.json").read_text(encoding="utf-8"))
missing = [row["image_path"] for row in rows if not Path(row["image_path"]).exists()]
print("VQA records:", len(rows))
print("Missing image paths:", len(missing))
print("First missing paths:", missing[:10])
PY
Training bundle dùng đường dẫn tương đối:
images/Cabenh/<cohort>/<patient>/imgs/<image>.png
Trong khi all_vqa.json có thể chứa đường dẫn tuyệt đối. Tạo một bản trung gian đã chuẩn hóa:
python - <<'PY'
import json
from pathlib import Path
source = Path("all_vqa.json")
target = Path("GEMeX-Colab/data/all_vqa_training_paths.json")
rows = json.loads(source.read_text(encoding="utf-8"))
for row in rows:
parts = Path(row["image_path"]).parts
try:
start = parts.index("Cabenh")
except ValueError as exc:
raise ValueError(f"Path does not contain Cabenh: {row['image_path']}") from exc
row["image_path"] = str(Path("images").joinpath(*parts[start:]))
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
print("Saved:", target)
print("Records:", len(rows))
PY
Đảm bảo ảnh tương ứng nằm trong GEMeX-Colab/images/. Workspace hiện tại đã có 6.781 ảnh tại đây.
(
cd GEMeX-Colab
python convert_bronchoscopy_vqa_to_gemex.py \
--input data/all_vqa_training_paths.json \
--output data/bronchoscopy_train_data.json
)
Một record sau chuyển đổi có dạng:
{
"conversations": [
{"from": "human", "value": "<image>\nCâu hỏi..."},
{
"from": "gpt",
"value": "<answer> ... <reason> ... <location> [[x1,y1,x2,y2]]"
}
],
"row_id": 0,
"case_id": "...",
"object_id": "...",
"q_type": "open_ended_questions",
"question_type": "mucosa_status",
"type_prompt": "...",
"image": "images/Cabenh/..."
}
Nếu script báo Missing image paths > 0, không bắt đầu train. Kiểm tra lại bước chuẩn hóa và thư mục ảnh.
Chia dữ liệu theo bệnh nhân với seed cố định:
python GEMeX-Colab/split_bronchoscopy_by_patient.py \
--input GEMeX-Colab/data/bronchoscopy_train_data.json \
--output-dir GEMeX-Colab/data \
--seed 42
Output:
GEMeX-Colab/data/bronchoscopy_train_70.json
GEMeX-Colab/data/bronchoscopy_validation_15.json
GEMeX-Colab/data/bronchoscopy_test_15.json
GEMeX-Colab/data/bronchoscopy_split_manifest.json
Kiểm tra dòng cuối phải là:
Leakage checks passed: True
Có thể kiểm tra lại manifest:
python - <<'PY'
import json
from pathlib import Path
path = Path("GEMeX-Colab/data/bronchoscopy_split_manifest.json")
manifest = json.loads(path.read_text(encoding="utf-8"))
for name, stats in manifest["splits"].items():
print(
name,
"records=", stats["records"],
"patients=", stats["patients"],
"images=", stats["unique_images"],
)
print("Leakage passed:", manifest["leakage_checks"]["passed"])
PY
Không chia ngẫu nhiên từng câu hỏi hoặc từng ảnh. Nhiều ảnh của cùng một bệnh nhân phải nằm trong cùng một split.
Script fine_tune_bronchoscopy.sh dùng:
BoKelvin/GEMeX-VQA-Model-Simple.openai/clip-vit-large-patch14.2e-5, cosine scheduler.MAX_STEPS=1 \
OUTPUT_DIR="$PWD/gemex_outputs/smoke_test" \
bash GEMeX-Colab/llava-med/fine_tune_bronchoscopy.sh
Smoke test đạt yêu cầu khi:
1/1.train_loss hữu hạn và khác 0.grad_norm hữu hạn.Nếu smoke test lỗi, không chạy full training.
OUTPUT_DIR="$PWD/gemex_outputs/patient_split_local_v1" \
bash GEMeX-Colab/llava-med/fine_tune_bronchoscopy.sh \
2>&1 | tee "$PWD/gemex_outputs/train_console.log"
Không dùng lại một output directory cũ cho thí nghiệm có cấu hình khác. Nên đặt tên theo phiên bản:
gemex_outputs/patient_split_seed42_v1
gemex_outputs/patient_split_seed42_lr1e-5_v2
Giảm context length:
MODEL_MAX_LENGTH=768 \
OUTPUT_DIR="$PWD/gemex_outputs/patient_split_ctx768_v1" \
bash GEMeX-Colab/llava-med/fine_tune_bronchoscopy.sh
Chọn GPU:
CUDA_VISIBLE_DEVICES=0 \
OUTPUT_DIR="$PWD/gemex_outputs/patient_split_local_v1" \
bash GEMeX-Colab/llava-med/fine_tune_bronchoscopy.sh
Trong output directory, tối thiểu cần giữ:
adapter_model.safetensors
adapter_config.json
tokenizer.model
tokenizer_config.json
special_tokens_map.json
added_tokens.json
trainer_state.json
README.md
Các checkpoint-* chứa optimizer và scheduler cần thiết nếu muốn resume training. Chỉ xóa chúng sau khi chắc chắn không cần tiếp tục train và adapter cuối đã được lưu ở thư mục cha.
Đặt các biến dùng chung:
PROJECT_ROOT="$PWD"
ADAPTER_DIR="$PROJECT_ROOT/gemex_outputs/patient_split_local_v1"
EVAL_DIR="$PROJECT_ROOT/gemex_outputs/evaluation"
mkdir -p "$EVAL_DIR"
Chạy thử 10 mẫu:
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_validation_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/validation_predictions.jsonl" \
--max-samples 10
Chạy lại mà không có --max-samples:
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_validation_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/validation_predictions.jsonl" \
2>&1 | tee -a "$EVAL_DIR/validation_console.log"
Evaluator ghi từng prediction vào JSONL và flush ngay. Nếu bị ngắt, chạy lại đúng lệnh; các index đã hoàn thành sẽ được bỏ qua.
Sau khi prediction hoàn tất:
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_validation_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/validation_predictions.jsonl" \
--metrics-only \
--bertscore \
--bertscore-device cuda
Metric được lưu tại:
gemex_outputs/evaluation/validation_predictions.jsonl.metrics.json
Chỉ dùng validation để chọn checkpoint, prompt, parser và hyperparameter. Không điều chỉnh mô hình dựa trên test.
Chỉ chạy test sau khi đã khóa:
max_new_tokens.PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_test_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/test_predictions.jsonl" \
--max-samples 10
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_test_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/test_predictions.jsonl" \
2>&1 | tee -a "$EVAL_DIR/test_console.log"
Không chạy validation và test đồng thời trên cùng GPU.
Theo dõi tiến độ:
wc -l "$EVAL_DIR/test_predictions.jsonl"
tail -f "$EVAL_DIR/test_console.log"
Tổng test hiện tại có 8.201 mẫu. Khi wc -l bằng 8.201, prediction đã đủ.
PYTHONPATH="$PROJECT_ROOT/GEMeX-Colab/llava-med" \
python "$PROJECT_ROOT/GEMeX-Colab/evaluate_bronchoscopy.py" \
--adapter "$ADAPTER_DIR" \
--data "$PROJECT_ROOT/GEMeX-Colab/data/bronchoscopy_test_15.json" \
--image-folder "$PROJECT_ROOT/GEMeX-Colab" \
--output "$EVAL_DIR/test_predictions.jsonl" \
--metrics-only \
--bertscore \
--bertscore-device cuda
Output:
gemex_outputs/evaluation/test_predictions.jsonl.metrics.json
| Metric | Ý nghĩa |
|---|---|
| Answer Exact Match | Câu trả lời sau chuẩn hóa phải trùng hoàn toàn |
| Answer Token F1 | Mức chồng lấp token giữa dự đoán và đáp án |
| Closed-ended precision/recall/F1 | Chất lượng phân loại yes/no |
| Specificity | Khả năng nhận đúng mẫu âm |
| Single-choice accuracy | Tỷ lệ chọn đúng một đáp án |
| Multi-choice exact-set | Tập lựa chọn phải trùng hoàn toàn |
| Multi-choice micro/macro F1 | Đúng theo từng lựa chọn |
| Jaccard | Mức giao nhau của hai tập lựa chọn |
| ROUGE-L | Độ giống chuỗi cho câu hỏi mở |
| CIDEr | Độ tương đồng nội dung với đáp án tham chiếu |
| BERTScore | Độ gần nhau về ngữ nghĩa |
| Location IoU | Mức chồng lấp bounding box |
| [email protected] | Tỷ lệ mẫu có IoU từ 0,5 trở lên |
Không nên chỉ nhìn Exact Match cho câu hỏi mở. Cần đọc đồng thời Token F1, ROUGE-L, CIDEr, BERTScore và một mẫu lỗi do chuyên gia kiểm tra.
Để so sánh công bằng, GPT và mô hình fine-tuned phải chạy trên đúng cùng test split. Tạo raw test set từ row_id:
python - <<'PY'
import json
from pathlib import Path
raw = json.loads(Path("all_vqa.json").read_text(encoding="utf-8"))
test = json.loads(
Path("GEMeX-Colab/data/bronchoscopy_test_15.json").read_text(encoding="utf-8")
)
selected = [raw[item["row_id"]] for item in test]
output = Path("vqa_llm_eval/test_patient_split_raw.json")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(selected, ensure_ascii=False, indent=2), encoding="utf-8")
print("Saved:", output, "records:", len(selected))
PY
Sau đó gọi một endpoint tương thích OpenAI:
export OPENAI_API_KEY="YOUR_API_KEY"
python vqa_llm_eval/run_openai_compatible_vqa.py \
--input vqa_llm_eval/test_patient_split_raw.json \
--output vqa_llm_eval/results/gpt4o_mini_test.jsonl \
--model gpt-4o-mini \
--image-detail low \
--sleep 0.5
Chạy thử --limit 20 trước khi chạy toàn bộ để kiểm tra quyền truy cập, prompt, parser và chi phí:
python vqa_llm_eval/run_openai_compatible_vqa.py \
--input vqa_llm_eval/test_patient_split_raw.json \
--output vqa_llm_eval/results/gpt4o_mini_smoke.jsonl \
--model gpt-4o-mini \
--image-detail low \
--limit 20
Khi báo cáo, cần lưu model ID thực tế, provider, model snapshot, temperature, prompt version, token và chi phí. Không so sánh GPT trên một phần all_vqa.json với BoKelvin trên patient-level test split.
MODEL_MAX_LENGTH từ 1.024 xuống 768 hoặc 512.Kiểm tra process GPU:
nvidia-smi
Nếu convert hoặc evaluation báo thiếu ảnh:
find GEMeX-Colab/images -type f -name '*.png' | wc -l
Kiểm tra trường image phải có dạng tương đối bắt đầu bằng images/.
--base-sleep.--overwrite.--max-tokens nếu output bị cắt.all_vqa.json trước khi kiểm tra.Không xóa file prediction JSONL. Chạy lại đúng lệnh; evaluator tự tiếp tục từ các index chưa có.
File metric chỉ phản ánh các prediction có tại thời điểm tính. Chỉ tạo metric cuối khi số dòng prediction bằng số record của split.
Mỗi lần chạy nên ghi lại:
Tính checksum:
sha256sum \
GEMeX-Colab/data/bronchoscopy_split_manifest.json \
GEMeX-Colab/data/bronchoscopy_train_70.json \
GEMeX-Colab/data/bronchoscopy_validation_15.json \
GEMeX-Colab/data/bronchoscopy_test_15.json \
gemex_outputs/patient_split_local_v1/adapter_model.safetensors
Trước khi train:
Leakage checks passed: True.Trước khi validation:
<answer>, <reason>, <location>.Trước khi test:
Sau test: