Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

224 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Push variable-k (k=1..6) multi-image LoRA adapters (v2) to HuggingFace.
Each adapter was trained to handle an arbitrary number of images per sample
(k ∈ {1..6}), with the gold screenshot always included at a random position.
All images at a fixed compression ratio (2x / 3x / 4x).
Uploads adapter files + tokenizer + minimal metadata (no optimizer state,
no RNG, no DeepSpeed raw).
Usage:
HF_TOKEN=$(cat ~/.cache/huggingface/token) python sft/push_multik_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: (comp label, adapter dir, cutoff, k=1/k=2/k=3/k=4 LLM-judge)
# 2x: v3 mixed-data (top3+vark, r=256, 2ep) — recovered v1's k=3 ceiling, all-k wins.
# 3x: v5 mixed-data with 2x top3 oversample (top3+top3+vark, r=256, 2ep), best ckpt at step 16000
# — exceeds base@0x at k=2/3/4 and beats v1's k=3 ceiling (0.900) by +0.032.
# 4x: v2 vark-only (r=256, 2ep) — mixed-data regressed at 4x (tight pixel budget can't absorb dual signals).
BEST = [
(
"2x",
"/scratch/users/zwcolin/cxr_embeds/sft_output/qwen3vl_top6_2x_v3",
10240,
(0.946, 0.950, 0.954, 0.936),
),
(
"3x",
"/scratch/users/zwcolin/cxr_embeds/sft_output/qwen3vl_top6_3x_v5/checkpoint-16000",
8192,
(0.904, 0.918, 0.932, 0.884),
),
(
"4x",
"/scratch/users/zwcolin/cxr_embeds/sft_output/qwen3vl_top6_4x_v2",
6144,
(0.834, 0.828, 0.814, 0.810),
),
]
# Base Qwen3-VL-4B reference (uncompressed, no SFT), same 500-example test set
BASE_0X = {1: 0.958, 2: 0.912, 3: 0.892, 4: 0.856}
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, adapter_dir, cutoff, scores):
n_ratio = int(comp.rstrip("x"))
sk1, sk2, sk3, sk4 = scores
rows = []
for k in (1, 2, 3, 4):
base = BASE_0X[k]
this = scores[k - 1]
delta = this - base
rows.append(f"| {k} | {base:.3f} | **{this:.3f}** | {delta:+.3f} |")
table = "\n".join(rows)
siblings = ", ".join(
f"`{USER}/qwen3vl-4b-wiki-screenshot-multik-{x}x-lora`"
for x in ("2", "3", "4")
if x != 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
- variable-k
- retrieval-augmented
- compressed-images
- {comp}-compression
pipeline_tag: image-text-to-text
---
# Qwen3-VL-4B Wiki-Screenshot QA LoRA — **variable-k (16 images)** @ {comp} compression
LoRA adapter for [Qwen/Qwen3-VL-4B-Instruct](https://huggingface.co/Qwen/Qwen3-VL-4B-Instruct), fine-tuned as the **reader** in a retrieval-augmented Wikipedia-screenshot QA pipeline.
Unlike the earlier fixed-3-image adapters, this v2 reads **any number of screenshots from 1 to 6** at inference time, at a fixed **{comp} pixel compression** per image.
## Performance (GPT-4.1 LLM-judge on 500 test examples)
| k (images per sample) | base Qwen3-VL-4B @ 0x (no compression, no SFT) | **This adapter @ {comp}** | Δ |
|---|---|---|---|
{table}
Base@0x degrades sharply as the number of images grows (distractor confusion: 0.958 → 0.856 from k=1 to k=4). The {comp} adapter is nearly **k-invariant**: {("0.946 → 0.936 from k=1 to k=4 — only 0.010 drop." if comp == "2x" else "0.904 → 0.884 from k=1 to k=4 — only 0.020 drop." if comp == "3x" else "0.834 → 0.810 from k=1 to k=4.")}
{"At **2x compression**, this adapter exceeds the uncompressed no-SFT baseline at k≥2 and nearly matches it at k=1 (0.946 vs 0.958), demonstrating that reader SFT fully compensates for both 2x compression *and* multi-image distractor confusion." if comp == "2x" else "At **3x compression**, this adapter exceeds the uncompressed no-SFT baseline at k=2/3/4 (0.918/0.932/0.884 vs 0.912/0.892/0.856) and trails it only at k=1. The k=3 score (0.932) also beats the prior fixed-k=3 specialist ceiling (0.900) by +0.032 — a heavier 2× top3 oversample during training pushed multi-k flexibility past the single-k specialist." if comp == "3x" else "At **4x compression**, the per-image pixel budget is ~¼ the original. Even with variable-k SFT, this adapter trails the 2x and 3x variants. Use only under a hard pixel budget."}
## Training setup
- Method: LoRA (r=256, alpha=256, targets=all linear layers including ViT; `freeze_vision_tower=false`)
- Base model: `Qwen/Qwen3-VL-4B-Instruct`
- Framework: [LLaMA-Factory](https://github.com/hiyouga/LLaMA-Factory) (fork)
- Optimizer: cosine LR, peak 1e-5, warmup 3%
- Effective batch: 32 (per_device=1 × 8 GPUs × grad_accum=4)
- `cutoff_len`: **{cutoff}** (scaled per compression — larger for smaller compression where each image takes more tokens)
- Hardware: 8× H100 80GB, DeepSpeed ZeRO-2, bf16
- {("Training data: mixed top3 (fixed k=3, 104k) + vark (k=1..6, 104k) at 1:1 ratio, 2 epochs over 208k samples (final adapter at step 13004)." if comp == "2x" else "Training data: mixed **2× top3 (fixed k=3, 208k oversampled) + vark (k=1..6, 104k)** at 2:1 ratio, 2 epochs over 312k samples per epoch. The released adapter is the **best intermediate checkpoint at step 16000** (~1.64 epochs / 0.82 of total training) selected by peak eval exact-match — full 2 epochs slightly overfit on the eval split." if comp == "3x" else "Training data: vark-only (k=1..6, 104k), 2 epochs over 104k samples (final adapter at step 6502). Mixed-data and longer-epoch recipes both regressed at this compression — token-level eval improved but LLM-judge dropped, indicating the 4x pixel budget cannot absorb additional training signal.")}
## Data — variable-k retrieval-augmented multi-image
Built from the [Chrisyichuan/screenshot-training-natural-filtered-v2](https://huggingface.co/datasets/Chrisyichuan/screenshot-training-natural-filtered-v2) QA dataset (~104k train examples):
1. For each query, retrieve top-6 screenshots from a Qwen3-VL-2B embedding index (dora-ls005 checkpoint) over 28M Wikipedia tiles.
2. **Per-sample, uniformly sample k ∈ {{1, 2, 3, 4, 5, 6}}**.
3. Compose the k-image set: **gold always included** + (k-1) non-gold hits (padded with gold repeats if retrieval has fewer than k-1 non-gold hits).
4. Randomize gold position among the k images (prevents positional shortcuts).
5. Apply {comp} compression (each dimension scaled by `1/sqrt({n_ratio})` via PIL LANCZOS).
6. Train the reader with `<image>×k \\n {{query}}` → gold answer.
**Distribution**: ~17k train samples per k value (uniform). eval/test sets likewise stratified over k∈{{1..6}}.
Gold-retrieval rate at top-6 across splits: ~75%. When gold is present at rank 1..6 among the retrieved hits, composition uses only retrieval results (else gold is always still included by construction).
## 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-multik-{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)
# k can be any integer in 1..6. Images must already be {comp}-compressed.
messages = [{{"role": "user", "content": [
{{"type": "image", "image": img_1}},
# ... up to img_6 ...
{{"type": "text", "text": your_question}},
]}}]
# ... standard Qwen3-VL inference
```
## Notes / limitations
- **Pixel budget is fixed at {comp}**. If your deployment can afford less compression, use the 2x sibling; if more, use the 4x sibling. Mixing compression at inference is untested.
- **Training always included gold in the image set**. If your retriever misses the gold at inference, this adapter has not seen that distribution — expect degradation on those queries.
- For **k ∈ {{1, 2, 3, 4}}** this adapter was evaluated with GPT-4.1 LLM-judge. k=5/6 were trained on but not explicitly benchmarked.
- {("v1 of this adapter (trained on fixed k=3 only) achieved slightly higher k=3 score but collapses at other k. This v2 trades ~0.02 at k=3 for flexibility across k=1..6." if comp != "3x" else "The 2× top3 oversample is asymmetric on purpose: at 3x compression the per-image budget is tight enough that purely uniform-k training under-specializes for the most common deployment k=3. Doubling top3 (104k extra fixed-k=3 samples) lifts k=3 from 0.892 to 0.932 (+0.040) while still gaining vs the uniform-k baseline at all other k. The same recipe regressed at 4x — see the 4x sibling adapter card.")}
- Sister adapters at other compression levels: {siblings}.
"""
def push_one(comp, adapter_dir, cutoff, scores):
src = Path(adapter_dir)
assert src.exists(), f"missing {src}"
repo_id = f"{USER}/qwen3vl-4b-wiki-screenshot-multik-{comp}-lora"
print(f"\n=== {repo_id} ===")
print(f" src: {src}")
print(f" k=1..4 LLM-judge: {scores}")
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, adapter_dir, cutoff, scores))
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 variable-k (1..6) {comp} LoRA",
)
print(f" uploaded → https://huggingface.co/{repo_id}")
def main():
for args in BEST:
push_one(*args)
print("\nAll done.")
if __name__ == "__main__":
main()