198 lines
7.4 KiB
Python
198 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Push top-6-retrieval multi-image (3-image) LoRA adapters to HuggingFace.
|
||
|
||
Uploads adapter files + tokenizer + minimal metadata (no optimizer state,
|
||
no RNG, no DeepSpeed raw).
|
||
|
||
Usage:
|
||
HF_TOKEN=$(cat ~/.cache/huggingface/token) python sft/push_multi3_to_hf.py
|
||
"""
|
||
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
from huggingface_hub import create_repo, upload_folder
|
||
|
||
TOKEN = (
|
||
os.environ.get("HF_TOKEN")
|
||
or open(os.path.expanduser("~/.cache/huggingface/token")).read().strip()
|
||
)
|
||
USER = "Chrisyichuan"
|
||
|
||
# Each entry: (compression label, adapter dir, LLM-judge score, config summary, epoch/step note)
|
||
BEST = [
|
||
(
|
||
"2x",
|
||
"/scratch/users/zwcolin/cxr_embeds/sft_output/qwen3vl_top6_2x_v1",
|
||
0.954,
|
||
"r=256, 2ep, lr 1e-5, LLM+ViT LoRA, cutoff_len 5120, eff-batch 32 on 8× H100",
|
||
"step 6502 (2 epochs)",
|
||
),
|
||
(
|
||
"3x",
|
||
"/scratch/users/zwcolin/cxr_embeds/sft_output/qwen3vl_top6_3x_v1",
|
||
0.900,
|
||
"r=256, 2ep, lr 1e-5, LLM+ViT LoRA, cutoff_len 4096, eff-batch 32 on 8× H100",
|
||
"step 6502 (2 epochs)",
|
||
),
|
||
(
|
||
"4x",
|
||
"/scratch/users/zwcolin/cxr_embeds/sft_output/qwen3vl_top6_4x_v1",
|
||
0.868,
|
||
"r=256, 2ep, lr 1e-5, LLM+ViT LoRA, cutoff_len 3072, eff-batch 32 on 8× H100",
|
||
"step 6502 (2 epochs)",
|
||
),
|
||
]
|
||
|
||
# Multi-image baselines (same 500 test set, GPT-4.1 judge, 3 images per sample, gold always in the 3)
|
||
BASE_MULTIIMAGE_0X = 0.892 # base Qwen3-VL-4B on uncompressed top-3
|
||
SINGLE_IMAGE_0X_BASELINE = (
|
||
0.958 # base Qwen3-VL-4B on uncompressed single image (from RESULTS.md)
|
||
)
|
||
|
||
KEEP_FILES = {
|
||
"adapter_config.json",
|
||
"adapter_model.safetensors",
|
||
"added_tokens.json",
|
||
"chat_template.jinja",
|
||
"merges.txt",
|
||
"preprocessor_config.json",
|
||
"special_tokens_map.json",
|
||
"tokenizer.json",
|
||
"tokenizer_config.json",
|
||
"training_args.bin",
|
||
"trainer_state.json",
|
||
"video_preprocessor_config.json",
|
||
"vocab.json",
|
||
}
|
||
|
||
|
||
def build_readme(comp, judge, config, step_note):
|
||
n_ratio = int(comp.rstrip("x"))
|
||
return f"""---
|
||
license: apache-2.0
|
||
library_name: peft
|
||
base_model: Qwen/Qwen3-VL-4B-Instruct
|
||
tags:
|
||
- peft
|
||
- lora
|
||
- qwen3-vl
|
||
- screenshot-qa
|
||
- multi-image
|
||
- retrieval-augmented
|
||
- compressed-images
|
||
- {comp}-compression
|
||
pipeline_tag: image-text-to-text
|
||
---
|
||
|
||
# Qwen3-VL-4B Multi-Image Wikipedia Screenshot QA LoRA — {comp} compression (top-3 retrieval)
|
||
|
||
LoRA adapter for [Qwen3-VL-4B-Instruct](https://huggingface.co/Qwen/Qwen3-VL-4B-Instruct) fine-tuned as the **reader** in a retrieval-augmented QA pipeline: given a question and **3 candidate Wikipedia screenshots** (one of which contains the answer, two are hard distractors), the model must locate the right image and extract the answer — all under **{comp} pixel compression**.
|
||
|
||
## Performance (GPT-4.1 LLM-judge on 500 test examples)
|
||
|
||
| Setup | LLM-judge |
|
||
|---|---|
|
||
| Uncompressed (0x) single-image, base Qwen3-VL-4B | {SINGLE_IMAGE_0X_BASELINE:.3f} |
|
||
| Uncompressed (0x) **multi-image (3)**, base Qwen3-VL-4B | {BASE_MULTIIMAGE_0X:.3f} |
|
||
| **This adapter @ {comp} multi-image (3)** | **{judge:.3f}** |
|
||
|
||
**Key observation:** {"The 2x multi-image SFT model nearly recovers the single-image uncompressed ceiling (0.958), and clearly exceeds the un-SFTed multi-image base (0.892) — fine-tuning compensates for both distractor confusion and 2x compression." if comp == "2x" else "Despite 3x pixel compression, this model slightly exceeds the un-SFTed multi-image uncompressed base (0.892) — fine-tuning compensates for both distractor confusion and 3x compression." if comp == "3x" else "At 4x compression the per-image pixel budget is ~¼ the original. Even with SFT, accuracy falls slightly below the un-SFTed multi-image 0x base (0.892). This checkpoint documents the compression–quality frontier at the extreme end; use 2x/3x for production."}
|
||
|
||
Gain over multi-image base @ 0x: **{judge - BASE_MULTIIMAGE_0X:+.3f}** ({100 * (judge - BASE_MULTIIMAGE_0X) / BASE_MULTIIMAGE_0X:+.1f}% relative).
|
||
|
||
## Training setup
|
||
|
||
- Method: LoRA ({config})
|
||
- Base model: `Qwen/Qwen3-VL-4B-Instruct`
|
||
- Framework: [LLaMA-Factory](https://github.com/hiyouga/LLaMA-Factory) (fork)
|
||
- Hardware: 8× H100 80GB, DeepSpeed ZeRO-2, bf16
|
||
- Checkpoint: {step_note}
|
||
|
||
## Data — retrieval-augmented multi-image
|
||
|
||
Training data was built from the [Chrisyichuan screenshot-training-natural-filtered-v2](https://huggingface.co/datasets/Chrisyichuan/screenshot-training-natural-filtered-v2) QA dataset:
|
||
|
||
1. For each query, retrieve top-6 screenshots from a Qwen3-VL-2B embedding index (dora-ls005 checkpoint) over 28M Wikipedia tiles.
|
||
2. Construct a 3-image set: **always include the gold** + up to 2 non-gold retrieved distractors.
|
||
3. Randomize gold position among the 3 to avoid positional shortcuts.
|
||
4. Apply **{comp} compression** (each dimension scaled by `1/sqrt({n_ratio})` via PIL LANCZOS).
|
||
5. Train the reader to answer the query given the 3 compressed images.
|
||
|
||
~104k training examples, 5.8k validation, 5.8k test. Gold-retrieval rate at top-6 across splits: ~75%.
|
||
|
||
## Usage
|
||
|
||
```python
|
||
from transformers import AutoProcessor, Qwen3VLForConditionalGeneration
|
||
from peft import PeftModel
|
||
import torch
|
||
|
||
base = "Qwen/Qwen3-VL-4B-Instruct"
|
||
adapter = "{USER}/qwen3vl-4b-wiki-screenshot-multi3-{comp}-lora"
|
||
|
||
model = Qwen3VLForConditionalGeneration.from_pretrained(base, torch_dtype=torch.bfloat16).cuda()
|
||
model = PeftModel.from_pretrained(model, adapter).merge_and_unload()
|
||
processor = AutoProcessor.from_pretrained(base)
|
||
|
||
# Three {comp}-compressed images (one gold + two distractors, any order)
|
||
messages = [{{"role": "user", "content": [
|
||
{{"type": "image", "image": img1}},
|
||
{{"type": "image", "image": img2}},
|
||
{{"type": "image", "image": img3}},
|
||
{{"type": "text", "text": your_question}},
|
||
]}}]
|
||
# ... standard Qwen3-VL inference
|
||
```
|
||
|
||
## Notes / limitations
|
||
|
||
- Training distribution **always includes the gold** in the 3-image set (by construction). If your retriever misses the gold, the model has not seen that distribution — expect degradation on those queries.
|
||
- Compression level is fixed at {comp}. Use the adapter that matches your deployment pixel budget.
|
||
- Sister adapters at other compression levels: {", ".join(f"`{USER}/qwen3vl-4b-wiki-screenshot-multi3-{x}-lora`" for x in ["2x", "3x", "4x"] if x != comp)}.
|
||
"""
|
||
|
||
|
||
def push_one(comp, adapter_dir, judge, config, step_note):
|
||
src = Path(adapter_dir)
|
||
assert src.exists(), f"missing {src}"
|
||
|
||
repo_id = f"{USER}/qwen3vl-4b-wiki-screenshot-multi3-{comp}-lora"
|
||
print(f"\n=== {repo_id} ===")
|
||
print(f" src: {src}")
|
||
print(f" judge: {judge}")
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
tmp = Path(tmpdir)
|
||
for name in KEEP_FILES:
|
||
p = src / name
|
||
if p.exists():
|
||
shutil.copy2(p, tmp / name)
|
||
print(f" + {name}")
|
||
else:
|
||
print(f" - {name} (not present, skipped)")
|
||
|
||
(tmp / "README.md").write_text(build_readme(comp, judge, config, step_note))
|
||
print(" + README.md")
|
||
|
||
create_repo(repo_id, token=TOKEN, exist_ok=True, private=False)
|
||
upload_folder(
|
||
folder_path=str(tmp),
|
||
repo_id=repo_id,
|
||
token=TOKEN,
|
||
commit_message=f"Upload multi-image {comp} LoRA (LLM-judge {judge})",
|
||
)
|
||
print(f" uploaded → https://huggingface.co/{repo_id}")
|
||
|
||
|
||
def main():
|
||
for args in BEST:
|
||
push_one(*args)
|
||
print("\nAll done.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|