Views
No views yet
negative / neutral / positive) in one pass, using the acoustic signal
(prosody, tone, energy) that a text-only sentiment pipeline throws away.ivrit-ai/whisper-large-v3
with a lightweight attention-pooling sentiment head tapped off the encoder.
The transcription path is the original Whisper, unchanged.log-mel ─► Whisper encoder ──┬──► Whisper ASR decoder (UNTOUCHED, frozen)
│ └─► transcript
│
└──► [tapped edge: encoder hidden states]
└─► SentimentHead (the only trained part)
└─► softmax(neg / neutral / pos)ivrit-ai/whisper-large-v3 (encoder + ASR decoder, untouched).full_senti_whisper.pt: Whisper + head).
The head is a custom class, so include it when loading:1import torch, torch.nn as nn, librosa
2from transformers import WhisperForConditionalGeneration, WhisperProcessor
3
4LABELS = ["negative", "neutral", "positive"]
5BASE = "ivrit-ai/whisper-large-v3"
6ENC_FPS, SR = 50, 16_000
7
8class SentimentHead(nn.Module):
9 def __init__(self, d_model, num_classes=3, hidden_dim=None, dropout=0.1):
10 super().__init__()
11 hidden_dim = hidden_dim or d_model // 2
12 self.attn = nn.Linear(d_model, 1)
13 self.mlp = nn.Sequential(
14 nn.LayerNorm(d_model), nn.Dropout(dropout),
15 nn.Linear(d_model, hidden_dim), nn.GELU(), nn.Dropout(dropout),
16 nn.Linear(hidden_dim, num_classes))
17 def forward(self, x, mask):
18 s = self.attn(x).masked_fill(mask.unsqueeze(-1) == 0, float("-inf"))
19 return self.mlp((torch.softmax(s, 1) * x).sum(1))
20
21# --- load base + head from the combined checkpoint ---
22device = "cuda" if torch.cuda.is_available() else "cpu"
23processor = WhisperProcessor.from_pretrained(BASE)
24whisper = WhisperForConditionalGeneration.from_pretrained(BASE).to(device).eval()
25head = SentimentHead(whisper.config.d_model).to(device).eval()
26
27state = torch.load("full_senti_whisper.pt", map_location=device)
28# keys are prefixed "whisper." / "sentiment_head." (adjust if you saved differently)
29whisper.load_state_dict({k[len("whisper."):]: v for k, v in state.items()
30 if k.startswith("whisper.")}, strict=False)
31head.load_state_dict({k[len("sentiment_head."):]: v for k, v in state.items()
32 if k.startswith("sentiment_head.")})
33
34@torch.no_grad()
35def predict(path):
36 audio, _ = librosa.load(path, sr=SR)
37 feats = processor.feature_extractor(audio, sampling_rate=SR,
38 return_tensors="pt").input_features.to(device)
39 hs = whisper.model.encoder(feats).last_hidden_state # [1, 1500, D]
40 n = max(1, min(hs.shape[1], int(len(audio) / SR * ENC_FPS)))
41 mask = torch.zeros(1, hs.shape[1], dtype=torch.long, device=device); mask[:, :n] = 1
42 sentiment = LABELS[head(hs, mask).argmax(-1).item()]
43 transcript = processor.batch_decode(whisper.generate(feats),
44 skip_special_tokens=True)[0]
45 return {"transcript": transcript, "sentiment": sentiment}
46
47print(predict("example.wav"))| → negative | → neutral | → positive | dropped (ambiguous) |
|---|---|---|---|
| angry, sad, fearful, disgust | neutral, calm | happy | surprised |
no_grad); its hidden states are cached, then the small head is trained
on the cache — so training is fast and never touches ASR weights (no WER regression).2e-4, weight decay 1e-2, cosine schedule,
~15 epochs, batch 32, fp16. Best checkpoint by validation macro-F1.⚠️ Fill these in from your run — placeholders, not measured values.
| split | macro-F1 | accuracy |
|---|---|---|
| validation | TBD | TBD |
| test (held-out) | TBD | TBD |
classification_report / confusion_matrix cells in the training notebook.
For KS-2959 the key comparison is macro-F1 vs. a text-only cascade on a
sarcasm/ambiguous-tone subset — the test that justifies using acoustics at all.ivrit-ai/whisper-large-v3.