ArkidMitra commited on
Commit
fbc2be0
·
verified ·
1 Parent(s): c63f26f

Card: MODA Pro Lite+ — results, recipe, serving_ann.py; process detail removed

Browse files
Files changed (2) hide show
  1. README.md +88 -81
  2. serving_ann.py +173 -0
README.md CHANGED
@@ -1,110 +1,117 @@
1
  ---
2
  license: apache-2.0
3
- library_name: open_clip
4
- pipeline_tag: zero-shot-image-classification
5
  tags:
6
- - fashion
7
- - retrieval
8
- - text-to-image
9
- - siglip2
10
- - e-commerce
11
- - moda
12
- datasets:
13
- - Marqo/marqo-GS-10M
14
- base_model: timm/ViT-B-16-SigLIP2-384
15
- model-index:
16
- - name: MODA Pro Lite
17
- results:
18
- - task: {type: text-to-image-retrieval, name: Text-to-Image Retrieval}
19
- dataset: {type: kaggle-fashion, name: KAGL (full corpus, 44K)}
20
- metrics:
21
- - {type: map@10, value: 0.31851, name: MAP@10}
22
- - task: {type: text-to-image-retrieval, name: Text-to-Image Retrieval}
23
- dataset: {type: polyvore, name: Polyvore (full corpus, 94K)}
24
- metrics:
25
- - {type: map@10, value: 0.39970, name: MAP@10}
26
- - task: {type: text-to-image-retrieval, name: Text-to-Image Retrieval}
27
- dataset: {type: atlas, name: Atlas (full corpus, 78K)}
28
- metrics:
29
- - {type: map@10, value: 0.19451, name: MAP@10}
30
- - task: {type: text-to-image-retrieval, name: Text-to-Image Retrieval}
31
- dataset: {type: fashion200k, name: Fashion200K (full corpus, 202K)}
32
- metrics:
33
- - {type: map@10, value: 0.18023, name: MAP@10}
34
  ---
35
 
36
- # MODA Pro Lite
 
 
37
 
38
- **A 213M open-weights fashion encoder that beats [MODA](https://huggingface.co/HopitAI/moda-fashionsiglip-multiview-203m) — the strongest open fashion retrieval system — on catalog search, at full corpus and with statistical significance, from one checkpoint with no serving machinery.**
 
 
39
 
40
- By [Hopit AI](https://hopit.ai). Weights are open (Apache-2.0); the training and data pipeline are proprietary.
 
 
41
 
42
- | vs MODA, full corpus | MODA Pro Lite | gain |
43
- |---|--:|--:|
44
- | KAGL (44K products) | **0.3185** vs 0.2890 | **+10.2%** |
45
- | Polyvore (94K) | **0.3997** vs 0.3726 | **+7.3%** |
46
- | ZooClaw-Fashion short | **0.4994** vs 0.4775 | **+4.6%** |
47
 
48
- The KAGL and Polyvore gains are significant under a paired bootstrap (10,000 resamples, 95% CI excludes zero); on Atlas the difference is positive but inconclusive, and on Fashion200K and In-Shop MODA is significantly better — see the full table. Every number in this card is a **full-corpus** measurement (no subsampled galleries) through one shared harness, alongside FashionSigLIP, SigLIP-SO400M, and ZooClaw under identical protocol. Full six-system table: [benchmark page](https://hopit-ai.github.io/Moda/).
 
49
 
50
- ## The MODA family
 
 
 
 
 
 
 
51
 
52
- | model | what it is | availability |
53
- |---|---|---|
54
- | [**MODA**](https://huggingface.co/HopitAI/moda-fashionsiglip-multiview-203m) | frozen FashionSigLIP + zero-parameter multi-view serving recipe | open source + open weights |
55
- | **MODA Pro Lite** (this model) | trained 213M encoder — the drop-in single-checkpoint option | **open weights** |
56
- | **MODA Pro** | our hosted retrieval system. Rank 1 or 2 on **9 of 10** benchmark cells; +6.9% mean over MODA on the six academic sets | closed · hosted by Hopit AI |
57
 
58
- **Which to pick:** already running MODA's recipe → keep MODA (it still leads on caption-style benchmarks). Want one checkpoint, standard CLIP usage, strongest catalog/title search → **Pro Lite**. Want the strongest overall → [MODA Pro](https://hopit.ai) (hosted).
 
59
 
60
- ## Usage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  ```python
63
- import torch, open_clip
64
- from PIL import Image
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  model, _, preprocess = open_clip.create_model_and_transforms("hf-hub:HopitAI/moda-pro-lite")
67
  tokenizer = open_clip.get_tokenizer("hf-hub:HopitAI/moda-pro-lite")
68
  model.eval()
69
 
70
- image = preprocess(Image.open("product.jpg")).unsqueeze(0)
71
- text = tokenizer(["red floral maxi dress", "slim-fit denim jacket"])
72
-
73
- with torch.no_grad(), torch.autocast("cuda"):
74
- img_emb = model.encode_image(image)
75
- txt_emb = model.encode_text(text)
76
- img_emb /= img_emb.norm(dim=-1, keepdim=True)
77
- txt_emb /= txt_emb.norm(dim=-1, keepdim=True)
78
- scores = txt_emb @ img_emb.T
79
  ```
80
 
81
- Plain bi-encoder: one 768-d vector per image, one per query, cosine similarity. Works with any vector database; no reranker, no prompt template required.
82
-
83
- ## What's inside (summary)
84
 
85
- - **Base:** SigLIP2-base-384 (`timm/ViT-B-16-SigLIP2-384`), full fine-tune of both towers.
86
- - **Size — 213,159,938 params:** the base's 256K-token multilingual vocabulary is trimmed to the 44,646 pieces fashion text actually uses (BPE merge-closure preserved). Verified: **100.00% bit-identical** outputs on 12,000 benchmark queries and 6,000 held-out fashion texts vs the untrimmed 375M model — every benchmark number in this card transfers exactly. Out-of-vocabulary input (emoji, non-Latin scripts, rare typos) re-segments gracefully into kept pieces (cosine 0.82–0.97 to full model), never a silent failure.
87
- - **Data:** 93K commercial product images (from [Marqo-GS-10M](https://huggingface.co/datasets/Marqo/marqo-GS-10M), Apache-2.0), re-annotated by a frontier VLM with graded relevance labels (99.9% coverage).
88
- - **Training:** a proprietary fine-tuning recipe; the pipeline and annotated dataset are not released.
89
- - **Distinctive property:** unlike typical fashion fine-tunes, Pro Lite does **not** collapse out of domain — it adds fashion skill while keeping generalist behavior (image-tower drift from base: 0.36%).
90
 
91
- ## Evaluation protocol
 
 
 
92
 
93
- Measured on six academic fashion benchmarks (DeepFashion In-Shop, DeepFashion Multimodal, Fashion200K, Polyvore, Atlas, KAGL) at **full corpus** — no gallery subsampling — plus H&M (10K, MRR) and ZooClaw-Fashion (12K, MAP@10), all through one open harness with identical preprocessing per model. Where Pro Lite loses (instance retrieval, caption-style sets), the card for [MODA](https://huggingface.co/HopitAI/moda-fashionsiglip-multiview-203m) shows the stronger pick — see the [full comparison](https://hopit-ai.github.io/Moda/) rather than a cherry-picked subset.
94
 
95
- ## Using this in production
96
 
97
- MODA Pro Lite is free to use under Apache-2.0. If you want the hosted MODA Pro,
98
- help evaluating on your own catalog, or a pilot, [book a call](https://calendly.com/arkid_/new-meeting?back=1) — we bring
99
- specific examples from your category and a proposed pilot shape.
100
 
101
- ## Citation
102
 
103
- ```bibtex
104
- @misc{modaprolite2026,
105
- title = {MODA Pro Lite: a fashion encoder that does not collapse out of domain},
106
- author = {Hopit AI},
107
- year = {2026},
108
- url = {https://huggingface.co/HopitAI/moda-pro-lite}
109
- }
110
- ```
 
1
  ---
2
  license: apache-2.0
 
 
3
  tags:
4
+ - fashion
5
+ - retrieval
6
+ - text-to-image
7
+ - open_clip
8
+ - siglip2
9
+ pipeline_tag: feature-extraction
10
+ library_name: open_clip
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
+ # MODA Pro Lite+
14
+
15
+ **The strongest open system at ≤250M parameters on catalogue and title search.**
16
 
17
+ MODA Pro Lite+ is a 213M fashion retrieval encoder (SigLIP2-base-384 backbone,
18
+ fashion-vocabulary build) served with a calibrated multi-view recipe. Both ship here:
19
+ the weights, and `serving_ann.py` which implements the recipe.
20
 
21
+ The encoder on its own is **MODA Pro Lite**; with the recipe it is **MODA Pro Lite+**.
22
+ Same weights, same repository, one extra step at index time and none at query time.
23
+ Numbers for both are below.
24
 
25
+ ## Results
 
 
 
 
26
 
27
+ MAP@10, full corpus, all ground-truth queries, one evaluator (`pytrec_eval map_cut.10`).
28
+ `MODA` is FashionSigLIP with its own serving recipe, shown for reference.
29
 
30
+ | benchmark | MODA | Pro Lite (bare) | **Pro Lite+** (with recipe) |
31
+ |---|---:|---:|---:|
32
+ | KAGL | 0.2887 | 0.3055 | **0.3201** |
33
+ | Polyvore | 0.3726 | 0.3952 | **0.4049** |
34
+ | Atlas | 0.1862 | 0.1814 | **0.1904** |
35
+ | Fashion200K | **0.1946** | 0.1758 | 0.1846 |
36
+ | DeepFashion In-Shop | **0.1642** | 0.0930 | 0.1026 |
37
+ | DeepFashion Multimodal | **0.0147** | 0.0118 | 0.0133 |
38
 
39
+ **Pro Lite+ leads the ≤250M class on KAGL, Polyvore and Atlas** — +10.9% over MODA on KAGL,
40
+ +8.7% on Polyvore, both significant under a paired bootstrap (10,000 resamples).
 
 
 
41
 
42
+ The recipe is worth +2.5% to +12.8% over the bare encoder on every benchmark, and costs
43
+ nothing at query time: the views are fused into a single vector before indexing.
44
 
45
+ **Where this model is weak, stated plainly.** Pro Lite is tuned for short catalogue titles.
46
+ On long natural-language descriptions it trails FashionSigLIP substantially — DeepFashion
47
+ In-Shop queries average 75 words, and Pro Lite+ scores 0.1026 there against MODA's 0.1642.
48
+ If your queries are descriptions rather than titles, use
49
+ [MODA Duo](https://huggingface.co/HopitAI/moda-duo), which routes per query.
50
+
51
+ ## Serving cost
52
+
53
+ ```
54
+ stored vectors per item : 1
55
+ ANN queries per search : 1
56
+ image forwards at index : 3x offline, paid once
57
+ text forwards per query : 2x negligible beside the ANN probe
58
+ ```
59
+
60
+ The recipe is a rule for *what you encode*, not a model change. Views are combined into one
61
+ unit vector before indexing, so nearest-neighbour search costs exactly what the bare encoder
62
+ costs — same index, same probe, no extra routes and no re-ranking.
63
+
64
+ ## Use
65
+
66
+ ```bash
67
+ pip install open_clip_torch pillow numpy hnswlib
68
+ python serving_ann.py --demo
69
+ ```
70
 
71
  ```python
72
+ from serving_ann import load, encode_images, encode_queries, build_index, search
73
+
74
+ enc = load() # open_clip, this repo's weights
75
+ docs = encode_images(catalogue, enc) # (n, 768) float32, one vector per item
76
+ index = build_index(docs) # hnswlib, cosine via inner product
77
+ qry = encode_queries(["black leather ankle boots"], enc)
78
+ ids, scores = search(index, qry, k=10)
79
+ ```
80
+
81
+ Bare encoder, if you would rather not use the recipe:
82
+
83
+ ```python
84
+ import open_clip, torch
85
 
86
  model, _, preprocess = open_clip.create_model_and_transforms("hf-hub:HopitAI/moda-pro-lite")
87
  tokenizer = open_clip.get_tokenizer("hf-hub:HopitAI/moda-pro-lite")
88
  model.eval()
89
 
90
+ with torch.no_grad():
91
+ image = torch.nn.functional.normalize(model.encode_image(preprocess(img).unsqueeze(0)), dim=-1)
92
+ text = torch.nn.functional.normalize(model.encode_text(tokenizer(["black leather ankle boots"])), dim=-1)
93
+ score = (text @ image.T).item()
 
 
 
 
 
94
  ```
95
 
96
+ 768-d embeddings, cosine similarity, one vector per item. Index them in any vector database.
 
 
97
 
98
+ ## The recipe
 
 
 
 
99
 
100
+ ```
101
+ document = normalize(official + 0.25 * square_pad + 0.25 * foreground_pad)
102
+ query = normalize(raw + 0.25 * "a photo of {query}")
103
+ ```
104
 
105
+ `serving_ann.py` implements it. Zero added parameters, one stored vector.
106
 
107
+ ## Evaluation
108
 
109
+ All figures are full corpus, all ground-truth queries, MAP@10 under one evaluator
110
+ (`pytrec_eval map_cut.10`), float32. Per-query results and confidence intervals are in the
111
+ [repository](https://github.com/hopit-ai/Moda).
112
 
113
+ ## Related
114
 
115
+ - [MODA Duo](https://huggingface.co/HopitAI/moda-duo) — routes each query to Pro Lite+ or MODA by its shape; beats both on a mixed workload.
116
+ - [MODA](https://huggingface.co/HopitAI/moda-fashionsiglip-multiview-203m) — FashionSigLIP with a serving recipe. Stronger on long descriptions.
117
+ - [MODA-SigLIP-Distilled](https://huggingface.co/HopitAI/moda-fashion-distilled) — image-to-image retrieval.
 
 
 
 
 
serving_ann.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MODA Pro Lite+ (moda-pro-lite with its calibrated serving harness) -- retrieval with ANN, end to end.
2
+
3
+ The harness below was selected on held-out development data only (OpenVTON + GLAMI); no target benchmark was touched during selection.
4
+ It adds ZERO parameters and stores ONE vector per item.
5
+
6
+ Serving cost
7
+ ------------
8
+ stored vectors per item : 1
9
+ ANN queries per search : 1
10
+ image forwards at index : 3x (paid once, offline)
11
+ text forwards per query : 2x (cheap next to the ANN probe)
12
+
13
+ The harness is a recipe for WHAT YOU ENCODE, not a model change: the views are
14
+ combined into a single unit vector before indexing, so nearest-neighbour search
15
+ costs exactly what it costs for the bare model. No extra routes, no re-ranking.
16
+
17
+ pip install open_clip_torch pillow numpy hnswlib
18
+
19
+ Example
20
+ -------
21
+ python serving_ann.py --demo
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import math
27
+ from typing import Sequence
28
+
29
+ import numpy as np
30
+ import torch
31
+ import torch.nn.functional as F
32
+ from PIL import Image
33
+
34
+ MODEL = "hf-hub:HopitAI/moda-pro-lite"
35
+ IMAGE_MIX = {'official': 1.0, 'pad': 0.25, 'foreground_pad': 0.25} # view -> weight, combined then L2-normalised
36
+ PROMPT_MIX = {'raw': 1.0, 'photo': 0.25} # prompt -> weight, combined then L2-normalised
37
+ PROMPT_TEMPLATES = {
38
+ "raw": "{query}",
39
+ "photo": "a photo of {query}",
40
+ "product": "a fashion product photo of {query}",
41
+ }
42
+
43
+
44
+ # ---------------------------------------------------------------- views ----
45
+ def square_pad(image: Image.Image, fill: int = 128) -> Image.Image:
46
+ image = image.convert("RGB")
47
+ side = max(image.size)
48
+ canvas = Image.new("RGB", (side, side), (fill, fill, fill))
49
+ canvas.paste(image, ((side - image.width) // 2, (side - image.height) // 2))
50
+ return canvas
51
+
52
+
53
+ def center_square(image: Image.Image) -> Image.Image:
54
+ side = min(image.size)
55
+ left, top = (image.width - side) // 2, (image.height - side) // 2
56
+ return image.convert("RGB").crop((left, top, left + side, top + side))
57
+
58
+
59
+ def foreground_square(image: Image.Image) -> Image.Image:
60
+ """Crop a near-white catalog border, then pad without distorting aspect."""
61
+ image = image.convert("RGB")
62
+ preview = image.copy()
63
+ preview.thumbnail((256, 256), Image.Resampling.BILINEAR)
64
+ mask = np.any(np.asarray(preview, dtype=np.uint8) < 242, axis=-1)
65
+ if float(mask.mean()) < 0.01:
66
+ return square_pad(image)
67
+ ys, xs = np.nonzero(mask)
68
+ sx, sy = image.width / preview.width, image.height / preview.height
69
+ left = max(0, math.floor(float(xs.min()) * sx))
70
+ right = min(image.width, math.ceil(float(xs.max() + 1) * sx))
71
+ top = max(0, math.floor(float(ys.min()) * sy))
72
+ bottom = min(image.height, math.ceil(float(ys.max() + 1) * sy))
73
+ mx, my = max(1, round((right - left) * 0.05)), max(1, round((bottom - top) * 0.05))
74
+ return square_pad(image.crop((max(0, left - mx), max(0, top - my),
75
+ min(image.width, right + mx), min(image.height, bottom + my))))
76
+
77
+
78
+ VIEWS = {
79
+ "official": lambda im: im.convert("RGB"),
80
+ "pad": square_pad,
81
+ "pad_white": lambda im: square_pad(im, fill=255),
82
+ "center_crop": center_square,
83
+ "foreground_pad": foreground_square,
84
+ }
85
+
86
+
87
+ # -------------------------------------------------------------- encoding ----
88
+ def load(device: str = "cpu"):
89
+ import open_clip
90
+ model, _, preprocess = open_clip.create_model_and_transforms(MODEL)
91
+ model.eval().to(device)
92
+ for p in model.parameters():
93
+ p.requires_grad = False
94
+ return model, preprocess, open_clip.get_tokenizer(MODEL), device
95
+
96
+
97
+ @torch.inference_mode()
98
+ def encode_images(images: Sequence[Image.Image], enc, batch_size: int = 32) -> np.ndarray:
99
+ """-> (n, 768) float32, unit norm. ONE vector per item."""
100
+ model, preprocess, _, device = enc
101
+ parts = {v: [] for v in IMAGE_MIX}
102
+ for s in range(0, len(images), batch_size):
103
+ chunk = images[s:s + batch_size]
104
+ for view in IMAGE_MIX:
105
+ px = torch.stack([preprocess(VIEWS[view](im)) for im in chunk]).to(device)
106
+ parts[view].append(F.normalize(model.encode_image(px).float(), dim=-1).cpu())
107
+ fused = sum(w * torch.cat(parts[v]) for v, w in IMAGE_MIX.items())
108
+ return F.normalize(fused, dim=-1).numpy().astype("float32")
109
+
110
+
111
+ @torch.inference_mode()
112
+ def encode_queries(texts: Sequence[str], enc, batch_size: int = 128) -> np.ndarray:
113
+ """-> (n, 768) float32, unit norm. ONE vector per query."""
114
+ model, _, tokenizer, device = enc
115
+ parts = {p: [] for p in PROMPT_MIX}
116
+ for s in range(0, len(texts), batch_size):
117
+ chunk = texts[s:s + batch_size]
118
+ for prompt in PROMPT_MIX:
119
+ rendered = [PROMPT_TEMPLATES[prompt].format(query=t) for t in chunk]
120
+ tok = tokenizer(rendered).to(device)
121
+ parts[prompt].append(F.normalize(model.encode_text(tok).float(), dim=-1).cpu())
122
+ fused = sum(w * torch.cat(parts[p]) for p, w in PROMPT_MIX.items())
123
+ return F.normalize(fused, dim=-1).numpy().astype("float32")
124
+
125
+
126
+ # ------------------------------------------------------------------ ANN ----
127
+ def build_index(vectors: np.ndarray, m: int = 32, ef_construction: int = 200):
128
+ """Cosine similarity over unit vectors is inner product, so use space='ip'."""
129
+ import hnswlib
130
+ index = hnswlib.Index(space="ip", dim=vectors.shape[1])
131
+ index.init_index(max_elements=len(vectors), ef_construction=ef_construction, M=m)
132
+ index.add_items(vectors, np.arange(len(vectors)))
133
+ return index
134
+
135
+
136
+ def search(index, queries: np.ndarray, k: int = 10, ef: int = 64):
137
+ index.set_ef(max(ef, k))
138
+ ids, distances = index.knn_query(queries, k=k)
139
+ return ids, 1.0 - distances # ip distance -> cosine similarity
140
+
141
+
142
+ def search_exact(vectors: np.ndarray, queries: np.ndarray, k: int = 10):
143
+ """Ground truth, for checking ANN recall on your own corpus."""
144
+ sims = queries @ vectors.T
145
+ ids = np.argsort(-sims, axis=1)[:, :k]
146
+ return ids, np.take_along_axis(sims, ids, axis=1)
147
+
148
+
149
+ def _demo() -> None:
150
+ enc = load()
151
+ corpus = [Image.new("RGB", (w, h), c) for w, h, c in
152
+ [(224, 300, "white"), (300, 224, "black"), (256, 256, "navy"),
153
+ (400, 200, "beige"), (200, 400, "maroon")]]
154
+ doc = encode_images(corpus, enc)
155
+ qry = encode_queries(["black leather ankle boots", "navy wool coat"], enc)
156
+ print(f"documents {doc.shape} queries {qry.shape} (one vector each)")
157
+ index = build_index(doc)
158
+ ann_ids, ann_scores = search(index, qry, k=3)
159
+ ex_ids, _ = search_exact(doc, qry, k=3)
160
+ agree = float(np.mean([len(set(a) & set(b)) / len(b) for a, b in zip(ann_ids, ex_ids)]))
161
+ print(f"ANN top-3 ids {ann_ids.tolist()}")
162
+ print(f"exact top-3 ids {ex_ids.tolist()}")
163
+ print(f"ANN/exact overlap@3: {agree:.3f} (1.000 expected on a corpus this small)")
164
+
165
+
166
+ if __name__ == "__main__":
167
+ ap = argparse.ArgumentParser(description=__doc__)
168
+ ap.add_argument("--demo", action="store_true")
169
+ args = ap.parse_args()
170
+ if args.demo:
171
+ _demo()
172
+ else:
173
+ ap.print_help()