Download modeling_sentio.py from Dragos-Datcu/Sentio: direct link, hf CLI and curl.
- Browser
- Download file 13.7 kB
-
https://huggingface.co/Dragos-Datcu/Sentio/resolve/main/modeling_sentio.py
- Command line
-
hf download hf://Dragos-Datcu/Sentio/modeling_sentio.py
-
curl -L -o modeling_sentio.py https://huggingface.co/Dragos-Datcu/Sentio/resolve/main/modeling_sentio.py
13.7 kB
| """ | |
| Sentio: WeMM-Embedding-9B (+ a LoRA for video moment retrieval) with an audio branch, all | |
| embedding into one 4096-d space. | |
| text / image / video / page -> WeMM-Embedding-9B with the vdr LoRA merged in | |
| audio -> Qwen2.5-Omni-3B audio tower + LoRA -> mean pool -> trained head | |
| (connector -> modality adapter fusion -> RMSNorm) | |
| config.json points at the two base checkpoints, the vdr LoRA adapter (PEFT directory) and | |
| trainable.pt (audio head + audio-tower LoRA). Preprocessing mirrors the MMEB-V3 evaluation that | |
| produced the reported scores. Adapted from Wianmu-5B's modeling_wianmu.py. | |
| """ | |
| import json | |
| import os | |
| from typing import List, Optional, Sequence, Union | |
| import numpy as np | |
| import soundfile as sf | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| import torchaudio | |
| from safetensors import safe_open | |
| from transformers import AutoConfig, AutoModel, AutoProcessor, WhisperFeatureExtractor | |
| from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder | |
| AudioInput = Union[str, np.ndarray, torch.Tensor] | |
| TARGET_MODALITIES = ("text", "audio", "video", "label") | |
| WEMM_IMAGE_KWARGS = {"min_pixels": 64 * 32 * 32, "max_pixels": 8192 * 32 * 32} | |
| WEMM_TEXT_MAX_LENGTH = 8192 | |
| # ============================================================================ | |
| # Trained audio head | |
| # ============================================================================ | |
| class AudioConnector(nn.Module): | |
| def __init__(self, input_dim: int, output_dim: int): | |
| super().__init__() | |
| h1, h2 = 2 * input_dim, 4 * input_dim | |
| self.mlp = nn.Sequential( | |
| nn.Linear(input_dim, h1, bias=False), nn.LayerNorm(h1), nn.GELU(), nn.Dropout(0.1), | |
| nn.Linear(h1, h2, bias=False), nn.LayerNorm(h2), nn.GELU(), nn.Dropout(0.1), | |
| nn.Linear(h2, output_dim, bias=False), | |
| ) | |
| def forward(self, x): | |
| return self.mlp(x) | |
| class LightweightAdapter(nn.Module): | |
| def __init__(self, dim: int, reduction: int = 8): | |
| super().__init__() | |
| self.down = nn.Linear(dim, dim // reduction, bias=False) | |
| self.act = nn.GELU() | |
| self.drop = nn.Dropout(0.1) | |
| self.up = nn.Linear(dim // reduction, dim, bias=False) | |
| def forward(self, x): | |
| return x + self.up(self.drop(self.act(self.down(x)))) | |
| class ModalityAdapterFusion(nn.Module): | |
| """One adapter per target modality, mixed by a softmax gate computed from the input.""" | |
| def __init__(self, dim: int): | |
| super().__init__() | |
| self.adapters = nn.ModuleDict({m: LightweightAdapter(dim) for m in TARGET_MODALITIES}) | |
| self.gate = nn.Sequential( | |
| nn.Linear(dim, dim // 4, bias=False), nn.LayerNorm(dim // 4), nn.GELU(), | |
| nn.Linear(dim // 4, len(TARGET_MODALITIES)), | |
| ) | |
| def forward(self, x): | |
| weights = F.softmax(self.gate(x), dim=-1) | |
| stacked = torch.stack([self.adapters[m](x) for m in TARGET_MODALITIES], dim=1) | |
| return (weights.unsqueeze(-1) * stacked).sum(dim=1) | |
| class ProjectionHead(nn.Module): | |
| def __init__(self, dim: int): | |
| super().__init__() | |
| self.norm = nn.RMSNorm(dim, eps=1e-6) | |
| def forward(self, x): | |
| return self.norm(x) | |
| class SentioAudioHead(nn.Module): | |
| """Pooled audio-tower output (B, audio_dim) -> L2-normalized (B, dim).""" | |
| def __init__(self, audio_dim: int, dim: int): | |
| super().__init__() | |
| self.audio_connector = AudioConnector(audio_dim, dim) | |
| self.adapter_fusion = ModalityAdapterFusion(dim) | |
| self.projection = ProjectionHead(dim) | |
| def forward(self, x): | |
| x = self.projection(self.adapter_fusion(self.audio_connector(x))) | |
| return F.normalize(x, p=2, dim=-1, eps=1e-6) | |
| # ============================================================================ | |
| # Qwen2.5-Omni-3B audio tower (frozen base + LoRA) | |
| # ============================================================================ | |
| def load_omni_audio_tower(qwen_omni_path: str, prefix: str = "thinker.audio_tower.") -> Qwen2_5OmniAudioEncoder: | |
| """Reads only the audio-tower tensors out of the full Qwen2.5-Omni checkpoint.""" | |
| audio_config = AutoConfig.from_pretrained(qwen_omni_path).thinker_config.audio_config | |
| tower = Qwen2_5OmniAudioEncoder(audio_config) | |
| with open(os.path.join(qwen_omni_path, "model.safetensors.index.json")) as f: | |
| weight_map = json.load(f)["weight_map"] | |
| state = {} | |
| for shard in sorted({s for k, s in weight_map.items() if k.startswith(prefix)}): | |
| with safe_open(os.path.join(qwen_omni_path, shard), framework="pt") as f: | |
| for key in f.keys(): | |
| if key.startswith(prefix): | |
| state[key[len(prefix):]] = f.get_tensor(key) | |
| tower.load_state_dict(state, strict=True) | |
| return tower | |
| def inject_audio_lora(tower: nn.Module, lora: dict) -> nn.Module: | |
| """Adds the (untrained) LoRA modules Sentio was trained with; weights come from trainable.pt.""" | |
| from peft import LoraConfig, inject_adapter_in_model | |
| cfg = LoraConfig(r=lora["r"], lora_alpha=lora["alpha"], lora_dropout=lora["dropout"], | |
| target_modules=list(lora["targets"]), bias="none") | |
| return inject_adapter_in_model(cfg, tower, adapter_name="default") | |
| def load_trainable(trainable_path: str, audio_tower: nn.Module, audio_head: nn.Module): | |
| """trainable.pt holds the head blocks and the audio-tower LoRA tensors saved during training.""" | |
| state = torch.load(trainable_path, map_location="cpu", weights_only=True) | |
| for block in ("audio_connector", "adapter_fusion", "projection"): | |
| missing, unexpected = getattr(audio_head, block).load_state_dict(state[block], strict=False) | |
| if missing or unexpected: | |
| raise RuntimeError(f"{block}: missing {missing}, unexpected {unexpected}") | |
| lora_state = {k[len("encoder."):] if k.startswith("encoder.") else k: v | |
| for k, v in state["audio_encoder"].items() if "lora_" in k} | |
| missing, unexpected = audio_tower.load_state_dict(lora_state, strict=False) | |
| if unexpected or any("lora_" in k for k in missing): | |
| raise RuntimeError(f"audio-tower LoRA: unexpected {unexpected}, missing {[k for k in missing if 'lora_' in k]}") | |
| def pool_audio_tower(tower: Qwen2_5OmniAudioEncoder, features: torch.Tensor, num_frames: int) -> torch.Tensor: | |
| """(128, T) mel features of one clip -> mean of the tower's output states over its valid frames.""" | |
| if num_frames < 4: | |
| return features.new_zeros(tower.config.output_dim) | |
| features = features[:, :num_frames].contiguous() | |
| feature_lens = torch.tensor([num_frames], dtype=torch.long, device=features.device) | |
| aftercnn_lens = ((feature_lens - 1) // 2 + 1).clamp(min=1) | |
| out = tower(features, feature_lens=feature_lens, aftercnn_lens=aftercnn_lens) | |
| out = out[0] if isinstance(out, tuple) else out.last_hidden_state | |
| return out.mean(dim=0) if out.dim() == 2 else out.mean(dim=1).squeeze(0) | |
| # ============================================================================ | |
| # Sentio | |
| # ============================================================================ | |
| class Sentio(nn.Module): | |
| def __init__(self, config: dict, audio_tower, audio_head, feature_extractor, wemm, wemm_processor): | |
| super().__init__() | |
| self.config = config | |
| self.audio_tower = audio_tower | |
| self.audio_head = audio_head | |
| self.feature_extractor = feature_extractor | |
| self.wemm = wemm | |
| self.wemm_processor = wemm_processor | |
| def from_pretrained(cls, path: str, device: Optional[str] = None, dtype: torch.dtype = torch.bfloat16, | |
| **config_overrides): | |
| """config_overrides replace config.json entries, e.g. wemm_path=..., trainable_path=...""" | |
| device = device or ("cuda" if torch.cuda.is_available() else "cpu") | |
| with open(os.path.join(path, "config.json")) as f: | |
| config = {**json.load(f), **config_overrides} | |
| audio_tower = inject_audio_lora(load_omni_audio_tower(config["qwen_omni_path"]), config["audio_lora"]) | |
| audio_head = SentioAudioHead(config["audio_tower_dim"], config["embedding_dim"]) | |
| load_trainable(config["trainable_path"], audio_tower, audio_head) | |
| feature_extractor = WhisperFeatureExtractor.from_pretrained(config["qwen_omni_path"]) | |
| wemm = AutoModel.from_pretrained( | |
| config["wemm_path"], torch_dtype=dtype, trust_remote_code=True, attn_implementation="sdpa", | |
| ) | |
| if config.get("wemm_adapter_path"): | |
| from peft import PeftModel | |
| wemm = PeftModel.from_pretrained(wemm, config["wemm_adapter_path"]).merge_and_unload() | |
| wemm_processor = AutoProcessor.from_pretrained(config["wemm_path"], trust_remote_code=True) | |
| model = cls(config, audio_tower, audio_head, feature_extractor, wemm, wemm_processor) | |
| return model.to(device=device, dtype=dtype).eval() | |
| def device(self) -> torch.device: | |
| return next(self.audio_head.parameters()).device | |
| def _autocast(self): | |
| return torch.autocast(device_type=self.device.type, dtype=torch.bfloat16, | |
| enabled=self.device.type == "cuda") | |
| # ---------------------------------------------------------------- audio | |
| def load_audio(self, audio: AudioInput, sampling_rate: Optional[int] = None) -> np.ndarray: | |
| """Path or waveform -> mono float32 at 16 kHz, head-cropped to audio_max_seconds. | |
| Clips shorter than audio_min_seconds come back as 10 ms of silence (a zero embedding).""" | |
| target_sr = self.config["audio_sample_rate"] | |
| if isinstance(audio, str): | |
| wav, sampling_rate = sf.read(audio, dtype="float32") | |
| wav = torch.from_numpy(wav) | |
| else: | |
| if sampling_rate is None: | |
| raise ValueError("sampling_rate is required when passing a waveform") | |
| wav = torch.as_tensor(audio, dtype=torch.float32) | |
| if wav.dim() > 1: | |
| wav = wav.mean(dim=1) | |
| if sampling_rate != target_sr and wav.numel() > 0: | |
| wav = torchaudio.functional.resample(wav, sampling_rate, target_sr) | |
| if wav.numel() < int(self.config["audio_min_seconds"] * target_sr): | |
| return np.zeros(target_sr // 100, dtype=np.float32) | |
| return wav[: int(self.config["audio_max_seconds"] * target_sr)].numpy() | |
| def encode_audio(self, audios: Sequence[AudioInput], sampling_rate: Optional[int] = None) -> torch.Tensor: | |
| """Audio file paths or 1-D/2-D (samples, channels) waveforms -> (N, 4096).""" | |
| pooled = [] | |
| for audio in audios: | |
| wav = self.load_audio(audio, sampling_rate) | |
| feats = self.feature_extractor(wav, sampling_rate=self.config["audio_sample_rate"], | |
| return_attention_mask=True, return_tensors="pt") | |
| features = feats["input_features"][0].to(self.device, dtype=self.audio_tower.dtype) | |
| num_frames = min(int(feats["attention_mask"][0].sum()), features.shape[-1]) | |
| with self._autocast(): | |
| pooled.append(pool_audio_tower(self.audio_tower, features, num_frames)) | |
| with self._autocast(): | |
| return self.audio_head(torch.stack(pooled)).float() | |
| # ----------------------------------------------------------------- WeMM | |
| def _encode_wemm(self, conversations: List[list], has_visual: bool) -> torch.Tensor: | |
| chat_texts = [ | |
| self.wemm_processor.apply_chat_template(conv, tokenize=False, add_generation_prompt=False) | |
| for conv in conversations | |
| ] | |
| kwargs = dict(text=chat_texts, padding=True, return_tensors="pt") | |
| if has_visual: | |
| from qwen_vl_utils import process_vision_info | |
| images, _, _ = process_vision_info(conversations, image_patch_size=16, | |
| return_video_kwargs=True, return_video_metadata=True) | |
| kwargs.update(images=images, truncation=False) | |
| else: | |
| kwargs.update(truncation=True, max_length=WEMM_TEXT_MAX_LENGTH) | |
| inputs = self.wemm_processor(**kwargs).to(self.device) | |
| inner = getattr(self.wemm, "model", None) | |
| if inner is not None and hasattr(inner, "rope_deltas"): | |
| inner.rope_deltas = None | |
| with self._autocast(): | |
| emb = self.wemm.embedding(**inputs) | |
| return F.normalize(emb.float(), p=2, dim=-1) | |
| def encode_text(self, texts: Sequence[str]) -> torch.Tensor: | |
| """Texts -> (N, 4096). Audio<->text was trained and evaluated on raw labels and | |
| captions (e.g. "dog", "chirping_birds"), with no instruction prefix.""" | |
| conversations = [[{"role": "user", "content": [{"type": "text", "text": t.strip()}]}] for t in texts] | |
| return self._encode_wemm(conversations, has_visual=False) | |
| def encode_image(self, images: Sequence, texts: Optional[Sequence[str]] = None) -> torch.Tensor: | |
| """Image paths/URLs/PIL images, each with optional accompanying text -> (N, 4096). | |
| This is WeMM-Embedding-9B with the vdr LoRA merged; for video, call self.wemm as in WeMM's model card.""" | |
| texts = texts or [""] * len(images) | |
| conversations = [] | |
| for image, text in zip(images, texts): | |
| content = [{"type": "image", "image": image, **WEMM_IMAGE_KWARGS}] | |
| if text.strip(): | |
| content.append({"type": "text", "text": text.strip()}) | |
| conversations.append([{"role": "user", "content": content}]) | |
| return self._encode_wemm(conversations, has_visual=True) | |