Sentio model card, config and modeling code
Browse files- .gitattributes +1 -0
- README.md +210 -0
- __pycache__/modeling_sentio.cpython-312.pyc +0 -0
- config.json +18 -0
- modeling_sentio.py +276 -0
- resources/sentio_architecture.png +3 -0
.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 |
+

|
| 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
|