|
| 1 | +""" |
| 2 | +Compare Qwen3-VL-Embedding-2B outputs between mlx-embeddings (this repo) |
| 3 | +and the official HF transformers reference implementation. |
| 4 | +
|
| 5 | +Loads both, runs the same GALLERY + QUERIES through both, and prints the |
| 6 | +two similarity matrices side-by-side with max/mean abs diff. |
| 7 | +
|
| 8 | +Run (with torch + transformers available): |
| 9 | + uv run --with torch --with transformers --with qwen-vl-utils \\ |
| 10 | + --with pillow --with requests --with matplotlib --with mlx \\ |
| 11 | + --with mlx-vlm --with huggingface-hub --with accelerate \\ |
| 12 | + examples/compare_mlx_vs_transformers.py |
| 13 | +
|
| 14 | +Or inside a venv that has those deps installed. |
| 15 | +""" |
| 16 | +from __future__ import annotations |
| 17 | + |
| 18 | +import importlib.util |
| 19 | +import sys |
| 20 | +from io import BytesIO |
| 21 | +from pathlib import Path |
| 22 | + |
| 23 | +import numpy as np |
| 24 | +import requests |
| 25 | +from PIL import Image |
| 26 | + |
| 27 | +GALLERY = [ |
| 28 | + ("woman with dog on beach", |
| 29 | + "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen-VL/assets/demo.jpeg"), |
| 30 | + ("two cats on a couch", |
| 31 | + "http://images.cocodataset.org/val2017/000000039769.jpg"), |
| 32 | + ("tennis player on a court", |
| 33 | + "http://images.cocodataset.org/val2017/000000000872.jpg"), |
| 34 | + ("bear in the wild", |
| 35 | + "http://images.cocodataset.org/val2017/000000000285.jpg"), |
| 36 | + ("dark train tunnel", |
| 37 | + "http://images.cocodataset.org/val2017/000000001268.jpg"), |
| 38 | + ("group of people standing together", |
| 39 | + "http://images.cocodataset.org/val2017/000000001000.jpg"), |
| 40 | +] |
| 41 | + |
| 42 | +QUERIES = [ |
| 43 | + "a person spending time with their pet outdoors at sunset", |
| 44 | + "sleepy cats relaxing indoors", |
| 45 | + "someone playing a racquet sport", |
| 46 | + "wildlife in a natural habitat", |
| 47 | + "the inside of a transit tunnel", |
| 48 | + "a crowd of people gathered outside", |
| 49 | +] |
| 50 | + |
| 51 | +INSTRUCTION = "Retrieve images that match the user's query." |
| 52 | +MODEL_ID = "Qwen/Qwen3-VL-Embedding-2B" |
| 53 | + |
| 54 | + |
| 55 | +def fetch_image(src: str) -> Image.Image: |
| 56 | + if src.startswith(("http://", "https://")): |
| 57 | + return Image.open(BytesIO(requests.get(src, timeout=30).content)).convert("RGB") |
| 58 | + return Image.open(src).convert("RGB") |
| 59 | + |
| 60 | + |
| 61 | +def run_mlx(images, queries): |
| 62 | + import mlx.core as mx |
| 63 | + from mlx_embeddings import load |
| 64 | + |
| 65 | + model, processor = load(MODEL_ID) |
| 66 | + image_embeds = model.process( |
| 67 | + [{"image": img} for img in images], processor=processor |
| 68 | + ) |
| 69 | + mx.eval(image_embeds) |
| 70 | + text_embeds = model.process( |
| 71 | + [{"text": q, "instruction": INSTRUCTION} for q in queries], |
| 72 | + processor=processor, |
| 73 | + ) |
| 74 | + mx.eval(text_embeds) |
| 75 | + sim = (text_embeds @ image_embeds.T).astype(mx.float32) |
| 76 | + mx.eval(sim) |
| 77 | + return np.array(sim) |
| 78 | + |
| 79 | + |
| 80 | +def _load_hf_reference(): |
| 81 | + """Import the model's bundled scripts/qwen3_vl_embedding.py (the reference |
| 82 | + implementation shipped with the checkpoint) without needing it on sys.path. |
| 83 | + """ |
| 84 | + from huggingface_hub import snapshot_download |
| 85 | + import transformers.utils.generic as _tgeneric |
| 86 | + |
| 87 | + # The reference script was written against transformers 4.x and imports |
| 88 | + # `check_model_inputs`, which was removed in 5.x. The one call site using |
| 89 | + # the decorator is already commented out, so a no-op is safe. |
| 90 | + if not hasattr(_tgeneric, "check_model_inputs"): |
| 91 | + def check_model_inputs(fn): |
| 92 | + return fn |
| 93 | + _tgeneric.check_model_inputs = check_model_inputs |
| 94 | + |
| 95 | + local = Path(snapshot_download(MODEL_ID)) |
| 96 | + script_path = local / "scripts" / "qwen3_vl_embedding.py" |
| 97 | + spec = importlib.util.spec_from_file_location("qwen3_vl_embedding", script_path) |
| 98 | + mod = importlib.util.module_from_spec(spec) |
| 99 | + sys.modules["qwen3_vl_embedding"] = mod |
| 100 | + spec.loader.exec_module(mod) |
| 101 | + return mod |
| 102 | + |
| 103 | + |
| 104 | +def run_transformers(images, queries): |
| 105 | + import torch |
| 106 | + |
| 107 | + ref = _load_hf_reference() |
| 108 | + embedder = ref.Qwen3VLEmbedder(MODEL_ID, torch_dtype=torch.bfloat16) |
| 109 | + |
| 110 | + image_inputs = [{"image": img} for img in images] |
| 111 | + text_inputs = [{"text": q, "instruction": INSTRUCTION} for q in queries] |
| 112 | + |
| 113 | + image_embeds = embedder.process(image_inputs, normalize=True).to(torch.float32).cpu() |
| 114 | + text_embeds = embedder.process(text_inputs, normalize=True).to(torch.float32).cpu() |
| 115 | + sim = (text_embeds @ image_embeds.T).numpy() |
| 116 | + return sim |
| 117 | + |
| 118 | + |
| 119 | +def format_matrix(name: str, sim: np.ndarray, row_labels, col_labels) -> str: |
| 120 | + header = " " + " ".join(f"{c:>6}" for c in col_labels) |
| 121 | + rows = [f" q{i}: " + " ".join(f"{v:+.3f}" for v in sim[i]) + f" <- {r}" |
| 122 | + for i, r in enumerate(row_labels)] |
| 123 | + return f"=== {name} ===\n{header}\n" + "\n".join(rows) |
| 124 | + |
| 125 | + |
| 126 | +def top_k_table(sim: np.ndarray, labels, queries, k: int = 3) -> str: |
| 127 | + lines = [] |
| 128 | + for i, q in enumerate(queries): |
| 129 | + order = np.argsort(-sim[i])[:k] |
| 130 | + picks = ", ".join(f"{labels[j]}({sim[i, j]:+.3f})" for j in order) |
| 131 | + lines.append(f" q{i} {q!r}\n {picks}") |
| 132 | + return "\n".join(lines) |
| 133 | + |
| 134 | + |
| 135 | +def main() -> None: |
| 136 | + labels, urls = zip(*GALLERY) |
| 137 | + short_labels = [f"img{i}" for i in range(len(GALLERY))] |
| 138 | + print(f"Fetching {len(GALLERY)} gallery images…") |
| 139 | + images = [fetch_image(u) for u in urls] |
| 140 | + |
| 141 | + print("\n[MLX] running mlx-embeddings path…") |
| 142 | + sim_mlx = run_mlx(images, QUERIES) |
| 143 | + |
| 144 | + print("[HF ] running transformers reference path…") |
| 145 | + sim_hf = run_transformers(images, QUERIES) |
| 146 | + |
| 147 | + print() |
| 148 | + print(format_matrix("mlx-embeddings", sim_mlx, QUERIES, short_labels)) |
| 149 | + print() |
| 150 | + print(format_matrix("transformers reference", sim_hf, QUERIES, short_labels)) |
| 151 | + |
| 152 | + diff = sim_mlx - sim_hf |
| 153 | + print("\n=== diff (mlx - hf) ===") |
| 154 | + print(f" max |diff|: {np.abs(diff).max():.4f}") |
| 155 | + print(f" mean |diff|: {np.abs(diff).mean():.4f}") |
| 156 | + print(f" per-row max |diff|: " |
| 157 | + f"{np.array2string(np.abs(diff).max(axis=1), precision=4)}") |
| 158 | + |
| 159 | + def rank(s): |
| 160 | + return np.argsort(-s, axis=1) |
| 161 | + |
| 162 | + ranks_mlx = rank(sim_mlx) |
| 163 | + ranks_hf = rank(sim_hf) |
| 164 | + top1_agree = (ranks_mlx[:, 0] == ranks_hf[:, 0]).mean() |
| 165 | + top3_agree = np.mean([ |
| 166 | + set(ranks_mlx[i, :3]) == set(ranks_hf[i, :3]) |
| 167 | + for i in range(len(QUERIES)) |
| 168 | + ]) |
| 169 | + print(f"\n top-1 agreement across queries: {top1_agree * 100:.0f}%") |
| 170 | + print(f" top-3 set agreement: {top3_agree * 100:.0f}%") |
| 171 | + |
| 172 | + print("\n=== mlx top-3 per query ===") |
| 173 | + print(top_k_table(sim_mlx, labels, QUERIES)) |
| 174 | + print("\n=== hf top-3 per query ===") |
| 175 | + print(top_k_table(sim_hf, labels, QUERIES)) |
| 176 | + |
| 177 | + |
| 178 | +if __name__ == "__main__": |
| 179 | + main() |
0 commit comments