Dragos-Datcu commited on
Commit
943d95f
·
verified ·
1 Parent(s): 251535d

Sentio model card, config and modeling code

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ resources/sentio_architecture.png filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ pipeline_tag: feature-extraction
4
+ base_model:
5
+ - tencent/WeMM-Embedding-9B
6
+ - Qwen/Qwen2.5-Omni-3B
7
+ tags:
8
+ - audio
9
+ - multimodal-embedding
10
+ - audio-retrieval
11
+ - audio-classification
12
+ - video-moment-retrieval
13
+ ---
14
+
15
+ # Sentio
16
+
17
+ ![Sentio architecture](resources/sentio_architecture.png)
18
+
19
+ Sentio is WeMM-Embedding-9B with ears, and with sharper eyes for video moments.
20
+
21
+ WeMM-Embedding-9B is the strongest open embedding model for text, images, video and visual
22
+ documents, but it cannot hear. [Wianmu-5B](https://huggingface.co/eduardo-terres2/Wianmu-5B) gave
23
+ the 4B member of the family an audio tower by freezing both the embedding model and a pretrained
24
+ audio encoder and training only a small connector between them, the recipe of Tianmu-Emb-Uni.
25
+ Sentio does the same to the 9B, with two additions: before the audio stage, the 9B tower gets a
26
+ small LoRA fine-tune for video moment retrieval, so the vision side also moves; and during the audio
27
+ stage the audio encoder gets a LoRA of its own, trained together with the connector. The name is
28
+ Latin for "I perceive": one model that hears, sees and reads into one space. Total 10.20B
29
+ parameters, of which 152M (1.5%) were trained.
30
+
31
+ ## How it works
32
+
33
+ Two experts, one that understands audio and one that understands everything else, and a trained
34
+ piece in the middle that moves audio into the other model's embedding space.
35
+
36
+ The audio side is the audio tower of Qwen2.5-Omni-3B (the encoder only, 638M parameters) with a
37
+ LoRA on its attention and MLP projections. Everything else goes through WeMM-Embedding-9B. Audio
38
+ lands in WeMM's 4096-d space, so an audio clip can be compared against a caption, a label name, an
39
+ image or a video directly, and nothing that was indexed with the tower has to be re-indexed.
40
+
41
+ ```
42
+ waveform
43
+ -> Whisper-style mel features (128 bins, up to 30 s)
44
+ -> Qwen2.5-Omni audio tower + LoRA (r=16), masked-mean pooled over frames -> 2048-d
45
+ -> connector: 3-layer MLP, 2048 -> 4096 -> 8192 -> 4096, LayerNorm + GELU
46
+ -> modality adapter fusion
47
+ -> RMSNorm projection
48
+ -> L2 normalize -> 4096-d embedding
49
+ ```
50
+
51
+ **Two stages, three trained pieces.**
52
+
53
+ 1. *Stage 1, the tower.* WeMM-Embedding-9B gets a LoRA (r=16, α=32, 43.3M parameters) trained with
54
+ InfoNCE on video moment-retrieval pairs mixed with a replay of visual-document, video and image
55
+ pairs so that nothing already strong is lost. This is the only change to the non-audio path: a
56
+ text, image, video or document embedding from Sentio is the embedding of WeMM-9B with this LoRA
57
+ applied. It lifts MMEB-V2 from 80.58 to 80.68 and V-MRET from 58.46 to 61.53.
58
+ 2. *Stage 2, the ears.* With the tower frozen (LoRA included), the audio path is trained: a LoRA on
59
+ the audio tower (r=16, α=32, dropout 0.05, on q/k/v/out projections and both MLP layers of every
60
+ encoder block, 11.8M parameters) together with the audio head — connector, adapter fusion,
61
+ projection norm and prototypes, 96.8M parameters, almost all of it the connector (75.5M).
62
+
63
+ **Adapter fusion.** One audio clip can be matched against very different things. For a caption you
64
+ care about meaning, for another audio clip about acoustic detail, for a video about timing, and for
65
+ a label about the category. So there are four small bottleneck adapters, one each for text, audio,
66
+ video and label. A gate looks at the audio vector and mixes the four outputs with soft weights, with
67
+ a small entropy bonus during training so the gate doesn't collapse onto one adapter.
68
+
69
+ | Adapter | Input | Down projection | Activation | Bottleneck | Up projection | Output | Residual | Parameters |
70
+ |---|---|---|---|---|---|---|---|---|
71
+ | text | 4096 | Linear 4096 → 512, no bias | GELU | 512 | Linear 512 → 4096, no bias | 4096 | yes | 4,194,304 |
72
+ | audio | 4096 | Linear 4096 → 512, no bias | GELU | 512 | Linear 512 → 4096, no bias | 4096 | yes | 4,194,304 |
73
+ | video | 4096 | Linear 4096 → 512, no bias | GELU | 512 | Linear 512 → 4096, no bias | 4096 | yes | 4,194,304 |
74
+ | label | 4096 | Linear 4096 → 512, no bias | GELU | 512 | Linear 512 → 4096, no bias | 4096 | yes | 4,194,304 |
75
+ | gate | 4096 | Linear 4096 → 1024, no bias + LayerNorm(1024) | GELU | 1024 | Linear 1024 → 4, with bias | 4 (softmax weights) | no | 4,200,452 |
76
+
77
+ **Shared prototypes.** 64 learnable vectors in the shared space. Each audio embedding and each target
78
+ embedding gets a soft assignment over them, and training pushes the audio and its matching target
79
+ to pick the same prototypes. They are only used for the loss; at inference you just use the embedding.
80
+
81
+ **Parameter count** (measured from the weight files):
82
+
83
+ | Component | Parameters | Trained |
84
+ |---|---|---|
85
+ | WeMM-Embedding-9B tower | 9,408M | frozen |
86
+ | vdr LoRA on the tower (stage 1) | 43.3M | yes |
87
+ | Qwen2.5-Omni-3B audio tower | 638M | frozen |
88
+ | LoRA on the audio tower (stage 2) | 11.8M | yes |
89
+ | Connector (3-layer MLP) | 75.5M | yes (stage 2) |
90
+ | Adapter fusion (4 adapters + gate) | 21.0M | yes (stage 2) |
91
+ | RMSNorm projection + 64 prototypes | 0.27M | yes (stage 2) |
92
+ | **Total** | **10.20B** | **152M (1.5%)** |
93
+
94
+ ## Training
95
+
96
+ *Stage 2 (audio).* The loss is symmetric in-batch InfoNCE between audio and target embeddings
97
+ (temperature 0.07), which does most of the work. On top of it come the Tianmu prototype loss — a
98
+ contrastive term over prototype assignments (0.03), a symmetric KL between the audio and target
99
+ assignments (0.01) and a balance term so all prototypes get used (0.005) — and a gate-entropy bonus
100
+ (0.01). For label datasets, batches are built so that each label appears at most once; otherwise two
101
+ clips of "dog bark" in one batch count as negatives of each other.
102
+
103
+ The data is 449k audio–target pairs over 37k distinct targets from seven public datasets, sampled
104
+ in natural proportion: NSynth (instrument labels, the bulk of every epoch), SpeechCommands
105
+ (spoken-word labels), FSD50K and RAVDESS (sound-event and emotion labels), Clotho (captions),
106
+ Flickr8k Audio (spoken captions paired with the image they describe) and AVE (clips paired with
107
+ their source video). Batch size 16 (smaller where a dataset has fewer distinct labels); learning
108
+ rate 1e-5 for the head and 1e-4 for the audio-tower LoRA, constant after 500 warm-up steps;
109
+ 498,000 steps on one H200. Validation is the same in-batch InfoNCE on a held-out split every 2,500
110
+ steps; the released checkpoint is the one with the lowest validation loss (step ~408,000).
111
+
112
+ *Stage 1 (tower).* LoRA r=16, α=32 on the WeMM-9B tower, 600 steps of InfoNCE at temperature 0.045;
113
+ 8/11 of each batch are DiDeMo moment–query pairs and 1/11 each MSR-VTT video–text, MSCOCO
114
+ text-to-image and ColPali page–query pairs, replayed so that video retrieval, image retrieval and
115
+ visual documents keep their scores while V-MRET moves.
116
+
117
+ ## Evaluation
118
+
119
+ All 190 MMEB-V3 tasks with the [Tencent WeMM evaluation harness](https://github.com/Tencent/WeMM-Embedding/tree/main/mmeb_v3_eval),
120
+ with the Wianmu audio modifications for the 11 audio tasks; scored with the leaderboard's own code.
121
+ For the two TUTSound tasks, whose queries carry both an audio clip and text, the model has no joint
122
+ encoder, so the two unit embeddings are summed with equal weight and re-normalised.
123
+
124
+ MMEB-V3, leaderboard snapshot of 16 Oct 2026:
125
+
126
+ | Model | Overall | Overall-V3 (new) | Text | Audio | Agent | Image | Video | Visdoc |
127
+ |---|---|---|---|---|---|---|---|---|
128
+ | **Sentio** | **61.69** | **48.46** | **48.98** | 36.81 | **50.57** | 80.90 | **75.04** | 83.28 |
129
+ | Wianmu-5B-v0 | 60.45 | 47.50 | 48.13 | 42.93 | 48.00 | 79.73 | 71.56 | 81.99 |
130
+ | WeMM-Embedding-9B | 59.33 | 44.54 | 48.76 | — | 50.10 | 80.99 | 74.31 | 83.34 |
131
+ | Wianmu-3B-v0 | 58.16 | 44.37 | 45.30 | 41.16 | 44.11 | 78.67 | 70.79 | 80.80 |
132
+
133
+ MMEB-V2 (78 tasks) and the video block:
134
+
135
+ | Model | Overall-V2 | Image (V2) | Video | V-CLS | V-QA | V-RET | V-MRET | Visdoc |
136
+ |---|---|---|---|---|---|---|---|---|
137
+ | **Sentio** | 80.68 | 81.77 | 75.04 | 87.54 | 77.89 | 67.80 | 61.53 | 83.28 |
138
+ | WeMM-Embedding-9B | 80.58 | 81.87 | 74.31 | 87.36 | 77.69 | 67.38 | 58.46 | 83.34 |
139
+
140
+ Audio tasks (hit@1):
141
+
142
+ | Task | Sentio | Wianmu-3B-v0 | Wianmu-5B-v0 |
143
+ |---|---|---|---|
144
+ | NSynth | 76.10 | 74.10 | 60.90 |
145
+ | ESC-50 | 56.15 | 79.20 | 86.60 |
146
+ | UrbanSound8K | 55.92 | 64.05 | 66.63 |
147
+ | SpeechCommands | 89.50 | 89.80 | 89.90 |
148
+ | CREMA-D | 33.74 | 47.08 | 48.93 |
149
+ | Clotho | 8.48 | 13.61 | 16.56 |
150
+ | SoundDescs | 6.10 | 9.50 | 20.50 |
151
+ | TUTSound | 61.76 | 52.96 | 46.89 |
152
+ | TUTSound (hard) | 5.16 | 4.55 | 3.64 |
153
+ | AVE | 9.20 | 16.42 | 18.66 |
154
+ | SpeechCOCO | 2.80 | 1.50 | 13.00 |
155
+ | **A-CLS / A-RET / Audio-Overall** | **62.28 / 15.58 / 36.81** | 70.85 / 16.42 / 41.16 | 70.59 / 19.88 / 42.93 |
156
+
157
+ Sentio takes the top of the 190-task board on the strength of the 9B tower (text, agent, video and
158
+ visual documents) and is the first of a series: the audio block is where it trails the smaller
159
+ Wianmu models, and the next releases work on exactly that.
160
+
161
+ ## Known limitations
162
+
163
+ - Audio is pooled into a single vector, so there is no fine timing inside a clip. Grounding and
164
+ moment-level tasks suffer from this the most.
165
+ - Clips longer than 30 seconds get cut.
166
+ - The audio stage saw seven datasets, dominated by NSynth instrument labels. Together with the
167
+ adaptable encoder this makes the model strong on instruments and sound events (NSynth, TUTSound)
168
+ and weaker than the smaller Wianmu models on environmental scenes (ESC-50, UrbanSound8K), emotion
169
+ (CREMA-D) and audio captions (Clotho, SoundDescs).
170
+ - The tower is frozen during the audio stage, so whatever WeMM-9B can't tell apart in text, Sentio
171
+ can't either. Some label names also collide across datasets ("happy" is a spoken word in
172
+ SpeechCommands and an emotion in RAVDESS, and both share one target vector).
173
+ - Most of the training targets are English.
174
+
175
+ ## Usage
176
+
177
+ The repository holds the model card, `config.json` and `modeling_sentio.py`; the base checkpoints
178
+ (WeMM-Embedding-9B, Qwen2.5-Omni-3B) are downloaded from their own repositories, and Sentio's
179
+ trained parts (the vdr LoRA adapter, 43M, and `trainable.pt` with the audio head and the
180
+ audio-tower LoRA, 109M) are not distributed here.
181
+
182
+ ```python
183
+ from modeling_sentio import Sentio
184
+
185
+ model = Sentio.from_pretrained(
186
+ ".",
187
+ wemm_path="path/to/WeMM-Embedding-9B",
188
+ wemm_adapter_path="path/to/sentio/vdr_lora_adapter",
189
+ qwen_omni_path="path/to/Qwen2.5-Omni-3B",
190
+ trainable_path="path/to/sentio/trainable.pt",
191
+ )
192
+ audio = model.encode_audio(["dog.wav", "piano.wav"]) # (2, 4096)
193
+ text = model.encode_text(["dog barking", "piano"]) # (2, 4096)
194
+ image = model.encode_image(["photo.jpg"]) # (1, 4096)
195
+ scores = audio @ text.T
196
+ ```
197
+
198
+ Preprocessing (128-bin mel features, 30-second head crop, mean pooling over valid frames) mirrors
199
+ the MMEB-V3 evaluation that produced the scores above.
200
+
201
+ ## License
202
+
203
+ Sentio's own code, model card and trained parameters are released under Apache-2.0. The two base
204
+ models keep their own terms: WeMM-Embedding-9B is licensed by Tencent under Apache-2.0; the audio
205
+ encoder is the audio tower of Qwen2.5-Omni-3B, distributed by Alibaba Cloud under the
206
+ [Qwen Research License](https://huggingface.co/Qwen/Qwen2.5-Omni-3B/blob/main/LICENSE), whose terms
207
+ apply to that component.
208
+
209
+ ## Credits
210
+ Credits to [Wianmu-5B](https://huggingface.co/eduardo-terres2/Wianmu-5B), [Tianmu-Emb-Uni](https://huggingface.co/TianmuLab/Tianmu-Emb-Uni/tree/main) and the [WeMM-Embedding family](https://github.com/Tencent/WeMM-Embedding).
__pycache__/modeling_sentio.cpython-312.pyc ADDED
Binary file (22.7 kB). View file
 
config.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "Sentio",
3
+ "wemm_path": "path/to/WeMM-Embedding-9B",
4
+ "wemm_adapter_path": "path/to/sentio/vdr_lora_adapter",
5
+ "qwen_omni_path": "path/to/Qwen2.5-Omni-3B",
6
+ "trainable_path": "path/to/sentio/trainable.pt",
7
+ "audio_tower_dim": 2048,
8
+ "embedding_dim": 4096,
9
+ "audio_sample_rate": 16000,
10
+ "audio_max_seconds": 30,
11
+ "audio_min_seconds": 0.025,
12
+ "audio_lora": {
13
+ "r": 16,
14
+ "alpha": 32,
15
+ "dropout": 0.05,
16
+ "targets": ["q_proj", "k_proj", "v_proj", "out_proj", "fc1", "fc2"]
17
+ }
18
+ }
modeling_sentio.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sentio: WeMM-Embedding-9B (+ a LoRA for video moment retrieval) with an audio branch, all
3
+ embedding into one 4096-d space.
4
+
5
+ text / image / video / page -> WeMM-Embedding-9B with the vdr LoRA merged in
6
+ audio -> Qwen2.5-Omni-3B audio tower + LoRA -> mean pool -> trained head
7
+ (connector -> modality adapter fusion -> RMSNorm)
8
+
9
+ config.json points at the two base checkpoints, the vdr LoRA adapter (PEFT directory) and
10
+ trainable.pt (audio head + audio-tower LoRA). Preprocessing mirrors the MMEB-V3 evaluation that
11
+ produced the reported scores. Adapted from Wianmu-5B's modeling_wianmu.py.
12
+ """
13
+ import json
14
+ import os
15
+ from typing import List, Optional, Sequence, Union
16
+
17
+ import numpy as np
18
+ import soundfile as sf
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+ import torchaudio
23
+ from safetensors import safe_open
24
+ from transformers import AutoConfig, AutoModel, AutoProcessor, WhisperFeatureExtractor
25
+ from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder
26
+
27
+ AudioInput = Union[str, np.ndarray, torch.Tensor]
28
+
29
+ TARGET_MODALITIES = ("text", "audio", "video", "label")
30
+ WEMM_IMAGE_KWARGS = {"min_pixels": 64 * 32 * 32, "max_pixels": 8192 * 32 * 32}
31
+ WEMM_TEXT_MAX_LENGTH = 8192
32
+
33
+
34
+ # ============================================================================
35
+ # Trained audio head
36
+ # ============================================================================
37
+ class AudioConnector(nn.Module):
38
+ def __init__(self, input_dim: int, output_dim: int):
39
+ super().__init__()
40
+ h1, h2 = 2 * input_dim, 4 * input_dim
41
+ self.mlp = nn.Sequential(
42
+ nn.Linear(input_dim, h1, bias=False), nn.LayerNorm(h1), nn.GELU(), nn.Dropout(0.1),
43
+ nn.Linear(h1, h2, bias=False), nn.LayerNorm(h2), nn.GELU(), nn.Dropout(0.1),
44
+ nn.Linear(h2, output_dim, bias=False),
45
+ )
46
+
47
+ def forward(self, x):
48
+ return self.mlp(x)
49
+
50
+
51
+ class LightweightAdapter(nn.Module):
52
+ def __init__(self, dim: int, reduction: int = 8):
53
+ super().__init__()
54
+ self.down = nn.Linear(dim, dim // reduction, bias=False)
55
+ self.act = nn.GELU()
56
+ self.drop = nn.Dropout(0.1)
57
+ self.up = nn.Linear(dim // reduction, dim, bias=False)
58
+
59
+ def forward(self, x):
60
+ return x + self.up(self.drop(self.act(self.down(x))))
61
+
62
+
63
+ class ModalityAdapterFusion(nn.Module):
64
+ """One adapter per target modality, mixed by a softmax gate computed from the input."""
65
+
66
+ def __init__(self, dim: int):
67
+ super().__init__()
68
+ self.adapters = nn.ModuleDict({m: LightweightAdapter(dim) for m in TARGET_MODALITIES})
69
+ self.gate = nn.Sequential(
70
+ nn.Linear(dim, dim // 4, bias=False), nn.LayerNorm(dim // 4), nn.GELU(),
71
+ nn.Linear(dim // 4, len(TARGET_MODALITIES)),
72
+ )
73
+
74
+ def forward(self, x):
75
+ weights = F.softmax(self.gate(x), dim=-1)
76
+ stacked = torch.stack([self.adapters[m](x) for m in TARGET_MODALITIES], dim=1)
77
+ return (weights.unsqueeze(-1) * stacked).sum(dim=1)
78
+
79
+
80
+ class ProjectionHead(nn.Module):
81
+ def __init__(self, dim: int):
82
+ super().__init__()
83
+ self.norm = nn.RMSNorm(dim, eps=1e-6)
84
+
85
+ def forward(self, x):
86
+ return self.norm(x)
87
+
88
+
89
+ class SentioAudioHead(nn.Module):
90
+ """Pooled audio-tower output (B, audio_dim) -> L2-normalized (B, dim)."""
91
+
92
+ def __init__(self, audio_dim: int, dim: int):
93
+ super().__init__()
94
+ self.audio_connector = AudioConnector(audio_dim, dim)
95
+ self.adapter_fusion = ModalityAdapterFusion(dim)
96
+ self.projection = ProjectionHead(dim)
97
+
98
+ def forward(self, x):
99
+ x = self.projection(self.adapter_fusion(self.audio_connector(x)))
100
+ return F.normalize(x, p=2, dim=-1, eps=1e-6)
101
+
102
+
103
+ # ============================================================================
104
+ # Qwen2.5-Omni-3B audio tower (frozen base + LoRA)
105
+ # ============================================================================
106
+ def load_omni_audio_tower(qwen_omni_path: str, prefix: str = "thinker.audio_tower.") -> Qwen2_5OmniAudioEncoder:
107
+ """Reads only the audio-tower tensors out of the full Qwen2.5-Omni checkpoint."""
108
+ audio_config = AutoConfig.from_pretrained(qwen_omni_path).thinker_config.audio_config
109
+ tower = Qwen2_5OmniAudioEncoder(audio_config)
110
+ with open(os.path.join(qwen_omni_path, "model.safetensors.index.json")) as f:
111
+ weight_map = json.load(f)["weight_map"]
112
+ state = {}
113
+ for shard in sorted({s for k, s in weight_map.items() if k.startswith(prefix)}):
114
+ with safe_open(os.path.join(qwen_omni_path, shard), framework="pt") as f:
115
+ for key in f.keys():
116
+ if key.startswith(prefix):
117
+ state[key[len(prefix):]] = f.get_tensor(key)
118
+ tower.load_state_dict(state, strict=True)
119
+ return tower
120
+
121
+
122
+ def inject_audio_lora(tower: nn.Module, lora: dict) -> nn.Module:
123
+ """Adds the (untrained) LoRA modules Sentio was trained with; weights come from trainable.pt."""
124
+ from peft import LoraConfig, inject_adapter_in_model
125
+ cfg = LoraConfig(r=lora["r"], lora_alpha=lora["alpha"], lora_dropout=lora["dropout"],
126
+ target_modules=list(lora["targets"]), bias="none")
127
+ return inject_adapter_in_model(cfg, tower, adapter_name="default")
128
+
129
+
130
+ def load_trainable(trainable_path: str, audio_tower: nn.Module, audio_head: nn.Module):
131
+ """trainable.pt holds the head blocks and the audio-tower LoRA tensors saved during training."""
132
+ state = torch.load(trainable_path, map_location="cpu", weights_only=True)
133
+ for block in ("audio_connector", "adapter_fusion", "projection"):
134
+ missing, unexpected = getattr(audio_head, block).load_state_dict(state[block], strict=False)
135
+ if missing or unexpected:
136
+ raise RuntimeError(f"{block}: missing {missing}, unexpected {unexpected}")
137
+ lora_state = {k[len("encoder."):] if k.startswith("encoder.") else k: v
138
+ for k, v in state["audio_encoder"].items() if "lora_" in k}
139
+ missing, unexpected = audio_tower.load_state_dict(lora_state, strict=False)
140
+ if unexpected or any("lora_" in k for k in missing):
141
+ raise RuntimeError(f"audio-tower LoRA: unexpected {unexpected}, missing {[k for k in missing if 'lora_' in k]}")
142
+
143
+
144
+ @torch.no_grad()
145
+ def pool_audio_tower(tower: Qwen2_5OmniAudioEncoder, features: torch.Tensor, num_frames: int) -> torch.Tensor:
146
+ """(128, T) mel features of one clip -> mean of the tower's output states over its valid frames."""
147
+ if num_frames < 4:
148
+ return features.new_zeros(tower.config.output_dim)
149
+ features = features[:, :num_frames].contiguous()
150
+ feature_lens = torch.tensor([num_frames], dtype=torch.long, device=features.device)
151
+ aftercnn_lens = ((feature_lens - 1) // 2 + 1).clamp(min=1)
152
+ out = tower(features, feature_lens=feature_lens, aftercnn_lens=aftercnn_lens)
153
+ out = out[0] if isinstance(out, tuple) else out.last_hidden_state
154
+ return out.mean(dim=0) if out.dim() == 2 else out.mean(dim=1).squeeze(0)
155
+
156
+
157
+ # ============================================================================
158
+ # Sentio
159
+ # ============================================================================
160
+ class Sentio(nn.Module):
161
+ def __init__(self, config: dict, audio_tower, audio_head, feature_extractor, wemm, wemm_processor):
162
+ super().__init__()
163
+ self.config = config
164
+ self.audio_tower = audio_tower
165
+ self.audio_head = audio_head
166
+ self.feature_extractor = feature_extractor
167
+ self.wemm = wemm
168
+ self.wemm_processor = wemm_processor
169
+
170
+ @classmethod
171
+ def from_pretrained(cls, path: str, device: Optional[str] = None, dtype: torch.dtype = torch.bfloat16,
172
+ **config_overrides):
173
+ """config_overrides replace config.json entries, e.g. wemm_path=..., trainable_path=..."""
174
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
175
+ with open(os.path.join(path, "config.json")) as f:
176
+ config = {**json.load(f), **config_overrides}
177
+
178
+ audio_tower = inject_audio_lora(load_omni_audio_tower(config["qwen_omni_path"]), config["audio_lora"])
179
+ audio_head = SentioAudioHead(config["audio_tower_dim"], config["embedding_dim"])
180
+ load_trainable(config["trainable_path"], audio_tower, audio_head)
181
+ feature_extractor = WhisperFeatureExtractor.from_pretrained(config["qwen_omni_path"])
182
+
183
+ wemm = AutoModel.from_pretrained(
184
+ config["wemm_path"], torch_dtype=dtype, trust_remote_code=True, attn_implementation="sdpa",
185
+ )
186
+ if config.get("wemm_adapter_path"):
187
+ from peft import PeftModel
188
+ wemm = PeftModel.from_pretrained(wemm, config["wemm_adapter_path"]).merge_and_unload()
189
+ wemm_processor = AutoProcessor.from_pretrained(config["wemm_path"], trust_remote_code=True)
190
+
191
+ model = cls(config, audio_tower, audio_head, feature_extractor, wemm, wemm_processor)
192
+ return model.to(device=device, dtype=dtype).eval()
193
+
194
+ @property
195
+ def device(self) -> torch.device:
196
+ return next(self.audio_head.parameters()).device
197
+
198
+ def _autocast(self):
199
+ return torch.autocast(device_type=self.device.type, dtype=torch.bfloat16,
200
+ enabled=self.device.type == "cuda")
201
+
202
+ # ---------------------------------------------------------------- audio
203
+ def load_audio(self, audio: AudioInput, sampling_rate: Optional[int] = None) -> np.ndarray:
204
+ """Path or waveform -> mono float32 at 16 kHz, head-cropped to audio_max_seconds.
205
+ Clips shorter than audio_min_seconds come back as 10 ms of silence (a zero embedding)."""
206
+ target_sr = self.config["audio_sample_rate"]
207
+ if isinstance(audio, str):
208
+ wav, sampling_rate = sf.read(audio, dtype="float32")
209
+ wav = torch.from_numpy(wav)
210
+ else:
211
+ if sampling_rate is None:
212
+ raise ValueError("sampling_rate is required when passing a waveform")
213
+ wav = torch.as_tensor(audio, dtype=torch.float32)
214
+ if wav.dim() > 1:
215
+ wav = wav.mean(dim=1)
216
+ if sampling_rate != target_sr and wav.numel() > 0:
217
+ wav = torchaudio.functional.resample(wav, sampling_rate, target_sr)
218
+ if wav.numel() < int(self.config["audio_min_seconds"] * target_sr):
219
+ return np.zeros(target_sr // 100, dtype=np.float32)
220
+ return wav[: int(self.config["audio_max_seconds"] * target_sr)].numpy()
221
+
222
+ @torch.no_grad()
223
+ def encode_audio(self, audios: Sequence[AudioInput], sampling_rate: Optional[int] = None) -> torch.Tensor:
224
+ """Audio file paths or 1-D/2-D (samples, channels) waveforms -> (N, 4096)."""
225
+ pooled = []
226
+ for audio in audios:
227
+ wav = self.load_audio(audio, sampling_rate)
228
+ feats = self.feature_extractor(wav, sampling_rate=self.config["audio_sample_rate"],
229
+ return_attention_mask=True, return_tensors="pt")
230
+ features = feats["input_features"][0].to(self.device, dtype=self.audio_tower.dtype)
231
+ num_frames = min(int(feats["attention_mask"][0].sum()), features.shape[-1])
232
+ with self._autocast():
233
+ pooled.append(pool_audio_tower(self.audio_tower, features, num_frames))
234
+ with self._autocast():
235
+ return self.audio_head(torch.stack(pooled)).float()
236
+
237
+ # ----------------------------------------------------------------- WeMM
238
+ @torch.no_grad()
239
+ def _encode_wemm(self, conversations: List[list], has_visual: bool) -> torch.Tensor:
240
+ chat_texts = [
241
+ self.wemm_processor.apply_chat_template(conv, tokenize=False, add_generation_prompt=False)
242
+ for conv in conversations
243
+ ]
244
+ kwargs = dict(text=chat_texts, padding=True, return_tensors="pt")
245
+ if has_visual:
246
+ from qwen_vl_utils import process_vision_info
247
+ images, _, _ = process_vision_info(conversations, image_patch_size=16,
248
+ return_video_kwargs=True, return_video_metadata=True)
249
+ kwargs.update(images=images, truncation=False)
250
+ else:
251
+ kwargs.update(truncation=True, max_length=WEMM_TEXT_MAX_LENGTH)
252
+ inputs = self.wemm_processor(**kwargs).to(self.device)
253
+ inner = getattr(self.wemm, "model", None)
254
+ if inner is not None and hasattr(inner, "rope_deltas"):
255
+ inner.rope_deltas = None
256
+ with self._autocast():
257
+ emb = self.wemm.embedding(**inputs)
258
+ return F.normalize(emb.float(), p=2, dim=-1)
259
+
260
+ def encode_text(self, texts: Sequence[str]) -> torch.Tensor:
261
+ """Texts -> (N, 4096). Audio<->text was trained and evaluated on raw labels and
262
+ captions (e.g. "dog", "chirping_birds"), with no instruction prefix."""
263
+ conversations = [[{"role": "user", "content": [{"type": "text", "text": t.strip()}]}] for t in texts]
264
+ return self._encode_wemm(conversations, has_visual=False)
265
+
266
+ def encode_image(self, images: Sequence, texts: Optional[Sequence[str]] = None) -> torch.Tensor:
267
+ """Image paths/URLs/PIL images, each with optional accompanying text -> (N, 4096).
268
+ This is WeMM-Embedding-9B with the vdr LoRA merged; for video, call self.wemm as in WeMM's model card."""
269
+ texts = texts or [""] * len(images)
270
+ conversations = []
271
+ for image, text in zip(images, texts):
272
+ content = [{"type": "image", "image": image, **WEMM_IMAGE_KWARGS}]
273
+ if text.strip():
274
+ content.append({"type": "text", "text": text.strip()})
275
+ conversations.append([{"role": "user", "content": content}])
276
+ return self._encode_wemm(conversations, has_visual=True)
resources/sentio_architecture.png ADDED

Git LFS Details

  • SHA256: 1e91a4e97dc31b5ce7a6bef3ccfeae2da975bff3f99edbbe5625f753347d2075
  • Pointer size: 131 Bytes
  • Size of remote file: 146 kB