Explaining a spoken sentence with an audio language model¶
Goal: let an audio language model hear a recording, then attribute its answer to the spoken words.
Why it matters: a spoken instruction is only auditable when you can show which words the model relied on; masking the audio itself, not just the transcript, tests what the model heard.
Prerequisites: uv sync --extra audio --extra transformers. The first run downloads ibm-granite/granite-speech-3.3-2b (about 5.9 GB) and facebook/wav2vec2-base-960h (about 360 MB). The clip voices_sp0307.wav is from the VOiCES corpus (CC BY 4.0). A GPU or Apple MPS keeps the run to minutes.
Output: the model’s transcript of the clip, one attribution value per spoken word, and a faithfulness check of those values.
from pathlib import Path
from IPython.display import Audio, display
from huggingface_hub.utils import disable_progress_bars
from transformers.utils.logging import disable_progress_bar
from mllm_shap import (
AudioPart,
ConversationSnapshot,
ExplainConfig,
ExplainRunner,
LmStudioBackend,
Message,
Role,
TextPart,
TransformersTextBackend,
Turn,
deletion_curve,
)
from mllm_shap.alignment import decode_audio
from mllm_shap.plugins import PluginContext
# Progress bars render as widgets that plain viewers (VS Code, GitHub) cannot show.
disable_progress_bars()
disable_progress_bar()
Hear the clip¶
A checkpoint that AutoModelForCausalLM does not know loads with its processor, so the transformers backend takes audio parts. Granite Speech’s chat template has no audio slot: the backend puts its <|audio|> token before the message text and passes the waveform, resampled to 16 kHz. A text system message comes first and the audio message after it, so the model gets both modalities; the system message stays in every coalition and is no player.
MODEL_ID = "ibm-granite/granite-speech-3.3-2b"
REVISION = "4ac2f02f413c6169ae8c0ccc217115a366e552d7"
TRANSCRIPT = "I had that curiosity beside me at this moment"
SYSTEM = (
"You are a transcription assistant. Write only the words you hear, on one line. "
'If you hear no speech, say "No speech." Never add comments, never invent words '
"and never ask a question."
)
audio = AudioPart(Path("voices_sp0307.wav"), sample_rate=16000)
settings = {
"backend": {
"kind": "transformers",
"model_id": MODEL_ID,
# Granite's projector fails under the default attention kernel on Apple MPS.
"options": {"revision": REVISION, "dtype": "bfloat16", "attn_implementation": "eager"},
},
"generation": {"max_new_tokens": 16},
"explanation": {
"estimator": "complementary",
"budget": {"fraction": 0.125},
"players": "sgpa_segments",
"absence_policy": "delete",
"archive": {"path": ""},
"plugin_options": {"sgpa_segments": {
"model_id": "facebook/wav2vec2-base-960h",
"revision": "22aad52d435eb6dbaf354bdad9b0da84ce7d6156",
}},
},
}
config = ExplainConfig.model_validate(settings)
backend = TransformersTextBackend(PluginContext(config=config))
ask = Message(
role=Role.USER,
content=(audio, TextPart("can you transcribe the speech into a written format?")),
)
heard = await backend.generate(
ConversationSnapshot.from_turns((
Turn(messages=(Message(role=Role.SYSTEM, text=SYSTEM), ask)),
)),
config.generation.model_copy(update={"max_new_tokens": 48}),
)
print(heard.text)
i had that curiosity beside me at this moment
[transformers] Ignoring clean_up_tokenization_spaces=True for BPE tokenizer GPT2Tokenizer. The clean_up_tokenization post-processing step is designed for WordPiece tokenizers and is destructive for BPE (it strips spaces before punctuation). Set clean_up_tokenization_spaces=False to suppress this warning, or set clean_up_tokenization_spaces_for_bpe_even_though_it_will_corrupt_output=True to force cleanup anyway.
Attribute the answer to spoken words¶
With players: sgpa_segments each spoken word of the transcript is a player with its stretch of audio. delete cuts an absent word from both the transcript and the waveform, so every coalition prompt is a shorter recording. Nine words have 512 coalitions; the complementary estimator samples 64 of them (about 70 s on an Apple M4 (16 GB)), so the values approximate Shapley values and their sum is close to, not equal to, the full answer’s score minus the empty one’s.
spoken = Message(role=Role.USER, content=(TextPart(TRANSCRIPT), audio))
snapshot = ConversationSnapshot.from_turns((
Turn(messages=(Message(role=Role.SYSTEM, text=SYSTEM), spoken)),
))
output = await ExplainRunner(config, backend=backend).explain(snapshot)
result = output.result
for span, value in zip(output.player_set.spans, result.values, strict=True):
print(f"{span.text:10} {value:+.4f}")
print("efficiency gap:", round(sum(result.values) - (result.grand_value - result.base_value), 3))
I -0.0091
had +0.1693
that +0.1104
curiosity +0.1879
beside +0.1434
me +0.1502
at +0.0754
this +0.2171
moment +0.1186
efficiency gap: 0.163
Listen to the cut words¶
Human validation: a value is only meaningful if each player is the word it names. Play the full clip, then each word’s stretch of audio as SGPA cut it; a clipped or shifted word means the attribution belongs to another sound.
waveform, rate = decode_audio(audio.data(), 16000)
display(Audio(waveform, rate=rate))
for span, value in zip(output.player_set.spans, result.values, strict=True):
start, end = span.samples
print(f"{span.text:10} {start / rate:5.2f}-{end / rate:5.2f} s {value:+.4f}")
display(Audio(waveform[start:end], rate=rate))
I 0.52- 0.62 s -0.0091
had 0.62- 0.88 s +0.1693
that 0.88- 1.08 s +0.1104
curiosity 1.08- 1.85 s +0.1879
beside 1.85- 2.27 s +0.1434
me 2.27- 2.51 s +0.1502
at 2.51- 2.58 s +0.0754
this 2.58- 2.78 s +0.2171
moment 2.78- 3.18 s +0.1186
What the model hears, not what it reads¶
The run above sends the masked transcript next to the masked audio, so a word can matter because the model reads it. With transcript: align_only the transcript only builds the players and every coalition sends the audio alone.
settings["explanation"]["plugin_options"]["sgpa_segments"]["transcript"] = "align_only"
heard_only = await ExplainRunner(ExplainConfig.model_validate(settings), backend=backend).explain(snapshot)
for span, read, heard_value in zip(
output.player_set.spans, result.values, heard_only.result.values, strict=True
):
print(f"{span.text:10} send {read:+.4f} align_only {heard_value:+.4f}")
I send -0.0091 align_only +0.0169
had send +0.1693 align_only +0.2137
that send +0.1104 align_only +0.0989
curiosity send +0.1879 align_only +0.1739
beside send +0.1434 align_only +0.0736
me send +0.1502 align_only +0.1939
at send +0.0754 align_only +0.0509
this send +0.2171 align_only +0.2317
moment send +0.1186 align_only +0.1241
Is the attribution faithful?¶
A deletion curve removes the top-ranked words together, for k = 0 to 9, and rescores what is left. It is scored with reference_wer against the true transcript, a utility other than the explanation’s tfidf_cosine, so the check is not circular. Leave-one-out (loo, 11 generations) is the baseline; a lower area means the ranking finds the words the answer depends on.
wer_settings = {**settings, "explanation": {
**settings["explanation"],
"value_fn": "reference_wer",
"plugin_options": {**settings["explanation"]["plugin_options"],
"reference_wer": {"reference": TRANSCRIPT}},
}}
wer_config = ExplainConfig.model_validate(wer_settings)
loo_settings = {**settings, "explanation": {**settings["explanation"], "estimator": "loo", "budget": {"evaluations": 11}}}
loo = await ExplainRunner(ExplainConfig.model_validate(loo_settings), backend=backend).explain(snapshot)
for name, values in (("shapley", result.values), ("loo", loo.result.values)):
curve = await deletion_curve(wer_config, snapshot, values, backend=backend)
print(f"{name:8} area {curve.area:.3f} values {[round(v, 2) for v in curve.values]}")
shapley area 0.389 values [1.0, 0.89, 0.78, 0.56, 0.33, 0.22, 0.11, 0.11, 0.0, 0.0]
loo area 0.352 values [1.0, 0.67, 0.67, 0.44, 0.33, 0.22, 0.22, 0.11, 0.0, 0.0]
LM Studio and HTTP servers¶
The LM Studio SDK takes no audio, so the lmstudio backend refuses audio parts unless backend.options.audio is transcript, which sends the text only. The refusal comes before any server call. The api backend sends audio as OpenAI input_audio parts to servers that accept them.
lms_config = ExplainConfig.model_validate({"backend": {"kind": "lmstudio", "model_id": "any"}})
try:
await LmStudioBackend(PluginContext(config=lms_config)).generate(snapshot, config.generation)
except ValueError as error:
print(error)
audio snapshots are not supported by the LM Studio backend (its SDK takes no audio input); set backend.options audio: transcript to score the text parts only