VieGem-ASR / asr /model.py
baohuynhbk14's picture
VieGem-ASR: fp16 weights, code, 4-gram KenLM, model card, demo video
3cf1186 verified
Raw History Blame Contribute Delete
10.6 kB
"""EmbeddingGemma 2 audio encoder + CTC reader with a head in Gemma's text-embedding space.
wav -> log-mel -> Gemma audio encoder (12 conformer layers, 25 frames/s, chunked attention)
-> learned softmax mix of the 13 hidden states (subsampler + each layer)
-> reader: LayerNorm + Linear + causal depthwise conv + N pre-norm transformer blocks
-> to_text (d -> 512) @ unit_rows^T + bias, unit rows initialized from Gemma input embeddings
-> CTC
Encoder adaptation: "frozen", "top_k" (unfreeze the top k layers) or "lora" (low-rank adapters on the linears).
Head: "trainable" (rows init from text embeddings and trained), "frozen" (rows fixed),
"residual" (fixed text rows + alpha * learned delta).
Streaming: reader self-attention is restricted to the current chunk and previous chunks; the encoder is already
chunked/left-context only and the reader conv is causal.
"""
import math
from dataclasses import asdict, dataclass, field, fields
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import AutoModel
from .features import LogMel, spec_augment
@dataclass
class ModelConfig:
encoder_id: str = "google/embeddinggemma-2"
vocab_size: int = 0 # incl. blank and unk
d_model: int = 768
n_layers: int = 6
n_heads: int = 12
ff_mult: int = 4
conv_kernel: int = 15
dropout: float = 0.1
layer_mix: bool = True
encoder_mode: str = "top_k" # frozen | top_k | lora
unfreeze_top_k: int = 2
lora_r: int = 16
lora_alpha: float = 32.0
lora_targets: list = field(default_factory=lambda: ["q_proj", "k_proj", "v_proj", "post", "ffw_layer_1", "ffw_layer_2"])
head_mode: str = "trainable" # trainable | frozen | residual
residual_alpha: float = 1.0
head_cosine: bool = True # cosine similarity with learned scale; False = plain dot product
chunk_frames: int = 12 # 0.48 s
dynamic_chunk: bool = True
max_chunk_units: int = 4
max_left_chunks: int = 8
specaug: dict = field(default_factory=lambda: dict(freq_masks=2, freq_width=27, time_masks=10, time_width_frac=0.04))
@classmethod
def from_dict(cls, d: dict) -> "ModelConfig":
known = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in d.items() if k in known})
def to_dict(self) -> dict:
return asdict(self)
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, r: int, alpha: float):
super().__init__()
self.base = base
self.lora_a = nn.Parameter(torch.empty(r, base.in_features))
self.lora_b = nn.Parameter(torch.zeros(base.out_features, r))
nn.init.kaiming_uniform_(self.lora_a, a=math.sqrt(5))
self.scale = alpha / r
def forward(self, x):
return self.base(x) + (x @ self.lora_a.t().to(x.dtype)) @ self.lora_b.t().to(x.dtype) * self.scale
def load_encoder_and_text_embeddings(encoder_id: str):
full = AutoModel.from_pretrained(encoder_id, dtype=torch.bfloat16, vision_config=None)
encoder = full.audio_tower.float()
text_emb = full.language_model.embed_tokens.weight.detach().float().clone() # (262144, 512)
del full
return encoder, text_emb
class TransformerBlock(nn.Module):
def __init__(self, d, n_heads, ff_mult, dropout):
super().__init__()
self.n_heads = n_heads
self.norm1 = nn.LayerNorm(d)
self.qkv = nn.Linear(d, 3 * d)
self.proj = nn.Linear(d, d)
self.norm2 = nn.LayerNorm(d)
self.ff = nn.Sequential(nn.Linear(d, ff_mult * d), nn.GELU(), nn.Dropout(dropout), nn.Linear(ff_mult * d, d))
self.drop = nn.Dropout(dropout)
self.attn_dropout = dropout
def forward(self, x, attn_mask):
B, T, D = x.shape
q, k, v = self.qkv(self.norm1(x)).view(B, T, 3, self.n_heads, D // self.n_heads).permute(2, 0, 3, 1, 4)
a = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask,
dropout_p=self.attn_dropout if self.training else 0.0)
x = x + self.drop(self.proj(a.transpose(1, 2).reshape(B, T, D)))
return x + self.drop(self.ff(self.norm2(x)))
class CTCModel(nn.Module):
def __init__(self, cfg: ModelConfig, gemma_ids: list[int], encoder=None, text_emb=None):
super().__init__()
assert cfg.vocab_size == len(gemma_ids) + 2
self.cfg = cfg
if encoder is None:
encoder, text_emb = load_encoder_and_text_embeddings(cfg.encoder_id)
self.mel = LogMel(cfg.encoder_id)
self.encoder = encoder
n_enc = encoder.config.num_hidden_layers
enc_dim = encoder.config.hidden_size
self._setup_encoder_training(n_enc)
# Collect subsampler output + every conformer layer output (output_hidden_states would give the
# 1536-d output_proj instead of the last layer's raw output).
self._acts = []
grab = lambda mod, inp, out: self._acts.append(out[0] if isinstance(out, tuple) else out) # noqa: E731
self.encoder.subsample_conv_projection.register_forward_hook(grab)
for layer in self.encoder.layers:
layer.register_forward_hook(grab)
d = cfg.d_model
self.layer_logits = nn.Parameter(torch.zeros(n_enc + 1)) if cfg.layer_mix else None
self.in_norm = nn.LayerNorm(enc_dim)
self.in_proj = nn.Linear(enc_dim, d)
self.pos_conv = nn.Conv1d(d, d, cfg.conv_kernel, groups=d) # causal (left padded)
self.blocks = nn.ModuleList([TransformerBlock(d, cfg.n_heads, cfg.ff_mult, cfg.dropout) for _ in range(cfg.n_layers)])
self.out_norm = nn.LayerNorm(d)
rows = text_emb[torch.tensor(gemma_ids)]
rows = rows / rows.norm(dim=-1, keepdim=True).mean() # put rows on unit average scale
text_dim = rows.shape[1]
self.to_text = nn.Linear(d, text_dim)
if cfg.head_mode == "trainable":
self.unit_rows = nn.Parameter(rows.clone())
elif cfg.head_mode == "frozen":
self.register_buffer("unit_rows", rows.clone())
elif cfg.head_mode == "residual":
self.register_buffer("text_rows", rows.clone())
self.delta_rows = nn.Parameter(torch.zeros_like(rows))
else:
raise ValueError(cfg.head_mode)
self.special_rows = nn.Parameter(torch.randn(2, text_dim) * 0.02) # blank, unk
self.head_bias = nn.Parameter(torch.zeros(cfg.vocab_size))
if cfg.head_cosine:
self.logit_scale = nn.Parameter(torch.tensor(math.log(10.0)))
def _setup_encoder_training(self, n_enc):
cfg = self.cfg
for p in self.encoder.parameters():
p.requires_grad_(False)
if cfg.encoder_mode == "top_k":
for layer in self.encoder.layers[n_enc - cfg.unfreeze_top_k:]:
for p in layer.parameters():
p.requires_grad_(True)
elif cfg.encoder_mode == "lora":
for layer in self.encoder.layers:
for name, mod in list(layer.named_modules()):
if name.split(".")[-1] in cfg.lora_targets and hasattr(mod, "linear"):
mod.linear = LoRALinear(mod.linear, cfg.lora_r, cfg.lora_alpha)
elif cfg.encoder_mode != "frozen":
raise ValueError(cfg.encoder_mode)
def head_rows(self):
if self.cfg.head_mode == "residual":
rows = self.text_rows + self.cfg.residual_alpha * self.delta_rows
else:
rows = self.unit_rows
return torch.cat([self.special_rows, rows], dim=0)
@staticmethod
def chunk_mask(T, chunk, left_chunks, device):
c = torch.arange(T, device=device) // chunk
allow = c[None, :] <= c[:, None]
if left_chunks is not None:
allow &= c[None, :] >= c[:, None] - left_chunks
return allow
def forward(self, wav, wav_lens, streaming=False, left_chunks=None):
"""Returns float32 logits (B, T, V) and output lengths (B,)."""
feats, fmask = self.mel(wav, wav_lens)
if self.training and self.cfg.specaug:
feats = spec_augment(feats, fmask, **self.cfg.specaug)
self._acts.clear()
out = self.encoder(feats, fmask, return_dict=True)
acts, self._acts = self._acts, []
mask = out.attention_mask
if self.cfg.layer_mix:
w = torch.softmax(self.layer_logits.float(), dim=0)
h = sum(w[i] * a.float() for i, a in enumerate(acts))
else:
h = acts[-1].float()
x = self.in_proj(self.in_norm(h))
xc = F.pad((x * mask[..., None]).transpose(1, 2), (self.cfg.conv_kernel - 1, 0))
x = x + F.gelu(self.pos_conv(xc).transpose(1, 2))
T = x.shape[1]
attn_mask = mask[:, None, None, :]
chunk = None
if streaming:
chunk = self.cfg.chunk_frames
elif self.training and self.cfg.dynamic_chunk and torch.rand(()) < 0.5:
chunk = self.cfg.chunk_frames * int(torch.randint(1, self.cfg.max_chunk_units + 1, ()))
left_chunks = None if torch.rand(()) < 0.5 else int(torch.randint(0, self.cfg.max_left_chunks + 1, ()))
if chunk is not None:
attn_mask = attn_mask & self.chunk_mask(T, chunk, left_chunks, x.device)[None, None]
# Rows that are fully padded would attend to nothing -> NaN; let them see themselves.
attn_mask = attn_mask | torch.eye(T, dtype=torch.bool, device=x.device)[None, None]
for blk in self.blocks:
x = blk(x, attn_mask)
t = self.to_text(self.out_norm(x)).float()
rows = self.head_rows().float()
if self.cfg.head_cosine:
logits = self.logit_scale.exp() * (F.normalize(t, dim=-1) @ F.normalize(rows, dim=-1).t())
else:
logits = t @ rows.t()
logits = logits + self.head_bias.float()
return logits, mask.sum(1)
def trainable_state_dict(self):
"""Only what is not recoverable from the base encoder: trained params + LoRA + head buffers."""
keep = {n for n, p in self.named_parameters() if p.requires_grad}
sd = self.state_dict()
return {k: v for k, v in sd.items()
if k in keep or not k.startswith("encoder.") or "lora_" in k}
def greedy_decode(logits, lens, blank=0):
best = logits.argmax(-1).cpu()
out = []
for b in range(best.shape[0]):
seq = best[b, : int(lens[b])]
keep = torch.ones_like(seq, dtype=torch.bool)
keep[1:] = seq[1:] != seq[:-1]
seq = seq[keep]
out.append(seq[seq != blank].tolist())
return out