Buckets:
| """ | |
| src/model.py | |
| Non-Autoregressive On-Device CTC Reader Architecture for EmbeddingGemma-2. | |
| Maps frozen 300M Audio Encoder representations (MRL 256-dim) to the shared multimodal | |
| vector space, matching fixed text embeddings via cosine similarity projection. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import random | |
| from typing import Optional, Tuple, Dict, Any | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| # Standard PyTorch Execution Mode | |
| MetalFusedConvMidFunction = None | |
| MetalFusedQKVLinearFunction = None | |
| MetalFusedResL2NormFunction = None | |
| # ============================================================================== | |
| # 1. Conformer / Transformer Building Blocks (Mobile-Friendly Standard Ops) | |
| # ============================================================================== | |
| class DepthwiseConv1d(nn.Module): | |
| """ | |
| Depthwise separable 1D convolution block with GLU and BatchNorm, | |
| providing temporal context modeling without heavy parameter overhead. | |
| Designed with standard PyTorch ops for seamless CoreML / ONNX export. | |
| """ | |
| def __init__(self, channels: int, kernel_size: int = 15, dropout: float = 0.1): | |
| super().__init__() | |
| assert kernel_size % 2 == 1, "Kernel size must be odd for same-padding" | |
| self.padding = kernel_size // 2 | |
| self.pointwise_in = nn.Conv1d(channels, channels * 2, kernel_size=1) | |
| self.act_glu = nn.GLU(dim=1) | |
| self.depthwise = nn.Conv1d( | |
| channels, channels, | |
| kernel_size=kernel_size, | |
| padding=self.padding, | |
| groups=channels, | |
| bias=False | |
| ) | |
| self.bn = nn.BatchNorm1d(channels) | |
| self.act_swish = nn.SiLU() | |
| self.pointwise_out = nn.Conv1d(channels, channels, kernel_size=1) | |
| self.dropout = nn.Dropout(dropout) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| # Fast-path on Apple Silicon MPS with bfloat16: F.linear pointwise + Fused Metal GLU + DWConv15 + BN + SiLU | |
| if ( | |
| x.device.type == "mps" | |
| and x.dtype == torch.bfloat16 | |
| and self.depthwise.kernel_size[0] == 15 | |
| and MetalFusedConvMidFunction is not None | |
| and not torch.jit.is_tracing() | |
| and not torch.onnx.is_in_onnx_export() | |
| ): | |
| res = x | |
| u = F.linear(x, self.pointwise_in.weight.squeeze(-1), self.pointwise_in.bias) | |
| y = MetalFusedConvMidFunction.apply( | |
| u, | |
| self.depthwise.weight, | |
| self.bn.weight, | |
| self.bn.bias, | |
| self.bn.running_mean, | |
| self.bn.running_var, | |
| self.training | |
| ) | |
| y = F.linear(y, self.pointwise_out.weight.squeeze(-1), self.pointwise_out.bias) | |
| return res + self.dropout(y) | |
| # Standard mobile-exportable path: (Batch, Time, Channels) -> transpose to (Batch, Channels, Time) | |
| res = x | |
| x = x.transpose(1, 2) | |
| x = self.pointwise_in(x) | |
| x = self.act_glu(x) | |
| x = self.depthwise(x) | |
| x = self.bn(x) | |
| x = self.act_swish(x) | |
| x = self.pointwise_out(x) | |
| x = self.dropout(x) | |
| x = x.transpose(1, 2) | |
| return res + x | |
| class ExplicitMultiHeadAttention(nn.Module): | |
| """ | |
| Explicit Multi-Head Attention implemented using standard tensor operations | |
| (MatMul, Scale, Softmax, MatMul) without proprietary PyTorch C++ kernels. | |
| Guarantees 100% compatibility with ONNX export (opset 17) and CoreML conversion. | |
| """ | |
| def __init__(self, embed_dim: int, num_heads: int, dropout: float = 0.0): | |
| super().__init__() | |
| assert embed_dim % num_heads == 0, "embed_dim must be divisible by num_heads" | |
| self.embed_dim = embed_dim | |
| self.num_heads = num_heads | |
| self.head_dim = embed_dim // num_heads | |
| self.scale = 1.0 / math.sqrt(self.head_dim) | |
| self.q_proj = nn.Linear(embed_dim, embed_dim) | |
| self.k_proj = nn.Linear(embed_dim, embed_dim) | |
| self.v_proj = nn.Linear(embed_dim, embed_dim) | |
| self.out_proj = nn.Linear(embed_dim, embed_dim) | |
| self.dropout = nn.Dropout(dropout) | |
| def forward( | |
| self, | |
| q: torch.Tensor, | |
| k: torch.Tensor, | |
| v: torch.Tensor, | |
| key_padding_mask: Optional[torch.Tensor] = None, | |
| attn_bias: Optional[torch.Tensor] = None | |
| ) -> torch.Tensor: | |
| b, t_q, _ = q.size() | |
| _, t_k, _ = k.size() | |
| # Fused QKV self-attention fast-path on MPS bfloat16 | |
| if ( | |
| q is k | |
| and k is v | |
| and q.device.type == "mps" | |
| and not torch.jit.is_tracing() | |
| and not torch.onnx.is_in_onnx_export() | |
| ): | |
| qkv_w_view = getattr(self, "_qkv_w_view", None) | |
| if qkv_w_view is not None and MetalFusedQKVLinearFunction is not None: | |
| if torch.is_grad_enabled() and q.requires_grad: | |
| qkv_lin = MetalFusedQKVLinearFunction.apply( | |
| q, qkv_w_view, self._qkv_b_view, self._qkv_w_grad_view, self._qkv_b_grad_view | |
| ) | |
| else: | |
| qkv_lin = F.linear(q, qkv_w_view, self._qkv_b_view) | |
| else: | |
| w_qkv = torch.cat([self.q_proj.weight, self.k_proj.weight, self.v_proj.weight], dim=0) | |
| b_qkv = torch.cat([self.q_proj.bias, self.k_proj.bias, self.v_proj.bias], dim=0) | |
| qkv_lin = F.linear(q, w_qkv, b_qkv) | |
| qkv = qkv_lin.view(b, t_q, 3, self.num_heads, self.head_dim).permute(2, 0, 3, 1, 4) | |
| q_proj, k_proj, v_proj = qkv[0], qkv[1], qkv[2] | |
| scores = torch.matmul(q_proj, k_proj.transpose(-2, -1)) * self.scale | |
| if attn_bias is not None: | |
| scores = scores + attn_bias | |
| elif key_padding_mask is not None: | |
| scores = scores.masked_fill(key_padding_mask[:, None, None, :], -10000.0) | |
| probs = F.softmax(scores.float(), dim=-1).to(dtype=q.dtype) | |
| if self.training and self.dropout.p > 0.0: | |
| probs = F.dropout(probs, p=self.dropout.p, training=True) | |
| context = torch.matmul(probs, v_proj).transpose(1, 2).reshape(b, t_q, self.embed_dim) | |
| return self.out_proj(context) | |
| q_proj = self.q_proj(q).view(b, t_q, self.num_heads, self.head_dim).transpose(1, 2) | |
| k_proj = self.k_proj(k).view(b, t_k, self.num_heads, self.head_dim).transpose(1, 2) | |
| v_proj = self.v_proj(v).view(b, t_k, self.num_heads, self.head_dim).transpose(1, 2) | |
| # Scaled dot-product attention using MPS-native fused SDPA kernel | |
| attn_mask = None | |
| if attn_bias is not None: | |
| attn_mask = attn_bias | |
| elif key_padding_mask is not None: | |
| # key_padding_mask: True for pad positions (b, t_k) | |
| # In SDPA boolean mask: True means attend (valid), False means mask out (pad) | |
| attn_mask = (~key_padding_mask).unsqueeze(1).unsqueeze(2) | |
| dropout_p = self.dropout.p if self.training else 0.0 | |
| context = F.scaled_dot_product_attention( | |
| q_proj, k_proj, v_proj, | |
| attn_mask=attn_mask, | |
| dropout_p=dropout_p | |
| ) # (b, heads, t_q, head_dim) | |
| context = context.transpose(1, 2).contiguous().view(b, t_q, self.embed_dim) | |
| return self.out_proj(context) | |
| class ConformerBlock(nn.Module): | |
| """ | |
| A lightweight Conformer block combining: | |
| - Pre-norm Multi-Head Self-Attention (MHSA) | |
| - Depthwise Convolution for local speech features | |
| - Feed-Forward Network (FFN) with half-step Macaron-style scaling | |
| """ | |
| def __init__( | |
| self, | |
| d_model: int = 256, | |
| nhead: int = 4, | |
| dim_feedforward: int = 1024, | |
| dropout: float = 0.1, | |
| conv_kernel_size: int = 15 | |
| ): | |
| super().__init__() | |
| # 1. First FFN (Macaron style) | |
| self.ffn1_norm = nn.LayerNorm(d_model) | |
| self.ffn1 = nn.Sequential( | |
| nn.Linear(d_model, dim_feedforward), | |
| nn.GELU(), | |
| nn.Dropout(dropout), | |
| nn.Linear(dim_feedforward, d_model), | |
| nn.Dropout(dropout) | |
| ) | |
| # 2. Multi-Head Self-Attention (Explicit ops for mobile deployment) | |
| self.attn_norm = nn.LayerNorm(d_model) | |
| self.self_attn = ExplicitMultiHeadAttention( | |
| embed_dim=d_model, | |
| num_heads=nhead, | |
| dropout=dropout | |
| ) | |
| self.attn_dropout = nn.Dropout(dropout) | |
| # 3. Convolution Module | |
| self.conv_norm = nn.LayerNorm(d_model) | |
| self.conv_module = DepthwiseConv1d( | |
| channels=d_model, | |
| kernel_size=conv_kernel_size, | |
| dropout=dropout | |
| ) | |
| # 4. Second FFN | |
| self.ffn2_norm = nn.LayerNorm(d_model) | |
| self.ffn2 = nn.Sequential( | |
| nn.Linear(d_model, dim_feedforward), | |
| nn.GELU(), | |
| nn.Dropout(dropout), | |
| nn.Linear(dim_feedforward, d_model), | |
| nn.Dropout(dropout) | |
| ) | |
| self.final_norm = nn.LayerNorm(d_model) | |
| def forward( | |
| self, | |
| x: torch.Tensor, | |
| key_padding_mask: Optional[torch.Tensor] = None, | |
| attn_bias: Optional[torch.Tensor] = None | |
| ) -> torch.Tensor: | |
| # FFN 1 with 0.5 residual scale | |
| x = x + 0.5 * self.ffn1(self.ffn1_norm(x)) | |
| # Multi-Head Attention | |
| normed = self.attn_norm(x) | |
| attn_out = self.self_attn( | |
| normed, normed, normed, | |
| key_padding_mask=key_padding_mask, | |
| attn_bias=attn_bias | |
| ) | |
| x = x + self.attn_dropout(attn_out) | |
| # Convolution Module | |
| x = x + self.conv_module(self.conv_norm(x)) | |
| # FFN 2 with 0.5 residual scale | |
| x = x + 0.5 * self.ffn2(self.ffn2_norm(x)) | |
| return self.final_norm(x) | |
| # ============================================================================== | |
| # 2. Audio-to-Vector Reader Module | |
| # ============================================================================== | |
| class AudioToVectorReader(nn.Module): | |
| """ | |
| Reader v2 Conformer Architecture (~21.5M parameters). | |
| Input: Audio Encoder features of shape (Batch, Time, input_dim=256) | |
| Internal: Conformer latent space (d_model=384, nhead=6, num_layers=10, dim_feedforward=1536) | |
| Output: Normalized vectors of shape (Batch, Time, output_dim=256) aligned with EmbeddingGemma-2 text space. | |
| """ | |
| def __init__( | |
| self, | |
| input_dim: int = 256, | |
| output_dim: int = 256, | |
| d_model: int = 384, | |
| nhead: int = 6, | |
| num_layers: int = 10, | |
| dim_feedforward: int = 1536, | |
| dropout: float = 0.1, | |
| conv_kernel_size: int = 15 | |
| ): | |
| super().__init__() | |
| self.input_dim = input_dim | |
| self.output_dim = output_dim | |
| self.d_model = d_model | |
| self.input_proj = nn.Linear(input_dim, d_model) | |
| self.pos_emb = PositionalEncoding(d_model, max_len=8192) | |
| self.layers = nn.ModuleList([ | |
| ConformerBlock( | |
| d_model=d_model, | |
| nhead=nhead, | |
| dim_feedforward=dim_feedforward, | |
| dropout=dropout, | |
| conv_kernel_size=conv_kernel_size | |
| ) | |
| for _ in range(num_layers) | |
| ]) | |
| self.output_proj = nn.Sequential( | |
| nn.Linear(d_model, output_dim), | |
| nn.LayerNorm(output_dim) | |
| ) | |
| # Small std=0.02 normal initialization to enable smooth early gradient flow | |
| nn.init.normal_(self.output_proj[0].weight, std=0.02) | |
| nn.init.zeros_(self.output_proj[0].bias) | |
| # Learnable global residual scale to balance acoustic adaptation and pretrained alignment | |
| self.res_scale = nn.Parameter(torch.tensor(0.5, dtype=torch.float32)) | |
| def forward( | |
| self, | |
| x: torch.Tensor, | |
| key_padding_mask: Optional[torch.Tensor] = None | |
| ) -> torch.Tensor: | |
| """ | |
| Args: | |
| x: Tensor of shape (Batch, Time, input_dim) | |
| key_padding_mask: Optional bool tensor (Batch, Time) where True denotes padded positions | |
| Returns: | |
| L2-normalized vectors (Batch, Time, output_dim) | |
| """ | |
| res = x | |
| h = self.input_proj(x) | |
| h = self.pos_emb(h) | |
| # Precompute additive attention mask once across all Conformer layers | |
| attn_bias: Optional[torch.Tensor] = None | |
| if key_padding_mask is not None: | |
| attn_bias = key_padding_mask[:, None, None, :].to(dtype=h.dtype) * -10000.0 | |
| for layer in self.layers: | |
| h = layer(h, attn_bias=attn_bias) | |
| out = self.output_proj(h) | |
| # Fused Metal Residual Scale + Mask + FP32-Accumulated L2 Normalization fast-path | |
| if ( | |
| out.device.type == "mps" | |
| and out.dtype == torch.bfloat16 | |
| and res.dtype == torch.bfloat16 | |
| and res.shape[-1] == out.shape[-1] | |
| and out.shape[-1] == 256 | |
| and key_padding_mask is not None | |
| and MetalFusedResL2NormFunction is not None | |
| and not torch.jit.is_tracing() | |
| and not torch.onnx.is_in_onnx_export() | |
| ): | |
| return MetalFusedResL2NormFunction.apply(out, res, self.res_scale, key_padding_mask) | |
| # Global residual connection with learnable scale parameter | |
| if res.shape[-1] == out.shape[-1]: | |
| combined = self.res_scale * res + out | |
| else: | |
| combined = out | |
| # Zero out padding positions before normalization if key_padding_mask provided | |
| if key_padding_mask is not None: | |
| combined = combined.masked_fill(key_padding_mask.unsqueeze(-1), 0.0) | |
| # Use FP32 accumulation + safe eps=1e-5 to guarantee zero underflow/NaN in bfloat16 | |
| normed = F.normalize(combined.float(), p=2, dim=-1, eps=1e-5).to(dtype=x.dtype) | |
| if key_padding_mask is not None: | |
| normed = normed.masked_fill(key_padding_mask.unsqueeze(-1), 0.0) | |
| return normed | |
| class PositionalEncoding(nn.Module): | |
| """Sinusoidal Positional Encoding compatible with dynamic sequence lengths.""" | |
| def __init__(self, d_model: int, max_len: int = 8192): | |
| super().__init__() | |
| pe = torch.zeros(max_len, d_model) | |
| position = torch.arange(0, max_len, dtype=torch.float32).unsqueeze(1) | |
| div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) | |
| pe[:, 0::2] = torch.sin(position * div_term) | |
| pe[:, 1::2] = torch.cos(position * div_term) | |
| self.register_buffer("pe", pe.unsqueeze(0), persistent=False) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| seq_len = x.size(1) | |
| return x + self.pe[:, :seq_len].to(dtype=x.dtype, device=x.device) | |
| # ============================================================================== | |
| # 3. Cosine Similarity Matching & Blank Logit Projection Head | |
| # ============================================================================== | |
| class CosineCTCHead(nn.Module): | |
| """ | |
| Unified Cosine Similarity Matching & Blank Head: | |
| - Learns a unit vector in the spherical space for <blank>: self.blank_vec | |
| - Combines [norm_blank, vector_bank] -> all_vectors: (V + 1, embedding_dim) | |
| - Computes single matrix multiplication: (embeddings @ all_vectors.T) * logit_scale | |
| - Applies learnable scalar bias offset to <blank> (index 0) and per-token vocab_bias (index 1..V) | |
| - Supports trainable vector bank mode for end-to-end representation adaptation into acoustic space | |
| """ | |
| def __init__( | |
| self, | |
| embedding_dim: int = 256, | |
| init_temperature: float = 0.05, | |
| vector_bank: Optional[torch.Tensor] = None, | |
| trainable_vector_bank: bool = True | |
| ): | |
| super().__init__() | |
| self.embedding_dim = embedding_dim | |
| self.trainable_vector_bank = trainable_vector_bank | |
| # logit_scale initialized to ln(1 / init_temperature), safely bounded in [-5.0, 5.2983] | |
| init_logit_scale = math.log(1.0 / max(init_temperature, 1e-4)) | |
| init_logit_scale = max(min(init_logit_scale, 5.2983), -5.0) | |
| self.logit_scale = nn.Parameter(torch.tensor(init_logit_scale, dtype=torch.float32)) | |
| # Learnable parameter vector in R^embedding_dim for <blank> token (Index 0) | |
| self.blank_vec = nn.Parameter(torch.randn(1, embedding_dim)) | |
| nn.init.normal_(self.blank_vec, std=0.02) | |
| # Learnable scalar bias for <blank> token (Index 0) | |
| self.blank_bias = nn.Parameter(torch.zeros(1, dtype=torch.float32)) | |
| # Vector bank buffer or parameter (V, embedding_dim) & token prior bias | |
| if vector_bank is not None: | |
| vb_f32 = vector_bank.to(torch.float32) | |
| norm_vb = F.normalize(vb_f32, p=2, dim=-1) | |
| if trainable_vector_bank: | |
| self.vector_bank = nn.Parameter(norm_vb) | |
| else: | |
| self.register_buffer("vector_bank", norm_vb, persistent=False) | |
| self.vocab_bias = nn.Parameter(torch.zeros(norm_vb.size(0), dtype=torch.float32)) | |
| else: | |
| self.register_buffer("vector_bank", None, persistent=False) | |
| self.vocab_bias = nn.Parameter(torch.zeros(0, dtype=torch.float32)) | |
| def set_vector_bank(self, vector_bank: torch.Tensor): | |
| """Sets or updates the target vector bank (V, embedding_dim).""" | |
| vb_f32 = vector_bank.to(torch.float32) | |
| norm_vb = F.normalize(vb_f32, p=2, dim=-1) | |
| if self.trainable_vector_bank: | |
| self.vector_bank = nn.Parameter(norm_vb) | |
| else: | |
| self.register_buffer("vector_bank", norm_vb, persistent=False) | |
| self.vocab_bias = nn.Parameter(torch.zeros(norm_vb.size(0), dtype=torch.float32)) | |
| def forward( | |
| self, | |
| embeddings: torch.Tensor, | |
| external_vector_bank: Optional[torch.Tensor] = None | |
| ) -> torch.Tensor: | |
| """ | |
| Args: | |
| embeddings: Normalized Reader output (Batch, Time, embedding_dim) | |
| external_vector_bank: Optional runtime vector bank (V, embedding_dim) | |
| Returns: | |
| Logits of shape (Batch, Time, V + 1), where index 0 is <blank> | |
| """ | |
| vb = external_vector_bank if external_vector_bank is not None else self.vector_bank | |
| if vb is None: | |
| raise ValueError("Vector bank must be provided either in constructor, set_vector_bank(), or as an argument.") | |
| # On MPS, execute the large (B*T, 256) x (256, V+1) projection strictly in bfloat16 | |
| # (with FP32 L2-normalization and clamped scale) to prevent MPS float32/float16 NaN bugs. | |
| compute_dtype = torch.bfloat16 if embeddings.device.type == "mps" else torch.float32 | |
| emb_f32 = embeddings.to(dtype=torch.float32) | |
| norm_blank = F.normalize(self.blank_vec.to(device=embeddings.device, dtype=torch.float32), p=2, dim=-1, eps=1e-5) | |
| if external_vector_bank is not None or self.trainable_vector_bank: | |
| vb_matched = vb.to(device=embeddings.device, dtype=torch.float32) | |
| norm_vb = F.normalize(vb_matched, p=2, dim=-1, eps=1e-5) | |
| else: | |
| # Frozen vector_bank is already float32 and L2-normalized in __init__ / set_vector_bank | |
| norm_vb = vb if (vb.device == embeddings.device and vb.dtype == torch.float32) else vb.to(device=embeddings.device, dtype=torch.float32) | |
| all_vectors = torch.cat([norm_blank, norm_vb], dim=0).to(dtype=compute_dtype) # (V + 1, embedding_dim) | |
| # Safely clamp logit_scale parameter directly BEFORE exp to prevent math overflow | |
| # ln(200) = ~5.2983. Clamping between -5.0 and 5.2983 bounds scale in [0.0067, 200.0] | |
| clamped_scale_log = torch.clamp(self.logit_scale, min=-5.0, max=5.2983) | |
| scale = torch.exp(clamped_scale_log) | |
| scaled_emb = (emb_f32 * scale).to(dtype=compute_dtype) | |
| # Fused linear projection + per-token prior bias [blank_bias, vocab_bias] | |
| blank_b = self.blank_bias.to(device=embeddings.device, dtype=compute_dtype) | |
| if self.vocab_bias is not None and self.vocab_bias.size(0) == norm_vb.size(0): | |
| vocab_b = self.vocab_bias.to(device=embeddings.device, dtype=compute_dtype) | |
| else: | |
| vocab_b = torch.zeros(norm_vb.size(0), device=embeddings.device, dtype=compute_dtype) | |
| bias = torch.cat([blank_b, vocab_b], dim=0) | |
| return F.linear(scaled_emb, all_vectors, bias) | |
| # ============================================================================== | |
| # 4. SpecAugment for 256-dim Conformer Features | |
| # ============================================================================== | |
| class SpecAugment1D(nn.Module): | |
| """ | |
| Time & Feature Masking for 256-dim feature sequences: | |
| - Time Masking: 2 masks, random length between 5 and 15 frames | |
| - Feature Masking: 2 masks, random length between 16 and 32 dimensions | |
| Applied strictly during model.train() mode. | |
| """ | |
| def __init__( | |
| self, | |
| time_mask_min: int = 5, | |
| time_mask_max: int = 15, | |
| num_time_masks: int = 2, | |
| freq_mask_min: int = 16, | |
| freq_mask_max: int = 32, | |
| num_freq_masks: int = 2 | |
| ): | |
| super().__init__() | |
| self.time_mask_min = time_mask_min | |
| self.time_mask_max = time_mask_max | |
| self.num_time_masks = num_time_masks | |
| self.freq_mask_min = freq_mask_min | |
| self.freq_mask_max = freq_mask_max | |
| self.num_freq_masks = num_freq_masks | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| if not self.training: | |
| return x | |
| B, T, D = x.shape | |
| x_aug = x.clone() | |
| # Time masking (2 times, 5~15 frames) | |
| for _ in range(self.num_time_masks): | |
| t_len = random.randint(self.time_mask_min, min(self.time_mask_max, max(T - 1, self.time_mask_min))) | |
| t_start = random.randint(0, max(0, T - t_len)) | |
| x_aug[:, t_start : t_start + t_len, :] = 0.0 | |
| # Feature masking (2 times, 16~32 dims) | |
| for _ in range(self.num_freq_masks): | |
| f_len = random.randint(self.freq_mask_min, min(self.freq_mask_max, max(D - 1, self.freq_mask_min))) | |
| f_start = random.randint(0, max(0, D - f_len)) | |
| x_aug[:, :, f_start : f_start + f_len] = 0.0 | |
| return x_aug | |
| # ============================================================================== | |
| # 5. Complete Trainable Reader System | |
| # ============================================================================== | |
| class EmbeddingGemmaCTCReader(nn.Module): | |
| """ | |
| Combined Module for training: | |
| AudioToVectorReader + CosineCTCHead with SpecAugment and Trainable Vector Bank options. | |
| """ | |
| def __init__( | |
| self, | |
| input_dim: int = 256, | |
| output_dim: int = 256, | |
| d_model: int = 384, | |
| nhead: int = 6, | |
| num_layers: int = 10, | |
| dim_feedforward: int = 1536, | |
| dropout: float = 0.1, | |
| conv_kernel_size: int = 15, | |
| init_temperature: float = 0.05, | |
| vector_bank: Optional[torch.Tensor] = None, | |
| use_spec_augment: bool = True, | |
| trainable_vector_bank: bool = True | |
| ): | |
| super().__init__() | |
| self.reader = AudioToVectorReader( | |
| input_dim=input_dim, | |
| output_dim=output_dim, | |
| d_model=d_model, | |
| nhead=nhead, | |
| num_layers=num_layers, | |
| dim_feedforward=dim_feedforward, | |
| dropout=dropout, | |
| conv_kernel_size=conv_kernel_size | |
| ) | |
| self.ctc_head = CosineCTCHead( | |
| embedding_dim=output_dim, | |
| init_temperature=init_temperature, | |
| vector_bank=vector_bank, | |
| trainable_vector_bank=trainable_vector_bank | |
| ) | |
| self.spec_aug = SpecAugment1D( | |
| time_mask_min=5, | |
| time_mask_max=15, | |
| num_time_masks=2, | |
| freq_mask_min=16, | |
| freq_mask_max=32, | |
| num_freq_masks=2 | |
| ) if use_spec_augment else None | |
| def enable_mps_bfloat16_fastpath(self) -> None: | |
| """ | |
| Configures Reader Conformer layers in native bfloat16 while preserving float32 | |
| BatchNorm1d parameters/stats, res_scale, and CosineCTCHead parameters. | |
| Eliminates 200+ per-step autocast weight-casting kernels on Apple Silicon MPS. | |
| """ | |
| self.reader.to(dtype=torch.bfloat16) | |
| for layer in self.reader.layers: | |
| layer.conv_module.bn.to(dtype=torch.float32) | |
| self.reader.res_scale.data = self.reader.res_scale.data.to(dtype=torch.float32) | |
| self.ctc_head.to(dtype=torch.float32) | |
| def forward_reader( | |
| self, | |
| features: torch.Tensor, | |
| key_padding_mask: Optional[torch.Tensor] = None | |
| ) -> torch.Tensor: | |
| """Runs SpecAugment (if training) + AudioToVectorReader without CosineCTCHead.""" | |
| if self.training and self.spec_aug is not None: | |
| features = self.spec_aug(features) | |
| return self.reader(features, key_padding_mask=key_padding_mask) | |
| def forward( | |
| self, | |
| features: torch.Tensor, | |
| key_padding_mask: Optional[torch.Tensor] = None, | |
| external_vector_bank: Optional[torch.Tensor] = None | |
| ) -> torch.Tensor: | |
| """ | |
| Args: | |
| features: Cached audio features (Batch, Time, 256) | |
| key_padding_mask: Padding mask (Batch, Time) | |
| external_vector_bank: Optional custom vector bank (V, 256) | |
| Returns: | |
| Logits: (Batch, Time, V + 1) | |
| """ | |
| if self.training and self.spec_aug is not None: | |
| features = self.spec_aug(features) | |
| embeddings = self.reader(features, key_padding_mask=key_padding_mask) | |
| logits = self.ctc_head(embeddings, external_vector_bank=external_vector_bank) | |
| return logits | |
| def extract_reader_embeddings( | |
| self, | |
| features: torch.Tensor, | |
| key_padding_mask: Optional[torch.Tensor] = None | |
| ) -> Tuple[torch.Tensor, torch.Tensor]: | |
| """ | |
| Outputs normalized embeddings and blank logit separately | |
| (Used for Mobile OTA / Plug-and-Play Dictionary mode). | |
| """ | |
| embeddings = self.reader(features, key_padding_mask=key_padding_mask) | |
| norm_blank = F.normalize(self.ctc_head.blank_vec.to(device=embeddings.device), p=2, dim=-1) | |
| scale = torch.clamp(self.ctc_head.logit_scale.exp(), max=200.0) | |
| blank_logit = torch.matmul(embeddings, norm_blank.t()) * scale + self.ctc_head.blank_bias | |
| return embeddings, blank_logit | |
| def count_parameters(self) -> Dict[str, int]: | |
| """Returns train parameter counts.""" | |
| total = sum(p.numel() for p in self.parameters()) | |
| trainable = sum(p.numel() for p in self.parameters() if p.requires_grad) | |
| reader_params = sum(p.numel() for p in self.reader.parameters()) | |
| head_params = sum(p.numel() for p in self.ctc_head.parameters()) | |
| return { | |
| "total_params": total, | |
| "trainable_params": trainable, | |
| "reader_params": reader_params, | |
| "ctc_head_params": head_params | |
| } | |
| # ============================================================================== | |
| # 5. Native Fallback Audio Encoder (Matches EmbeddingGemma-2 Audio Front-End) | |
| # ============================================================================== | |
| class StandardTransformerBlock(nn.Module): | |
| """Clean, standard Transformer block without proprietary fastpath C++ kernels.""" | |
| def __init__(self, d_model: int = 1024, nhead: int = 8, dim_feedforward: int = 2048, dropout: float = 0.0): | |
| super().__init__() | |
| self.norm1 = nn.LayerNorm(d_model) | |
| self.self_attn = ExplicitMultiHeadAttention(embed_dim=d_model, num_heads=nhead, dropout=dropout) | |
| self.norm2 = nn.LayerNorm(d_model) | |
| self.ffn = nn.Sequential( | |
| nn.Linear(d_model, dim_feedforward), | |
| nn.GELU(), | |
| nn.Dropout(dropout), | |
| nn.Linear(dim_feedforward, d_model), | |
| nn.Dropout(dropout) | |
| ) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| nx = self.norm1(x) | |
| attn_out = self.self_attn(nx, nx, nx) | |
| x = x + attn_out | |
| x = x + self.ffn(self.norm2(x)) | |
| return x | |
| class RealEmbeddingGemma2AudioEncoder(nn.Module): | |
| """ | |
| Official 305M Audio Encoder from google/embeddinggemma-2: | |
| - audio_tower: 304.8M Gemma 4 Audio Conformer Encoder | |
| - embed_audio: 0.8M Multimodal Projector | |
| - Matryoshka Slicing to 256 dimensions + L2 Normalization | |
| Strictly uses real pretrained weights; raises RuntimeError on failure. | |
| """ | |
| def __init__(self, audio_tower: nn.Module, embed_audio: nn.Module): | |
| super().__init__() | |
| self.audio_tower = audio_tower | |
| self.embed_audio = embed_audio | |
| def from_pretrained( | |
| cls, | |
| model_name: str = "google/embeddinggemma-2", | |
| dtype: torch.dtype = torch.bfloat16 | |
| ) -> "RealEmbeddingGemma2AudioEncoder": | |
| """Loads and freezes the genuine 300M Audio Encoder from Hugging Face checkpoint.""" | |
| try: | |
| from transformers import AutoModel | |
| full_model = AutoModel.from_pretrained(model_name, dtype=dtype) | |
| audio_tower = full_model.audio_tower | |
| embed_audio = full_model.embed_audio | |
| # Freeze all parameters | |
| for p in audio_tower.parameters(): | |
| p.requires_grad = False | |
| for p in embed_audio.parameters(): | |
| p.requires_grad = False | |
| encoder = cls(audio_tower=audio_tower, embed_audio=embed_audio) | |
| encoder.eval() | |
| return encoder | |
| except Exception as e: | |
| raise RuntimeError( | |
| f"[FATAL ERROR] Failed to load genuine '{model_name}' Audio Encoder: {e}\n" | |
| f"Make sure transformers is up-to-date: pip install git+https://github.com/huggingface/transformers.git" | |
| ) | |
| def forward(self, input_features: torch.Tensor) -> torch.Tensor: | |
| """ | |
| Args: | |
| input_features: (Batch, Time_Frames, 128) 128-bin log-mel filterbanks | |
| Returns: | |
| (Batch, Time_Tokens, 256) MRL-sliced, L2-normalized representations | |
| """ | |
| tower_out = self.audio_tower(input_features, attention_mask=None) | |
| audio_hs = tower_out.last_hidden_state # (B, T, 1536) | |
| audio_proj = self.embed_audio(audio_hs) # (B, T, 512) | |
| mrl_256 = audio_proj[:, :, :256] | |
| return F.normalize(mrl_256.float(), p=2, dim=-1) | |
| # Quick Self-Test when executed directly | |
| if __name__ == "__main__": | |
| print("[Testing model.py - Reader v2 Conformer]") | |
| batch_size = 2 | |
| time_steps = 150 | |
| input_dim = 256 | |
| output_dim = 256 | |
| vocab_size = 5999 # Sliced vocab size (5,999 non-blank tokens -> 6,000 total with <blank>) | |
| dummy_features = torch.randn(batch_size, time_steps, input_dim) | |
| dummy_vector_bank = torch.randn(vocab_size, output_dim) | |
| model = EmbeddingGemmaCTCReader( | |
| input_dim=input_dim, | |
| output_dim=output_dim, | |
| d_model=384, | |
| nhead=6, | |
| num_layers=10, | |
| dim_feedforward=1536, | |
| conv_kernel_size=15, | |
| init_temperature=0.05, | |
| vector_bank=dummy_vector_bank, | |
| trainable_vector_bank=True | |
| ) | |
| stats = model.count_parameters() | |
| print("Parameter Stats:") | |
| for k, v in stats.items(): | |
| print(f" {k}: {v:,} ({v / 1e6:.2f}M)") | |
| logits = model(dummy_features) | |
| print(f"Input features shape: {dummy_features.shape}") | |
| print(f"Logits output shape: {logits.shape} (Expected: ({batch_size}, {time_steps}, {vocab_size + 1}))") | |
| assert logits.shape == (batch_size, time_steps, vocab_size + 1) | |
| print("Model test PASSED successfully!") | |
Xet Storage Details
- Size:
- 31.7 kB
- Xet hash:
- b5ca650d28c64f85e98fe65b42a191e9794bd8207ac91f4236d42aaeece0ef01
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.