abtonmoy commited on
Commit
f18df08
·
verified ·
1 Parent(s): 811ad73

Add Sentence Transformers integration (custom module)

Browse files
config_sentence_transformers.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "SentenceTransformer",
3
+ "__version__": {
4
+ "sentence_transformers": "5.7.0",
5
+ "transformers": "5.14.1",
6
+ "pytorch": "2.13.0"
7
+ },
8
+ "prompts": {
9
+ "query": "Retrieve images or text relevant to the user's query.",
10
+ "document": "Represent the user's input."
11
+ },
12
+ "default_prompt_name": "query",
13
+ "similarity_fn_name": "cosine"
14
+ }
custom_st.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sentence Transformers module for fusion-embedding-2.
2
+
3
+ A thin adapter that exposes the released fusion-embedding model through the
4
+ Sentence Transformers multimodal ``encode`` API (text, image, audio, video).
5
+ All embedding math runs through the ``fusion_embedding`` package's own
6
+ ``UnifiedEmbedder`` (the model's native loading path), so vectors produced here
7
+ are identical to ``fusion_embedding.UnifiedEmbedder.from_pretrained(...)``:
8
+
9
+ * text: chat-template instruction, EOS pooling, text-side whitening (fp32);
10
+ * image: the frozen base's native vision path (no whitening, no adapters);
11
+ * video: the released video preprocessing over the frozen base's video path;
12
+ * audio: soxr resampling to 16 kHz, Whisper-style mel, trained resampler, and
13
+ the frozen decoder with ONLY the audio adapter gate open.
14
+
15
+ Every vector is L2-normalized at the full interop dimension (2048). Shorter
16
+ Matryoshka rungs: pass ``truncate_dim=<rung>`` together with
17
+ ``normalize_embeddings=True`` to ``encode`` (truncate-then-renormalize equals
18
+ the native MRL readout).
19
+
20
+ Requirements (beyond sentence-transformers>=5.5.1):
21
+
22
+ pip install "fusion-embedding[sense]>=0.3.0" torchvision
23
+
24
+ The ``sense`` extra pulls the audio decode/resample stack (soundfile, librosa);
25
+ transformers itself ships with sentence-transformers. torchvision is required to
26
+ load the model at all, not only for video, because the base processor builds a
27
+ video processor during construction; this applies to the native loader too.
28
+ Embedding an image needs Pillow, which torchvision carries. Embedding a video by
29
+ file path additionally requires torchcodec, which in turn needs FFmpeg.
30
+
31
+ Supported inputs per item (one modality per item; the model has no fused
32
+ multi-modality input):
33
+
34
+ * ``str`` — text, or a local image/audio/video file path (auto-detected);
35
+ * ``PIL.Image.Image`` or an HxWxC uint8 array — image;
36
+ * ``{"audio": {"array": waveform, "sampling_rate": sr}}`` (or the inner dict
37
+ directly) — audio; a bare 1-D array is rejected because the sampling rate
38
+ would be unknown;
39
+ * a ``[T, C, H, W]`` uint8 frame tensor/array — video.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ from typing import Any, Optional
46
+
47
+ import torch
48
+
49
+ try:
50
+ from sentence_transformers.base.modality import infer_modality
51
+ from sentence_transformers.base.modules.input_module import InputModule
52
+ except ImportError as exc: # pragma: no cover - version guard
53
+ raise ImportError(
54
+ "The fusion-embedding-2 Sentence Transformers integration requires "
55
+ "sentence-transformers>=5.5.1 (multimodal encode). "
56
+ "Upgrade with: pip install -U sentence-transformers"
57
+ ) from exc
58
+
59
+ try:
60
+ from fusion_embedding.config import INSTRUCTION_REGISTRY
61
+ from fusion_embedding.model import last_token_pool
62
+ from fusion_embedding.unified import UnifiedEmbedder, _chat
63
+ except ImportError as exc: # pragma: no cover - dependency guard
64
+ raise ImportError(
65
+ "The fusion-embedding-2 Sentence Transformers integration needs the "
66
+ "fusion-embedding package for the model implementation. Install it "
67
+ "with: pip install 'fusion-embedding[sense]>=0.3.0'"
68
+ ) from exc
69
+
70
+ _MIN_ST_VERSION = (5, 5, 1)
71
+
72
+
73
+ def _require_min_st_version() -> None:
74
+ """Fail with the real reason on Sentence Transformers older than 5.5.1.
75
+
76
+ Single-key modality dicts such as {"audio": {"array": ..., "sampling_rate": ...}}
77
+ are classified as a tuple by infer_modality before 5.5.1, so the encode call is
78
+ rejected by Sentence Transformers itself with a message claiming the modality is
79
+ unsupported. The import guard above cannot catch that: sentence_transformers.base
80
+ imports cleanly on 5.4.x.
81
+ """
82
+ import sentence_transformers
83
+
84
+ raw = getattr(sentence_transformers, "__version__", "0")
85
+ parts = []
86
+ for chunk in raw.split(".")[:3]:
87
+ digits = "".join(c for c in chunk if c.isdigit())
88
+ parts.append(int(digits) if digits else 0)
89
+ while len(parts) < 3:
90
+ parts.append(0)
91
+ if tuple(parts) < _MIN_ST_VERSION:
92
+ raise ImportError(
93
+ "The fusion-embedding-2 Sentence Transformers integration requires "
94
+ f"sentence-transformers>=5.5.1, found {raw}. Earlier versions reject "
95
+ "single-key modality dicts such as "
96
+ '{"audio": {"array": ..., "sampling_rate": ...}} before this module is '
97
+ "reached. Upgrade with: pip install -U 'sentence-transformers>=5.5.1'"
98
+ )
99
+
100
+
101
+ CKPT_FILENAME = "fusion-embedding-2-2b-preview.pt"
102
+
103
+
104
+ class FusionEmbedding2Module(InputModule):
105
+ """Single Sentence Transformers module wrapping the full fusion-embedding-2
106
+ encoder (all modalities plus the canonical readout, so no separate Pooling
107
+ or Normalize module is needed: ``forward`` emits ``sentence_embedding``
108
+ directly)."""
109
+
110
+ config_file_name = "sentence_bert_config.json"
111
+ config_keys = ["ckpt_filename", "max_seq_length"]
112
+ save_in_root = True
113
+
114
+ def __init__(
115
+ self,
116
+ model_name_or_path: Optional[str] = None,
117
+ ckpt_filename: str = CKPT_FILENAME,
118
+ max_seq_length: int = 512,
119
+ revision: Optional[str] = None,
120
+ token: "bool | str | None" = None,
121
+ cache_folder: Optional[str] = None,
122
+ local_files_only: bool = False,
123
+ model_kwargs: Optional[dict] = None,
124
+ embedder: Optional[UnifiedEmbedder] = None,
125
+ **kwargs,
126
+ ) -> None:
127
+ super().__init__()
128
+ _require_min_st_version()
129
+ self.ckpt_filename = ckpt_filename
130
+ self.max_seq_length = max_seq_length
131
+
132
+ if embedder is None:
133
+ if model_name_or_path is None:
134
+ raise ValueError("model_name_or_path is required (or pass embedder=)")
135
+ model_kwargs = dict(model_kwargs or {})
136
+ dtype = model_kwargs.pop("torch_dtype", model_kwargs.pop("dtype", torch.bfloat16))
137
+ if isinstance(dtype, str):
138
+ dtype = getattr(torch, dtype)
139
+ device = model_kwargs.pop(
140
+ "device", "cuda" if torch.cuda.is_available() else "cpu"
141
+ )
142
+ ckpt_path = self.load_file_path(
143
+ model_name_or_path,
144
+ filename=ckpt_filename,
145
+ token=token,
146
+ cache_folder=cache_folder,
147
+ revision=revision,
148
+ local_files_only=local_files_only,
149
+ )
150
+ if ckpt_path is None:
151
+ raise FileNotFoundError(
152
+ f"checkpoint {ckpt_filename!r} not found in {model_name_or_path!r}"
153
+ )
154
+ embedder = UnifiedEmbedder.from_pretrained(ckpt_path, device=device, dtype=dtype)
155
+
156
+ self._emb = embedder
157
+ # Wire the video seam the UnifiedEmbedder anticipates: the released video
158
+ # preprocessing (fusion_embedding.multimodal) over the frozen base.
159
+ self._emb._video_pooler = self._video_pooled
160
+
161
+ # Register the underlying torch modules so Sentence Transformers device
162
+ # management (`model.to(device)`) moves the whole stack.
163
+ self.fusion_model = embedder.model
164
+ if embedder.full is not None:
165
+ self.base = embedder.full
166
+ if embedder.tok is not None:
167
+ self.tokenizer = embedder.tok
168
+
169
+ # ------------------------------------------------------------------ loading
170
+ @classmethod
171
+ def load(
172
+ cls,
173
+ model_name_or_path: str,
174
+ subfolder: str = "",
175
+ token: "bool | str | None" = None,
176
+ cache_folder: Optional[str] = None,
177
+ revision: Optional[str] = None,
178
+ local_files_only: bool = False,
179
+ trust_remote_code: bool = False,
180
+ model_kwargs: Optional[dict] = None,
181
+ processor_kwargs: Optional[dict] = None,
182
+ config_kwargs: Optional[dict] = None,
183
+ backend: str = "torch",
184
+ **kwargs,
185
+ ) -> "FusionEmbedding2Module":
186
+ if backend != "torch":
187
+ raise ValueError(
188
+ f"fusion-embedding-2 only supports the torch backend, got {backend!r}"
189
+ )
190
+ config = cls.load_config(
191
+ model_name_or_path,
192
+ subfolder=subfolder,
193
+ token=token,
194
+ cache_folder=cache_folder,
195
+ revision=revision,
196
+ local_files_only=local_files_only,
197
+ )
198
+ config.pop("model_name_or_path", None)
199
+ if config_kwargs:
200
+ config.update(config_kwargs)
201
+ return cls(
202
+ model_name_or_path,
203
+ revision=revision,
204
+ token=token,
205
+ cache_folder=cache_folder,
206
+ local_files_only=local_files_only,
207
+ model_kwargs=model_kwargs,
208
+ **config,
209
+ )
210
+
211
+ # -------------------------------------------------------------- ST contract
212
+ @property
213
+ def modalities(self) -> list:
214
+ return ["text", "image", "audio", "video"]
215
+
216
+ def get_embedding_dimension(self) -> int:
217
+ return int(self._emb.contract.dim)
218
+
219
+ def save(self, output_path: str, *args, safe_serialization: bool = True, **kwargs) -> None:
220
+ # Configuration only: the 2B weights live in the model repository's
221
+ # checkpoint file and are not duplicated by Sentence Transformers saves.
222
+ self.save_config(output_path)
223
+
224
+ # ------------------------------------------------------------- input parsing
225
+ def preprocess(self, inputs: list, prompt: Optional[str] = None, **kwargs) -> dict:
226
+ items = []
227
+ for item in inputs:
228
+ modality = infer_modality(item, supported_modalities=self.modalities)
229
+ if isinstance(modality, tuple):
230
+ raise ValueError(
231
+ "fusion-embedding-2 embeds one modality per input item; "
232
+ f"got a combined input with {modality}. Encode each modality "
233
+ "separately (the shared space makes the vectors comparable)."
234
+ )
235
+ if isinstance(item, dict) and set(item.keys()) == {modality}:
236
+ item = item[modality]
237
+ items.append((modality, self._parse(modality, item)))
238
+ return {"fusion_inputs": items, "fusion_prompt": prompt}
239
+
240
+ def _parse(self, modality: str, item: Any) -> Any:
241
+ if modality == "text":
242
+ return item
243
+ if modality == "image":
244
+ return self._parse_image(item)
245
+ if modality == "audio":
246
+ return self._parse_audio(item)
247
+ if modality == "video":
248
+ return self._parse_video(item)
249
+ raise ValueError(f"unsupported modality {modality!r}")
250
+
251
+ @staticmethod
252
+ def _parse_image(item):
253
+ import numpy as np
254
+
255
+ # Guarded so transformers' trust_remote_code import check does not make
256
+ # Pillow a load-time requirement: dynamic_module_utils.get_imports skips
257
+ # ast.Try blocks, and an unguarded import here is otherwise treated as
258
+ # mandatory at construction even for text-only use.
259
+ try:
260
+ from PIL import Image
261
+ except ImportError as exc: # pragma: no cover - optional dependency
262
+ raise ImportError(
263
+ "embedding an image requires Pillow (pip install pillow)"
264
+ ) from exc
265
+
266
+ if isinstance(item, Image.Image):
267
+ return item
268
+ if isinstance(item, str):
269
+ if item.startswith(("http://", "https://", "data:")):
270
+ raise ValueError(
271
+ "image URLs / data URIs are not supported; download the file "
272
+ "and pass a local path or a PIL image"
273
+ )
274
+ return item # local path; decoded by the native path (PIL)
275
+ if isinstance(item, torch.Tensor):
276
+ item = item.cpu().numpy()
277
+ if isinstance(item, np.ndarray):
278
+ if item.ndim == 3 and item.shape[0] in (1, 3, 4) and item.shape[-1] not in (1, 3, 4):
279
+ item = np.transpose(item, (1, 2, 0)) # CHW -> HWC
280
+ if item.ndim != 3 or item.shape[-1] not in (1, 3, 4):
281
+ raise ValueError(f"expected an HxWxC image array, got shape {item.shape}")
282
+ if item.dtype != np.uint8:
283
+ item = np.clip(item, 0, 255).astype(np.uint8)
284
+ return Image.fromarray(item.squeeze(-1) if item.shape[-1] == 1 else item)
285
+ raise ValueError(f"unsupported image input type {type(item).__name__}")
286
+
287
+ @staticmethod
288
+ def _parse_audio(item):
289
+ """Return (payload, sampling_rate_or_None); paths carry their own rate."""
290
+ import numpy as np
291
+
292
+ if isinstance(item, str):
293
+ if item.startswith(("http://", "https://")):
294
+ raise ValueError(
295
+ "audio URLs are not supported; download the file and pass a "
296
+ "local path or {'array': ..., 'sampling_rate': ...}"
297
+ )
298
+ return (item, None)
299
+ if isinstance(item, dict):
300
+ if "array" not in item or "sampling_rate" not in item:
301
+ raise ValueError(
302
+ "audio dicts must have the form "
303
+ "{'array': waveform, 'sampling_rate': sr}"
304
+ )
305
+ array, sr = item["array"], int(item["sampling_rate"])
306
+ else:
307
+ try: # torchcodec AudioDecoder (optional dependency)
308
+ from torchcodec.decoders import AudioDecoder
309
+ except ImportError:
310
+ AudioDecoder = None
311
+ if AudioDecoder is not None and isinstance(item, AudioDecoder):
312
+ samples = item.get_all_samples()
313
+ return (samples.data.mean(dim=0).cpu().numpy(), int(samples.sample_rate))
314
+ raise ValueError(
315
+ "a bare audio array has no sampling rate; pass "
316
+ "{'audio': {'array': waveform, 'sampling_rate': sr}} instead"
317
+ )
318
+ if isinstance(array, torch.Tensor):
319
+ array = array.cpu().numpy()
320
+ array = np.asarray(array)
321
+ if array.ndim == 2 and array.shape[0] < array.shape[1]:
322
+ array = array.T # (channels, samples) -> (samples, channels)
323
+ if array.ndim > 2:
324
+ raise ValueError(f"expected a 1-D or 2-D waveform, got shape {array.shape}")
325
+ return (array.astype(np.float32, copy=False), sr)
326
+
327
+ @staticmethod
328
+ def _parse_video(item):
329
+ import numpy as np
330
+
331
+ if isinstance(item, str):
332
+ if item.startswith(("http://", "https://")):
333
+ raise ValueError(
334
+ "video URLs are not supported; download the file and pass a "
335
+ "local path or a [T, C, H, W] frame tensor"
336
+ )
337
+ return item # local path; decoded natively (torchcodec, 1 fps, <=64 frames)
338
+ if isinstance(item, dict):
339
+ # {"array": frames, "video_metadata": ...}: the released frame-tensor
340
+ # path derives its own metadata, so user metadata is not consumed.
341
+ item = item["array"]
342
+ if isinstance(item, np.ndarray):
343
+ item = torch.from_numpy(np.ascontiguousarray(item))
344
+ if isinstance(item, torch.Tensor):
345
+ if item.ndim == 5 and item.shape[0] == 1:
346
+ item = item.squeeze(0)
347
+ if item.ndim == 4 and item.shape[-1] in (1, 3) and item.shape[1] not in (1, 3):
348
+ item = item.permute(0, 3, 1, 2) # THWC -> TCHW
349
+ if item.ndim != 4:
350
+ raise ValueError(f"expected a [T, C, H, W] frame tensor, got shape {list(item.shape)}")
351
+ return item
352
+ raise ValueError(f"unsupported video input type {type(item).__name__}")
353
+
354
+ # ------------------------------------------------------------------ forward
355
+ def forward(self, features: dict, **kwargs) -> dict:
356
+ self._sync_device()
357
+ prompt = features.get("fusion_prompt")
358
+ vectors = []
359
+ for modality, payload in features["fusion_inputs"]:
360
+ if modality == "text":
361
+ vectors.append(self._emb.embed_text(payload, instruction=prompt or None))
362
+ elif modality == "image":
363
+ vectors.append(self._emb.embed_image(payload))
364
+ elif modality == "audio":
365
+ array, sr = payload
366
+ vectors.append(self._emb.embed_audio(array, sr=sr))
367
+ elif modality == "video":
368
+ vectors.append(self._emb.embed_video(payload))
369
+ else: # pragma: no cover - guarded in preprocess
370
+ raise ValueError(f"unsupported modality {modality!r}")
371
+ features["sentence_embedding"] = torch.stack(vectors)
372
+ return features
373
+
374
+ def _sync_device(self) -> None:
375
+ """Follow Sentence Transformers device moves (`model.to(...)`)."""
376
+ param = next(self.parameters(), None)
377
+ if param is not None:
378
+ self._emb.device = param.device
379
+
380
+ # ----------------------------------------------------------- native video path
381
+ @torch.no_grad()
382
+ def _video_pooled(self, video, fps, max_frames) -> torch.Tensor:
383
+ """The released fusion-embedding video path: reference-exact frame
384
+ preprocessing (fusion_embedding.multimodal) -> frozen base's video
385
+ forward -> EOS pooling. Runs with every adapter gate closed."""
386
+ from fusion_embedding.config import VIDEO_USER_CONTENT
387
+ from fusion_embedding.multimodal import _v_prepare, _v_resize_video
388
+
389
+ emb = self._emb
390
+ gate = getattr(emb.model, "_adapter_gate", None)
391
+ if gate is not None and gate.active:
392
+ raise RuntimeError("adapter gate is open during a video embed")
393
+ if emb.full is None or emb.proc is None:
394
+ raise RuntimeError("video embedding needs the real processor + base")
395
+ frames, metadata = _v_prepare(video, fps, max_frames)
396
+ frames = _v_resize_video(frames)
397
+ text = _chat(INSTRUCTION_REGISTRY["doc"], VIDEO_USER_CONTENT)
398
+ inputs = emb.proc(
399
+ text=[text],
400
+ videos=[frames],
401
+ video_metadata=[metadata],
402
+ do_resize=False,
403
+ do_sample_frames=False,
404
+ return_tensors="pt",
405
+ ).to(emb.device)
406
+ hidden = emb.full(**inputs).last_hidden_state
407
+ return last_token_pool(hidden, inputs["attention_mask"])
modules.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "idx": 0,
4
+ "name": "0",
5
+ "path": "",
6
+ "type": "custom_st.FusionEmbedding2Module"
7
+ }
8
+ ]
sentence_bert_config.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "ckpt_filename": "fusion-embedding-2-2b-preview.pt",
3
+ "max_seq_length": 512
4
+ }