[{"content":"Problem Formulation Consider a microphone array recording a monaural acoustic signal in a noisy reverberant environment. The signal can be modeled as:\n$$x(t) = \\left( s_T(t) * h_T(t) \\right) + \\sum_{i=1}^{I} \\left( s_i(t) * h_i(t) \\right) + n(t)$$where \\(s_T(t)\\) is the dry audio from the target speaker, \\(s_i(t)\\) are interfering background speakers, \\(h(t)\\) are Room Impulse Responses (RIRs), \\(*\\) denotes convolution, and \\(n(t)\\) is additive environmental noise.\nThe objective is to learn a mapping \\(f(x) \\to D \\in \\{0, 1\\}\\) that satisfies:\n$$D = 1 \\iff \\left( \\mathcal{K}(x) = k_T \\right) \\land \\left( \\mathcal{S}(x) = s_T \\right)$$where \\(\\mathcal{K}(x)\\) identifies the keyword content and \\(\\mathcal{S}(x)\\) identifies the speaker. The detection fires only when both conditions hold simultaneously.\nThe model must operate under tight constraints: fewer than 3 million parameters, CPU inference under 200 ms for a 2-second window, real-time factor \\(\\text{xRT} = \\Delta\\tau / T \u003c 0.20\\), and robustness across SNR from \\(-5\\) to 30 dB. These constraints come from the target deployment scenario: a microcontroller with no GPU, no cloud offloading, and a power budget that rules out large transformer models. Every parameter counts. Every millisecond matters. This rules out the dominant approach of fine-tuning a large pre-trained model and forces us to design from scratch.\nWhy Explicit Disentanglement A standard keyword spotter trained on multi-speaker data learns correlations between acoustic features and keyword labels but never explicitly separates content from identity. To see why this is a problem, consider the information flow. Let \\(\\mathbf{z} \\in \\mathbb{R}^d\\) be the shared embedding produced by the encoder. This single vector encodes both what word was spoken and who spoke it: mutual informations \\(I(\\mathbf{z}; \\text{keyword}) \u003e 0\\) and \\(I(\\mathbf{z}; \\text{speaker}) \u003e 0\\). The encoder has no incentive to orthogonalize these factors because the training objective is purely discriminative.\nNow consider what happens at inference. The user enrolls by providing 5+ utterances of a keyword. We compute a prototype embedding \\(\\mathbf{p} = \\frac{1}{N}\\sum_i \\mathbf{z}_i\\) from these utterances. At runtime, we compare each incoming embedding against \\(\\mathbf{p}\\). If the embedding space is entangled, a confuser who speaks a phonetically similar word in a voice that happens to resemble the target\u0026rsquo;s can land arbitrarily close to \\(\\mathbf{p}\\) in embedding space. The model never learned to penalize this because the training objective \\(P(\\text{keyword} | \\text{audio})\\) does not factorize the generative components of the signal. Under ideal conditions the joint distribution factors as:\n$$P(\\text{audio}) = P(\\text{content}) \\cdot P(\\text{speaker}) \\cdot P(\\text{channel})$$But the discriminative posterior estimated by a standard classifier does not reflect this factorization. It compresses both content and speaker variation into the same dimensions.\nThe solution is to design two independent embedding spaces \\(\\mathbf{z}_{phn} \\in \\mathbb{R}^{192}\\) and \\(\\mathbf{z}_{spk} \\in \\mathbb{R}^{192}\\) with the property that \\(I(\\mathbf{z}_{phn}; \\mathbf{z}_{spk}) \\approx 0\\). Why 192 dimensions? This is a design tradeoff. Fewer dimensions (e.g., 64) would save parameters but would limit the model\u0026rsquo;s capacity to separate 35 keyword classes and 1,251 speakers in hyperspherical space. More dimensions (e.g., 512) would consume too much of the 3M parameter budget on the final projection layers. At 192, each head uses roughly 0.2M parameters for the projection, leaving the bulk of the budget for the shared computation. We verified empirically that increasing to 256 yielded diminishing returns while decreasing to 128 hurt speaker EER by 2.3 percentage points.\nArchitecture The model follows a single-backbone, dual-head design. Why not two completely separate models? A shared backbone forces both tasks to build on common acoustic representations: both phonetic content and speaker identity require the same low-level feature hierarchies (frequency modulation, formant structure, harmonic content). Two separate encoders would duplicate this computation and consume the entire parameter budget on overlapping work. And why not a single unified head? Because a single embedding space cannot satisfy both objectives simultaneously: speaker discrimination benefits from pooling over the full time-frequency plane, while keyword detection needs frame-level local patterns. The dual-head design extracts common features once, then branches into specialized pathways for each objective.\nAcoustic Front-End The raw waveform at 16 kHz is framed with a Hamming window (25 ms, 10 ms hop). For each frame we compute the Short-Time Fourier Transform:\n$$X(t, k) = \\sum_{n=0}^{N-1} x[t R + n] \\cdot w[n] \\cdot e^{-j \\frac{2 \\pi k n}{N}}$$The power spectrum is mapped to 80 Mel-scale filterbanks:\n$$m = 2595 \\cdot \\log_{10}\\left(1 + \\frac{f}{700}\\right)$$$$\\text{LFBE}(t, m) = \\log \\left( \\sum_{k=0}^{N/2} |X(t, k)|^2 \\cdot H_m(k) + \\epsilon \\right)$$The output for a 2-second segment is \\(\\mathbf{X} \\in \\mathbb{R}^{1 \\times 80 \\times 200}\\). Why 16 kHz sampling and 80 Mel bins? The keyword bandwidth (voice fundamental up to the first few formants) is well contained below 8 kHz, satisfying Nyquist at 16 kHz with margin. Eighty Mel bins provide roughly 12 bins per critical band in the 0-8 kHz range, sufficient to resolve formant structure for phoneme discrimination without the computational overhead of full-resolution spectrograms (e.g., 257 FFT bins). The 200-frame dimension follows from the 10 ms hop: 2,000 ms / 10 ms = 200 frames per utterance.\nShared Encoder: BC-ResNet-2 [1] The Broadcasted Residual Network [1] splits computation into two parallel pathways within each residual block. Let \\(\\mathbf{x} \\in \\mathbb{R}^{C_{in} \\times F \\times T}\\) be the input. The first pathway applies a 2D convolution to capture local frequency-time structure:\n$$\\mathbf{y}_{2D} = \\text{ReLU}(\\text{BatchNorm2D}(\\text{Conv2D}(\\mathbf{x}; \\mathbf{W}_{2D})))$$The second pathway compresses the frequency axis via average pooling and applies a 1D convolution for temporal context:\n$$\\mathbf{y}_{1D} = \\text{ReLU}\\left(\\text{BatchNorm1D}\\left(\\text{Conv1D}\\left(\\frac{1}{F}\\sum_{f=1}^F \\mathbf{x}[:, f, :]; \\mathbf{W}_{1D}\\right)\\right)\\right)$$The temporal feature map is broadcast back across the frequency dimension:\n$$\\mathbf{y}_{block} = \\mathbf{y}_{2D} + \\text{Broadcast}\\left(\\mathbf{y}_{1D}, \\text{target\\_shape}=(C_{out}, F', T)\\right)$$This separation reduces parameters because global temporal statistics (average energy over frequency) are computed cheaply by the 1D path while spatial-frequency detail is preserved by the 2D path. The shared encoder totals 33.8K parameters.\nParameter budget across all model components. The Causal Conformer phonetic head dominates at 1.67M params, while the shared encoder runs on just 33.8K. Phonetic Head: Causal Conformer [2] The phonetic head uses a Causal Conformer [2]. Why a Conformer rather than a plain CNN or a Transformer? Keyword discrimination requires resolving local phonetic detail (e.g., the plosive burst distinguishing \u0026ldquo;bat\u0026rdquo; from \u0026ldquo;pat\u0026rdquo;) over short time scales, and global temporal structure (e.g., the stress pattern distinguishing \u0026ldquo;record\u0026rdquo; noun from \u0026ldquo;record\u0026rdquo; verb) over longer spans. Convolutions capture local patterns efficiently but struggle with long-range dependencies. Self-attention captures long-range dependencies but dilutes local detail in its global receptive field. The Conformer combines both: depthwise convolutions process local structure at each time step, while self-attention layers propagate information across the entire sequence. The Macaron-style sandwich (feed-forward → conv → attention → feed-forward) ensures each operation acts on features already transformed by the other. Each Conformer block follows a Macaron-style structure:\n$$\\tilde{\\mathbf{x}}_i = \\mathbf{x}_{i-1} + \\frac{1}{2} \\text{FFN}(\\mathbf{x}_{i-1})$$$$\\mathbf{x}'_i = \\tilde{\\mathbf{x}}_i + \\text{MHSA}(\\tilde{\\mathbf{x}}_i)$$$$\\mathbf{x}''_i = \\mathbf{x}'_i + \\text{CausalConv1D}(\\mathbf{x}'_i)$$$$\\mathbf{x}_i = \\text{LayerNorm}\\left(\\mathbf{x}''_i + \\frac{1}{2} \\text{FFN}(\\mathbf{x}''_i)\\right)$$Causality is enforced via a masked attention matrix:\n$$A_{i,j} = \\frac{\\mathbf{q}_i \\mathbf{k}_j^T}{\\sqrt{d_k}} + M_{i,j}, \\quad M_{i,j} = \\begin{cases} 0 \u0026 \\text{if } j \\le i \\\\ -\\infty \u0026 \\text{if } j \u003e i \\end{cases}$$The output is the phonetic embedding \\(\\mathbf{z}_{phn} \\in \\mathbb{R}^{192}\\). The phonetic head contains 1,673K parameters (92.6% of the model).\nSpeaker Head: ECAPA-TDNN Lite [3] The speaker head is a lightweight ECAPA-TDNN [3] built on SpeechBrain [13]. Speaker identity is a global property of an utterance: it does not change frame to frame. The task requires pooling discriminative cues across the utterance while suppressing noise-dominated frames. A standard CNN with mean pooling treats every frame equally, which is suboptimal because speaker-discriminative segments (vowel nuclei, nasal formants) are sparse and unevenly distributed. ECAPA-TDNN solves this with two key mechanisms. The Squeeze-and-Excitation block computes channel attention:\n$$\\mathbf{s} = \\sigma\\left(\\mathbf{W}_2 \\cdot \\text{ReLU}\\left(\\mathbf{W}_1 \\cdot \\left(\\frac{1}{T}\\sum_{t=1}^T \\mathbf{h}_t\\right)\\right)\\right)$$The gated features pass through Attentive Statistics Pooling (ASP), which learns frame-level attention weights:\n$$e_t = \\mathbf{v}^T \\tanh\\left(\\mathbf{W} \\tilde{\\mathbf{h}}_t + \\mathbf{b}\\right), \\quad \\alpha_t = \\frac{e^{e_t}}{\\sum_{\\tau=1}^T e^{e_{\\tau}}}$$The attention-weighted statistics form the speaker embedding:\n$$\\boldsymbol{\\mu} = \\sum_{t=1}^T \\alpha_t \\tilde{\\mathbf{h}}_t, \\quad \\boldsymbol{\\sigma} = \\sqrt{\\sum_{t=1}^T \\alpha_t \\left(\\tilde{\\mathbf{h}}_t - \\boldsymbol{\\mu}\\right)^2}$$$$\\mathbf{z}_{spk} = \\mathbf{W}_{proj} \\cdot [\\boldsymbol{\\mu}; \\boldsymbol{\\sigma}] + \\mathbf{b}_{proj}$$ASP is more parameter-efficient than global pooling because speaker-discriminative information is not uniformly distributed across frames. The speaker head uses 88.8K parameters.\nDisentanglement The shared backbone produces features used by both heads. Without intervention, the two embedding spaces become correlated: the speaker head relies on phonetic cues, and the phonetic head encodes speaker-specific traits. We apply two complementary mechanisms to enforce separation.\nGradient Reversal Layer [4] An auxiliary speaker classifier \\(D_{spk}\\) is attached to the phonetic embedding \\(\\mathbf{z}_{phn}\\). Why GRL rather than a simpler decorrelation penalty (e.g., minimizing the Frobenius norm of the cross-covariance matrix between \\(\\mathbf{z}_{phn}\\) and \\(\\mathbf{z}_{spk}\\))? A covariance-based penalty can reduce linear correlation but does not force the embeddings to discard speaker information. The encoder could still encode speaker identity nonlinearly while maintaining zero linear correlation. GRL, by contrast, pits the encoder against a powerful discriminator in a minimax game: the discriminator tries to decode speaker identity from \\(\\mathbf{z}_{phn}\\), and the encoder tries to make this impossible. This forces the encoder to remove whatever speaker information the discriminator can exploit, and the discriminator adaptively discovers new patterns to exploit. The GRL [4] sits between the phonetic head and the classifier. During forward propagation it passes data unchanged. During backpropagation it inverts gradients with scaling factor \\(-\\lambda\\):\n$$\\text{Forward: } R_\\lambda(\\mathbf{x}) = \\mathbf{x}, \\quad \\text{Backward: } \\frac{d R_\\lambda(\\mathbf{x})}{d\\mathbf{x}} = -\\lambda \\mathbf{I}$$The overall objective is:\n$$E(G_{back}, G_{phn}, D_{spk}) = L_{kw}(G_{back}, G_{phn}) - \\lambda L_{adv}(G_{back}, D_{spk})$$As \\(D_{spk}\\) improves at speaker classification, the inverted gradient pushes the encoder to produce \\(\\mathbf{z}_{phn}\\) that is less informative for speaker identity. The equilibrium is reached when \\(\\mathbf{z}_{phn}\\) contains no linearly decodable speaker information.\nCLUB Mutual Information Minimization [5] GRL only removes information that the auxiliary classifier can linearly decode. Residual nonlinear dependencies can persist. The mutual information between the two embeddings is:\n$$I(\\mathbf{Z}_{phn}; \\mathbf{Z}_{spk}) = \\mathbb{E}_{P(\\mathbf{Z}_{phn}, \\mathbf{Z}_{spk})} \\left[ \\log \\frac{P(\\mathbf{Z}_{phn} | \\mathbf{Z}_{spk})}{P(\\mathbf{Z}_{phn})} \\right]$$Since the true conditional is intractable, we use a variational estimator \\(q_\\theta\\). The Contrastive Log-ratio Upper Bound (CLUB) [5] provides a tractable upper bound:\n$$I_{CLUB}(\\mathbf{Z}_{phn}; \\mathbf{Z}_{spk}) = \\frac{1}{B} \\sum_{i=1}^B \\left[ \\log q_\\theta(\\mathbf{z}_{phn, i} | \\mathbf{z}_{spk, i}) - \\frac{1}{B} \\sum_{j=1}^B \\log q_\\theta(\\mathbf{z}_{phn, j} | \\mathbf{z}_{spk, i}) \\right]$$The optimization alternates between updating \\(q_\\theta\\) to fit the conditional, and minimizing the bound with respect to encoder parameters:\n$$\\max_\\theta \\frac{1}{B} \\sum_{i=1}^B \\log q_\\theta(\\mathbf{z}_{phn, i} | \\mathbf{z}_{spk, i}), \\quad \\min_{\\Theta} I_{CLUB}(\\mathbf{Z}_{phn}; \\mathbf{Z}_{spk})$$The combination of GRL and CLUB removes both linearly decodable and nonlinearly dependent speaker information from the phonetic pathway.\nTraining Pipeline Training proceeds in four phases, each with distinct optimization objectives. Why four phases instead of joint end-to-end training from scratch? The objectives conflict. AAM-Softmax classification requires the model to exploit all available information (content + speaker) to minimize cross-entropy. Disentanglement asks the opposite: discard speaker information from the phonetic pathway and keyword information from the speaker pathway. GE2E speaker refinement requires utterance-level comparison, which is incompatible with the frame-level objectives of the phonetic head. Joint optimization from scratch creates destructive gradient interference: the disentanglement loss pushes gradients that oppose the classification loss, and neither objective converges. By decomposing into phases, each set of weights is initialized to a reasonable basin before the next objective is introduced.\nPhase 1: AAM-Softmax Pre-Training [6] The backbone and heads are pre-trained separately on keyword classification (Google Speech Commands v2 [10], 35 classes, 105K utterances) and speaker classification (VoxCeleb1 [11], 1,251 speakers, 153K utterances). Both use Additive Angular Margin Softmax [6]:\n$$L_{AAM} = -\\frac{1}{B} \\sum_{i=1}^B \\log \\frac{e^{s \\cdot \\cos(\\theta_{y_i} + m)}}{e^{s \\cdot \\cos(\\theta_{y_i} + m)} + \\sum_{j \\neq y_i} e^{s \\cdot \\cos(\\theta_j)}}$$Why AAM-Softmax over plain softmax? Standard softmax produces embeddings that are separable by class but not necessarily compact within class. The decision boundaries are linear hyperplanes in the embedding space, and there is no mechanism to reduce intra-class variance. AAM-Softmax inserts a multiplicative margin \\(m\\) into the angle between the embedding vector \\(\\mathbf{x}_i\\) and the weight vector \\(\\mathbf{W}_{y_i}\\) of its target class. The loss function requires \\(\\cos(\\theta_{y_i} + m) \u003e \\cos(\\theta_j)\\) for all \\(j \\neq y_i\\), which tightens the angular decision boundary around each class. Geometrically, each class occupies a conical region of width \\(m\\) on the hypersphere. For 35 keyword classes, this is essential: words like \u0026ldquo;yes\u0026rdquo; and \u0026ldquo;yep\u0026rdquo; or \u0026ldquo;two\u0026rdquo; and \u0026ldquo;too\u0026rdquo; are acoustically close, and the margin prevents them from overlapping in embedding space. At this stage there is no disentanglement.\nPhase 2: Joint Training with Disentanglement GRL, CLUB, and the dual-gate scorer are enabled. Two additional loss terms are introduced. The triplet loss separates positive and negative pairs:\n$$L_{triplet} = \\max\\left(0, \\|\\mathbf{z}_a - \\mathbf{z}_p\\|_2^2 - \\|\\mathbf{z}_a - \\mathbf{z}_n\\|_2^2 + \\alpha\\right)$$Why both triplet and rejection losses? The triplet loss ensures that a random negative utterance stays further from the anchor than the positive utterance by margin \\(\\alpha\\). This creates relative ordering in embedding space. However, it does not enforce an absolute floor on confuser similarity. A confuser could be closer to the anchor than the positive by less than \\(\\alpha\\) and still incur zero triplet loss. The rejection loss fixes this by requiring an absolute minimum distance \\(\\gamma\\) for any confuser utterance. The two losses operate at different levels: triplet creates relative separation, rejection imposes absolute boundaries.\n$$L_{reject} = \\max\\left(0, \\gamma - \\|\\mathbf{z}_a - \\mathbf{z}_{confuser}\\|_2^2\\right)$$Phase 3a: GE2E Speaker Refinement [7] The speaker head is fine-tuned using Generalized End-to-End loss [7]. Why GE2E instead of the triplet loss already used in Phase 2? Triplet loss compares one positive and one negative per anchor, which requires careful mining to avoid easy negatives that contribute zero loss. GE2E computes similarities between every utterance and every speaker centroid in the batch, producing \\(N \\times M\\) comparisons per step. This is more sample-efficient because every utterance in the batch contributes to the gradient through the softmax over all centroids. The inference-time operation (compare against a prototype centroid) also matches the training objective directly: GE2E trains embeddings to maximize similarity to their own centroid, which is exactly what the scorer does at runtime. For a batch of \\(N\\) speakers with \\(M\\) utterances each, let \\(\\mathbf{e}_{ji}\\) be the embedding for utterance \\(i\\) of speaker \\(j\\). The centroid excluding utterance \\(i\\) is:\n$$\\mathbf{c}_{j}^{(-i)} = \\frac{1}{M-1} \\sum_{m \\neq i} \\mathbf{e}_{jm}$$The similarity matrix \\(S_{ji,k} = w \\cdot \\cos(\\mathbf{e}_{ji}, \\mathbf{c}_k) + b\\) is optimized via softmax:\n$$L_{GE2E} = -\\frac{1}{N \\cdot M} \\sum_{j=1}^N \\sum_{i=1}^M \\log \\frac{e^{S_{ji,j}}}{\\sum_{k=1}^N e^{S_{ji,k}}}$$Phase 3b: Hard-Negative GE2E GE2E is extended with hard-negative mining from LibriPhrase [12]. For each anchor, a phonetically similar confuser from a different speaker is retrieved and penalized:\n$$L_{hard} = L_{GE2E} + \\beta \\cdot \\max\\left(0, \\cos(\\mathbf{e}_{anchor}, \\mathbf{e}_{hardneg}) - \\delta\\right)$$This phase targets the hardest failure mode: a confuser whose voice and chosen keyword are both similar to the target. In our tests it reduced joint EER by approximately 2 percentage points.\nThe complete training pipeline: Phase 1 pre-trains individually, Phase 2 introduces disentanglement + joint objectives, Phase 3a refines speaker head with GE2E, Phase 3b adds hard-negative mining. Scoring and Decision At enrollment, prototype embeddings \\(\\mathbf{p}_{kw}\\) and \\(\\mathbf{p}_{spk}\\) are computed by averaging \\(\\mathbf{z}_{phn}\\) and \\(\\mathbf{z}_{spk}\\) across enrollment utterances, following the prototypical network formulation [8]. At detection time, cosine similarities are computed:\n$$\\text{Sim}(\\mathbf{z}_1, \\mathbf{z}_2) = \\frac{\\mathbf{z}_1 \\cdot \\mathbf{z}_2^T}{\\|\\mathbf{z}_1\\|_2 \\|\\mathbf{z}_2\\|_2}$$$$\\text{Score} = w_{kw} \\cdot \\text{Sim}(\\mathbf{z}_{phn}, \\mathbf{p}_{kw}) + w_{spk} \\cdot \\text{Sim}(\\mathbf{z}_{spk}, \\mathbf{p}_{spk})$$The weights \\(w_{kw}\\) and \\(w_{spk}\\) are obtained by grid search over \\(10 \\times 10\\) combinations spanning \\([0, 1]\\) at increments of 0.1. This granularity captures the tradeoff landscape: coarser (\\(5 \\times 5\\)) misses the optimal operating point, finer (\\(20 \\times 20\\)) offers no measurable improvement while costing 4x computation. The optimum is \\(w_{kw}=0.30, w_{spk}=0.65\\). The sum is 0.95 rather than 1.0 because the keyword-only EER (4.69%) is much lower than the speaker-only EER (17.86%). The scorer compensates by weighting the less reliable speaker head more heavily.\nFor streaming, Exponential Moving Average smoothing is applied:\n$$\\bar{S}_t = \\alpha_{ema} \\cdot \\bar{S}_{t-1} + (1 - \\alpha_{ema}) \\cdot \\text{Score}_t$$with \\(\\alpha_{ema}=0.7\\). The EMA dampens transient false accepts from noise bursts or non-speech artifacts. Why \\(\\alpha=0.7\\) rather than a higher value like 0.95? Speaker turns happen on timescales of hundreds of milliseconds. A score drop from a genuine speaker to a confuser produces a sharp transition in Score\\(_t\\). With \\(\\alpha=0.7\\), the smoothed score drops to 30% of its original value within 2 frames (20 ms), preserving responsiveness to speaker changes. Higher values (e.g., 0.95) would create a long tail that masks true rejections for up to 20 frames. Lower values (e.g., 0.3) fail to suppress the transient noise. The value 0.7 was validated by sweeping \\(\\alpha \\in [0.5, 0.95]\\) on a held-out validation set and selecting the minimum joint EER. The final decision is:\n$$D = \\begin{cases} 1 \u0026 \\text{if } \\bar{S}_t \\ge \\tau_{EER} \\\\ 0 \u0026 \\text{if } \\bar{S}_t \u003c \\tau_{EER} \\end{cases}$$where \\(\\tau_{EER} = 0.2222\\) is the threshold calibrated via DET curve analysis.\nResults Metric Value Parameters 1.806 M ONNX Size 0.60 MB (INT8) CPU Latency 26.43 ms (p95: 28.29 ms) Real-Time Factor 0.0132 Keyword EER 4.69% Speaker EER 17.86% Joint EER 23.47% Joint AUC 0.8425 The keyword EER of 4.69% is competitive with larger models that lack a disentanglement constraint. The model processes a 2-second window in 26 ms (xRT = 0.0132), well under the 0.20 target.\nThe speaker EER (17.86%) is higher than dedicated speaker verification models (under 5% on VoxCeleb1). This is expected given the parameter budget (88.8K parameters versus millions in a full ECAPA-TDNN) and the competing objectives of the shared backbone.\nThe joint EER (23.47%) is the relevant operating point. A false accept requires both keyword and speaker to match incorrectly in the same trial. The joint AUC of 0.8425 indicates strong separation between positive and negative trials across thresholds.\nDetection Error Trade-off curve for joint keyword + speaker verification. The EER operating point at \\(\\tau = 0.2222\\) balances false accepts and false rejects. Ablation Study Configuration Keyword EER (%) Speaker EER (%) Parameters Full Model (baseline) 4.69 17.33 1.806 M No FiLM [14] 4.69 17.33 1.683 M No Speaker Head 4.69 N/A 1.806 M No Temporal Block [9] 11.22 25.48 1.796 M Equal Scorer Weights 4.69 17.33 1.806 M Temporal block. Removing the Mamba SSM [9] or Dilated Conv1D module increases keyword EER by 2.4x (4.69% to 11.22%) and speaker EER by 1.5x (17.33% to 25.48%). Temporal context modeling is critical for distinguishing phonetically similar words like \u0026ldquo;sit\u0026rdquo; and \u0026ldquo;sat\u0026rdquo; that differ primarily in their temporal envelope. Without temporal integration, the model reduces to a bag-of-frames classifier that averages over time and loses the ordering information that distinguishes sequential phoneme patterns.\nSpeaker head removal. Ignoring the speaker head output during scoring leaves keyword EER unchanged (4.69% in both conditions). This is expected: the speaker head receives its own loss signal (speaker classification) that does not backpropagate into the keyword pathway beyond the shared backbone. The backbone is already well-optimized for keyword detection from Phase 1 pre-training, and the speaker head\u0026rsquo;s gradient contributions during Phase 2 are comparatively small (88.8K params vs. 1.67M params in the phonetic head). The parameter count stays at 1.806M because all weights are still present; only the scoring pathway omits the speaker branch.\nFiLM conditioning. The FiLM layer [14] saved 123K parameters when removed, with zero change in either EER. The conditioning likely helps in few-shot or noisy enrollment scenarios, but the GSC plus VoxCeleb test set did not stress this.\nScorer weights. Calibrated weights (0.30/0.65) and equal weights (0.50/0.50) produced identical EER on the standard test set. Calibration matters more at low-FAR operating points, which standard benchmarks do not emphasize.\nComponent-wise ablation impact on Keyword and Speaker EER. The temporal block is the single most critical component. Removing it degrades keyword EER by 2.4x. Limitations and Future Directions Speaker head capacity. 88.8K parameters is too tight for robust speaker verification. Increasing the budget to 5M parameters (still qualifying as a tiny model) or using a backbone that shares computation more efficiently with the speaker head would likely reduce speaker EER.\nGE2E integration. Speaker head refinement via GE2E was restricted to Phases 3a and 3b. Alternating between classification and GE2E objectives throughout Phase 2 would give the speaker head more training exposure before disentanglement constraints are applied.\nGRL and CLUB interaction. GRL removes linearly decodable speaker information. CLUB targets residual nonlinear dependencies. In practice, GRL removed most speaker information first, leaving little for CLUB to act on. The reason is that GRL\u0026rsquo;s adversarial training provides a stronger, more direct gradient signal: the discriminator explicitly classifies speaker identity, and the gradient reversal directly pushes the encoder away from speaker-predictive features. CLUB\u0026rsquo;s gradient, by contrast, is an upper-bound estimate of mutual information that is less tightly coupled to the classification objective. A more principled approach would monitor estimated mutual information and activate CLUB only when it rises above a threshold, or schedule the GLR weight \\(\\lambda\\) to be small initially and ramp up gradually so both mechanisms contribute.\nRejection margin sensitivity. The rejection margin \\(\\gamma\\) was fixed across all keywords. The optimal margin depends on intra-class variance of the keyword embeddings: tight clusters need smaller margins, loose clusters need larger ones. Adaptive margin selection would be more robust.\nFar-field evaluation. All benchmarks used clean, single-channel datasets. The SNR robustness curve (evaluated with MUSAN noise [15] from -5 dB to 30 dB) provides some confidence, but real-world far-field conditions with room acoustics and microphone arrays introduce challenges that synthetic noise augmentation does not fully replicate.\nPerformance across -5 dB to 30 dB SNR using MUSAN noise [15] (babble, music, environmental). The model maintains consistent accuracy across the full SNR range. Related Work Two broad approaches exist for custom keyword spotting with speaker verification. The big-model approach uses large pre-trained models (Wav2Vec 2.0, HuBERT, Whisper) with 100M+ parameters and GPU inference, suitable for cloud-connected systems. The tiny-model approach, which this work follows, builds from scratch under aggressive parameter constraints for edge deployment on CPU-only hardware with sub-30 ms latency.\nThis work sits at the intersection of disentangled representation learning (GRL [4], CLUB [5]), efficient speech architectures (BC-ResNet [1], Causal Conformer [2]), and few-shot speaker adaptation (GE2E [7], prototypical scoring [8]). The contribution is in the composition: a 1.8M parameter model with explicit content-identity separation that improves joint keyword-plus-speaker verification over entangled baselines.\nCitation Please cite this work as:\nBanerjee, Sohini and Tripathi, Swarnim. \u0026ldquo;Identity Is Not the Keyword\u0026rdquo;. Swarnim Tripathi\u0026rsquo;s Blog (Jul 2026). https://blog.swarnim.site/posts/disent-kws/\nOr use the BibTeX citation:\n@misc{bt2026disentkws, title={Identity Is Not the Keyword}, author={Banerjee, Sohini and Tripathi, Swarnim}, year={2026}, month={July}, url={https://blog.swarnim.site/posts/disent-kws/}, howpublished={\\url{https://github.com/tripathiji1312/DISENT_KWS}}, note={BC-ResNet-2 backbone with Causal Conformer phonetic head, ECAPA-TDNN Lite speaker head, and GRL+CLUB disentanglement. 1.806M parameters.} } Code and Reproducibility The training pipeline, demo scripts, and ONNX export are open source.\nCode: github.com/tripathiji1312/DISENT_KWS Model weights: Hugging Face (PyTorch + ONNX) Datasets: Google Speech Commands v2 [10], VoxCeleb1 [11], LibriPhrase [12], MUSAN [15] To reproduce the benchmark:\nuv sync --all-extras make test python src/demo.py record --model model_final.pt --out enrollment.pt python src/demo.py detect --enrollment enrollment.pt --auto-threshold References [1] Kim, B. et al. (2021). \u0026ldquo;BC-ResNet: Broadcasted Residual Learning for Lightweight Noise-Robust Keyword Spotting.\u0026rdquo; Proc. Interspeech.\n[2] Gulati, A. et al. (2020). \u0026ldquo;Conformer: Convolution-augmented Transformer for Speech Recognition.\u0026rdquo; Proc. Interspeech.\n[3] Desplanques, B. et al. (2020). \u0026ldquo;ECAPA-TDNN: Emphasized Channel Attention, Propagation and Aggregation in TDNN Based Speaker Verification.\u0026rdquo; Proc. Interspeech.\n[4] Ganin, Y. \u0026amp; Lempitsky, V. (2015). \u0026ldquo;Unsupervised Domain Adaptation by Backpropagation.\u0026rdquo; Proc. ICML.\n[5] Cheng, P. et al. (2020). \u0026ldquo;CLUB: A Contrastive Log-ratio Upper Bound of Mutual Information.\u0026rdquo; Proc. ICML.\n[6] Deng, J. et al. (2019). \u0026ldquo;ArcFace: Additive Angular Margin Loss for Deep Face Recognition.\u0026rdquo; Proc. CVPR.\n[7] Wan, L. et al. (2018). \u0026ldquo;Generalized End-to-End Loss for Speaker Verification.\u0026rdquo; Proc. ICASSP.\n[8] Snell, J. et al. (2017). \u0026ldquo;Prototypical Networks for Few-shot Learning.\u0026rdquo; Proc. NeurIPS.\n[9] Gu, A. \u0026amp; Dao, T. (2023). \u0026ldquo;Mamba: Linear-Time Sequence Modeling with Selective State Spaces.\u0026rdquo; arXiv preprint arXiv:2312.00752.\n[10] Warden, P. (2018). \u0026ldquo;Speech Commands: A Dataset for Limited-Vocabulary Speech Recognition.\u0026rdquo; arXiv preprint arXiv:1804.03209.\n[11] Nagrani, A. et al. (2017). \u0026ldquo;VoxCeleb: A Large-Scale Speaker Identification Dataset.\u0026rdquo; Proc. Interspeech.\n[12] LibriPhrase dataset. Available at: https://huggingface.co/datasets/charsiu/libriphrase\n[13] Ravanelli, M. et al. (2021). \u0026ldquo;SpeechBrain: A Multi-Task Speech Toolkit.\u0026rdquo; arXiv preprint arXiv:2106.04624.\n[14] Perez, E. et al. (2018). \u0026ldquo;FiLM: Visual Reasoning with a General Conditioning Layer.\u0026rdquo; Proc. AAAI.\n[15] Snyder, D. et al. (2015). \u0026ldquo;MUSAN: A Music, Speech, and Noise Corpus.\u0026rdquo; arXiv preprint.\n","permalink":"http://blog.swarnim.site/posts/disent-kws/","summary":"Adversarial disentanglement (GRL + CLUB), dual-gate scoring, and four-phase training produce a 0.60 MB model for custom wake word detection. Achieves 4.69% keyword EER and 0.8425 joint AUC with only 1.8M parameters — deployable on CPU at 26 ms per 2-second window.","title":"Identity Is Not the Keyword"},{"content":"Referencing PR \u0026ndash;\u0026gt; PR #174009, altough there were few changes, made afterwards by the reviewers. Whenever we write tensor[0] in Python, it feels like magic. But under the hood, PyTorch has to route this operation through a massive C++ dispatching engine to figure out if this should run on a CPU or a CUDA GPU\u0026hellip;\nThe Bug: Empty Indices Crashing index.Tensor The issue behind this PR was deceptively small: torch.ops.aten.index.Tensor(t, []) could crash instead of failing cleanly when the index list was empty. In other words, PyTorch was being asked to perform tensor indexing with no actual indices, and one of the internal code paths did not defend itself against that case.\nThat sounds like a corner case, but it is exactly the kind of corner case that matters in a system like PyTorch. Indexing is one of the most heavily used operations in the entire stack, and even a tiny inconsistency can surface in surprising places, especially once you start mixing Python frontend code, ATen internals, and backend-specific behavior.\nTracing the Dispatch: Python to ATen When you write something like x[0] in Python, the operation does not stay in Python for long. It is lowered into ATen, PyTorch’s internal tensor library, where the framework decides how to interpret the indexing request and which backend should handle it.\nThat path has to answer a lot of questions:\nIs the index list valid? Are we doing basic or advanced indexing? Do the indices describe a contiguous subspace? Should the implementation materialize any optional indices first? The bug in this PR lived in that validation layer. The problem was not really about a specific device or dtype. It was about what happens when the list of indices is empty and the internal helpers assume there is at least one meaningful tensor to inspect.\nThe Solution: Fixing IndexingUtils.h and TensorAdvancedIndexing.cpp The PR made two functional changes in the C++ code and one regression test in Python.\nFirst, IndexingUtils.h was updated so that the helper that checks whether the indexed tensors form a contiguous subspace handles an empty set safely. That is the kind of guard that looks boring in isolation, but it is exactly what keeps an internal helper from wandering into undefined behavior when the input is degenerate.\nSecond, TensorAdvancedIndexing.cpp now checks the meta path for an empty index list and raises a clear IndexError with the message at least one index must be provided. That is the right failure mode here: fail early, fail explicitly, and fail in a place that tells the caller what is wrong.\nThird, the Python test suite gained a regression test that exercises the exact empty-index call:\nwith self.assertRaisesRegex(IndexError, \u0026#34;at least one index must be provided\u0026#34;): torch.ops.aten.index.Tensor(t, []) That test is doing more than checking an error message. It locks in the contract so the bug does not quietly return later after some unrelated refactor.\n","permalink":"http://blog.swarnim.site/posts/pytorch-aten-dispatch/","summary":"How I fixed a crash in PyTorch\u0026rsquo;s ATen C++ layer when tensor indexing receives an empty index list — tracing the dispatch from Python through to IndexingUtils.h and TensorAdvancedIndexing.cpp.","title":"Deconstructing my PyTorch ATen PR: What happens when you index a Tensor?"},{"content":"Model Card: DISENT-KWS Model Description DISENT-KWS is a speech disentanglement model for robust custom word detection. It uses a shared BC-ResNet-2 encoder with dual heads — a Causal Conformer (phonetic) and ECAPA-TDNN Lite (speaker) — to produce orthogonal latent embeddings. A Dual-Gate Scorer combines keyword and speaker similarity with calibrated weights for joint verification.\nTotal Parameters: 1.806M ONNX Size: 0.60 MB (INT8 quantized) Input: 80-band log Mel filterbank energies, 200 frames (2 s @ 16 kHz) Output: z_phn ∈ ℝ¹⁹² (phonetic embedding), z_spk ∈ ℝ¹⁹² (speaker embedding) Intended Use Custom keyword spotting with speaker verification for access control, voice assistants, or wake-word systems Real-time streaming inference on CPU (26.4 ms per 2 s window) Few-shot enrollment (5+ utterances) for new users and custom words Architecture Component Type Params Shared Encoder BC-ResNet-2 33.8K Temporal Block Mamba SSM / Dilated Conv1D 10.3K Phonetic Head Causal Conformer (4 heads, kernel 15) 1,673K Speaker Head ECAPA-TDNN Lite (SE ratio 4, scale 4) 88.8K Scorer Dual-Gate (w_kw=0.30, w_spk=0.65, EMA α=0.7) — Training Data Dataset Usage Samples Google Speech Commands v2 Keyword pre-training (35 classes) 105K VoxCeleb1 Speaker verification (1,251 speakers) 153K LibriPhrase Hard-negative triplet pairs 3K triplets MUSAN Noise augmentation (babble, music, environmental) 109 hrs Training Phases Phase 1: AAM-Softmax pre-training on keyword (GSC) and speaker (VoxCeleb) separately Phase 2: Joint training with GRL adversarial reversal + CLUB MI minimization + triplet rejection loss Phase 3a: GE2E speaker head refinement Phase 3b: Hard-negative GE2E with LibriPhrase confusers Performance Metric Value Keyword EER 4.69% Speaker EER 17.86% Joint EER 23.47% Joint AUC 0.8425 CPU Latency 26.43 ms (p95: 28.29 ms) Real-Time Factor (xRT) 0.0132 Optimal Threshold (τ) 0.2222 Hardware \u0026amp; Runtime Training: NVIDIA GPU with 16 GB+ VRAM (tested on Tesla T4) Inference: CPU-only via ONNX Runtime; no GPU required Memory: ~200 MB RAM at runtime Known Limitations Requires clean enrollment recordings (SNR ≥ 10 dB recommended) Speaker EER (17.86%) is higher than keyword EER — joint verification mitigates this Mamba SSM fallback to Dilated Conv1D on platforms without CUDA (no performance loss) License MIT\n","permalink":"http://blog.swarnim.site/ref/model_card/","summary":"\u003ch1 id=\"model-card-disent-kws\"\u003eModel Card: DISENT-KWS\u003c/h1\u003e\n\u003ch2 id=\"model-description\"\u003eModel Description\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eDISENT-KWS\u003c/strong\u003e is a speech disentanglement model for robust custom word detection. It uses a shared BC-ResNet-2 encoder with dual heads — a Causal Conformer (phonetic) and ECAPA-TDNN Lite (speaker) — to produce orthogonal latent embeddings. A Dual-Gate Scorer combines keyword and speaker similarity with calibrated weights for joint verification.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eTotal Parameters:\u003c/strong\u003e 1.806M\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eONNX Size:\u003c/strong\u003e 0.60 MB (INT8 quantized)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eInput:\u003c/strong\u003e 80-band log Mel filterbank energies, 200 frames (2 s @ 16 kHz)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eOutput:\u003c/strong\u003e \u003ccode\u003ez_phn ∈ ℝ¹⁹²\u003c/code\u003e (phonetic embedding), \u003ccode\u003ez_spk ∈ ℝ¹⁹²\u003c/code\u003e (speaker embedding)\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"intended-use\"\u003eIntended Use\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eCustom keyword spotting\u003c/strong\u003e with speaker verification for access control, voice assistants, or wake-word systems\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eReal-time streaming\u003c/strong\u003e inference on CPU (26.4 ms per 2 s window)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFew-shot enrollment\u003c/strong\u003e (5+ utterances) for new users and custom words\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"architecture\"\u003eArchitecture\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eComponent\u003c/th\u003e\n          \u003cth\u003eType\u003c/th\u003e\n          \u003cth\u003eParams\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eShared Encoder\u003c/td\u003e\n          \u003ctd\u003eBC-ResNet-2\u003c/td\u003e\n          \u003ctd\u003e33.8K\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eTemporal Block\u003c/td\u003e\n          \u003ctd\u003eMamba SSM / Dilated Conv1D\u003c/td\u003e\n          \u003ctd\u003e10.3K\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003ePhonetic Head\u003c/td\u003e\n          \u003ctd\u003eCausal Conformer (4 heads, kernel 15)\u003c/td\u003e\n          \u003ctd\u003e1,673K\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eSpeaker Head\u003c/td\u003e\n          \u003ctd\u003eECAPA-TDNN Lite (SE ratio 4, scale 4)\u003c/td\u003e\n          \u003ctd\u003e88.8K\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eScorer\u003c/td\u003e\n          \u003ctd\u003eDual-Gate (w_kw=0.30, w_spk=0.65, EMA α=0.7)\u003c/td\u003e\n          \u003ctd\u003e—\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch2 id=\"training-data\"\u003eTraining Data\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eDataset\u003c/th\u003e\n          \u003cth\u003eUsage\u003c/th\u003e\n          \u003cth\u003eSamples\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eGoogle Speech Commands v2\u003c/td\u003e\n          \u003ctd\u003eKeyword pre-training (35 classes)\u003c/td\u003e\n          \u003ctd\u003e105K\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eVoxCeleb1\u003c/td\u003e\n          \u003ctd\u003eSpeaker verification (1,251 speakers)\u003c/td\u003e\n          \u003ctd\u003e153K\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eLibriPhrase\u003c/td\u003e\n          \u003ctd\u003eHard-negative triplet pairs\u003c/td\u003e\n          \u003ctd\u003e3K triplets\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eMUSAN\u003c/td\u003e\n          \u003ctd\u003eNoise augmentation (babble, music, environmental)\u003c/td\u003e\n          \u003ctd\u003e109 hrs\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch2 id=\"training-phases\"\u003eTraining Phases\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003ePhase 1:\u003c/strong\u003e AAM-Softmax pre-training on keyword (GSC) and speaker (VoxCeleb) separately\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePhase 2:\u003c/strong\u003e Joint training with GRL adversarial reversal + CLUB MI minimization + triplet rejection loss\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePhase 3a:\u003c/strong\u003e GE2E speaker head refinement\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePhase 3b:\u003c/strong\u003e Hard-negative GE2E with LibriPhrase confusers\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"performance\"\u003ePerformance\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eMetric\u003c/th\u003e\n          \u003cth\u003eValue\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eKeyword EER\u003c/td\u003e\n          \u003ctd\u003e4.69%\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eSpeaker EER\u003c/td\u003e\n          \u003ctd\u003e17.86%\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eJoint EER\u003c/td\u003e\n          \u003ctd\u003e23.47%\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eJoint AUC\u003c/td\u003e\n          \u003ctd\u003e0.8425\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eCPU Latency\u003c/td\u003e\n          \u003ctd\u003e26.43 ms (p95: 28.29 ms)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eReal-Time Factor (xRT)\u003c/td\u003e\n          \u003ctd\u003e0.0132\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eOptimal Threshold (τ)\u003c/td\u003e\n          \u003ctd\u003e0.2222\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch2 id=\"hardware--runtime\"\u003eHardware \u0026amp; Runtime\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eTraining:\u003c/strong\u003e NVIDIA GPU with 16 GB+ VRAM (tested on Tesla T4)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eInference:\u003c/strong\u003e CPU-only via ONNX Runtime; no GPU required\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eMemory:\u003c/strong\u003e ~200 MB RAM at runtime\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"known-limitations\"\u003eKnown Limitations\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eRequires clean enrollment recordings (SNR ≥ 10 dB recommended)\u003c/li\u003e\n\u003cli\u003eSpeaker EER (17.86%) is higher than keyword EER — joint verification mitigates this\u003c/li\u003e\n\u003cli\u003eMamba SSM fallback to Dilated Conv1D on platforms without CUDA (no performance loss)\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"license\"\u003eLicense\u003c/h2\u003e\n\u003cp\u003eMIT\u003c/p\u003e","title":""},{"content":"DISENT-KWS — Speech Disentanglement for Robust Custom Word Detection Demo Video Link — YouTube Demo Video Setup \u0026amp; Result Reproducibility Video Link — YouTube Setup Video Quick Start # Install dependencies uv sync --all-extras # Run tests (verify 60+ tests pass) make test # Option A: Live record 5 utterances from mic, then enroll python src/demo.py record --model model_final.pt --out enrollment.pt # Option B: Provide pre-recorded WAV files python src/demo.py enroll \\ --recordings ./my_recordings/*.wav \\ --model model_final.pt \\ --out enrollment.pt # Real-time detection python src/demo.py detect --enrollment enrollment.pt --auto-threshold --vad-threshold 0.02 Project Artifacts Technical Documentation All technical documentation is organized in the docs/ directory:\nDocument Description docs/solution_architecture.md Architecture, mathematical foundations, loss functions, ablation study \u0026amp; results docs/installation.md Environment setup, dependencies, dataset configuration docs/user_guide.md Training pipeline, speaker enrollment, real-time demo, result reproducibility docs/ax.md Agentic AI setup, workflows, tool chaining, and developer retrospective Source Code The complete source code is organized under src/:\ns ├ ├ ├ ├ │ │ │ │ │ │ ├ │ │ │ ├ │ │ │ ├ │ │ │ └ r ─ ─ ─ ─ ─ ─ ─ ─ c ─ ─ ─ ─ ─ ─ ─ ─ / c t d m ├ ├ ├ ├ ├ └ d ├ ├ └ t ├ ├ └ e ├ ├ └ e └ o r e o ─ ─ ─ ─ ─ ─ a ─ ─ ─ r ─ ─ ─ v ─ ─ ─ n ─ n a m d ─ ─ ─ ─ ─ ─ t ─ ─ ─ a ─ ─ ─ a ─ ─ ─ r ─ f i o e a i l o i n . l b t f h s d / d a s n l d s / b a e l e g . p s c e i e c i a u y i o i c e b x l n . p y / _ m l a o s t g n n s s h n l p m r p y r p m d r e a m t g s e e c a o e o y e o . s e n s e h / e n d h t r n l s r p . r t e n e s t u m i t t l n a y p . _ t t t . a l a o . / . e l y p v s a i p n e r n p p t . y 2 . t c y g r k . y y . p . p i . l . . p p y p y o p e p p y y y n y . y y s p . y p y # # # # # # # # # # # # # # # # # # # # # # # # H M R B S M F C D U A G R D L A G G E F C O O P y u e C h a i a u n u S I S o A R R v u o N f r p l a - a m L u a i d C R P s M L L a l m N f o e t l R r b M s l f i , , - s - l l p X l t r i - e e a a - i o b S a l u o i o p - t s d c l g e V M a f o u a a K n e n t a p i N S o a d l o U s u f t m t P e x e y r h m e e S n C t o x S e n t o b i I n p p a a e t n M d o e m a C A d c m g d o t o s e m s c i n o d e N t a r a n e - r p e e s e o / t f s d e l e i x a v w t e e t t n d i o c e r e n n o , d r \u0026amp; a i a x e t r c e D o r o l s b o r n a l s \u0026amp; k t r r e o r i n m r , i o s P f m b u e e r s a a d l i e e a \u0026amp; s l r u p e a I r a i m e b a n r r s L e l \u0026amp; o n - n t a N / c \u0026amp; n i r a t g s a i , m t c u c i b T k t i n , c e ( w e u b e d o t p h o l 8 e i a n g k d l p i m g r S n i t i m n a y o r g t b a h t b m i p t s y o s a t q w n c d e o C y o h l e P e e p n c r ( i u o h e e m n o e n y n h c a n i h k T o a r w i n t p e n r e E t r A u t c + e i A n n d i t t e o v t M a a u g a a d n , t t e r c r 1 i A t s g m n l C u g s i e h c y t a D c i e m e g , L l F t z n t o l ) s o e n l U e A u a r D u p r f m n l n t e R B , d t o S r o b a + o o t a m e y i l P e i l l o p a , t e j M D o l n o l E t i d i n e I E n m a c t c b C h p e s o t c T e u o k a A i e r p n t e , n g n , c P n l s e i s t m t k A g i e o t l e r d - n d n i a n a u L e , m t t c a i p a e a t l t e K t n t e r D o c i h t r y o e ( u l ) n a s r o d p b s s e a s , a t e k i s s e o c r n o ) r e r Models Used Model Description License SpeechBrain ECAPA-TDNN Pre-trained speaker verification teacher Apache 2.0 Models Published Model Link Format Size DISENT-KWS 🤗 Hugging Face (free tier) PyTorch + ONNX 7 MB + 0.60 MB The model is hosted on Hugging Face\u0026rsquo;s free tier — no payment required to download or use.\nDatasets Used Dataset Usage Samples License Google Speech Commands v2 Keyword spotting pre-training 105K utterances CC BY 4.0 VoxCeleb1 Speaker verification training 153K utterances (1,251 speakers) CC BY 4.0 LibriPhrase Hard-negative triplet pairs (up to 3K triplets generated from metadata) Apache 2.0 MUSAN Noise augmentation 109 hrs CC BY 4.0 Datasets Published No custom datasets were published. All datasets listed above are publicly available.\nArchitecture Overview The system uses a dual-head disentangled architecture built on a shared BC-ResNet-2 encoder (total 1.806M parameters):\nShared Encoder (BC-ResNet-2) — Broadcasted residual network, 33.8K params Temporal Block (Mamba SSM / Dilated Conv1D) — O(T) temporal context modeling, 10.3K params Phonetic Head (Causal Conformer) — Extracts keyword-discriminative embeddings zₚₕₙ ∈ ℝ¹⁹², 1,673K params Speaker Head (ECAPA-TDNN Lite) — Extracts speaker-discriminative embeddings zₛₚₖ ∈ ℝ¹⁹², 88.8K params Disentanglement Module (GRL + CLUB) — Adversarial gradient reversal + mutual information minimization forces zₚₕₙ ⟂ zₛₚₖ Dual-Gate Scorer — Weighted cosine similarity (w_kw=0.30, w_spk=0.65) with EMA smoothing and DET-calibrated threshold (τ=0.2222) Three-Layer Defense Against False Accepts Layer Mechanism Failure Mode Blocked ① FiLM Conditioning Directs attention toward enrolled speaker/keyword ② GRL + CLUB Disentanglement Prevents speaker ID leaking into phonetic embeddings ③ Dual-Gate Scoring Both keyword AND speaker must match independently Final Performance Benchmarks Evaluated on Google Speech Commands v2 test set (11,005 samples, 35 classes) and VoxCeleb1 (1,251 speakers). Scorer weights calibrated via joint verification grid search over 10×10 weight combinations + DET-driven threshold selection.\nMetric Achieved Target Status Parameters 1.806 M \u0026lt; 3.0 M ✅ ONNX Model Size 0.60 MB (INT8) — ✅ CPU Latency 26.43 ms (p95: 28.29 ms) \u0026lt; 200 ms ✅ Real-Time Factor (xRT) 0.0132 \u0026lt; 0.20 ✅ Keyword EER (standalone) 4.69% low ✅ Speaker EER (standalone) 17.86% low ✅ Joint EER 23.47% — — Joint AUC 0.8425 — ✅ Optimal Scorer Weights wₖw=0.30, wₛₚₖ=0.65 — ✅ EER Threshold (τ) 0.2222 — ✅ Detection Error Trade-off (DET) Curve SNR Robustness Across -5 dB to 30 dB Ablation Study Results To isolate each component\u0026rsquo;s contribution, we systematically disabled modules and re-evaluated:\nConfiguration Keyword EER (%) Speaker EER (%) Params Impact Full Model (baseline) 4.69 17.33 1.806 M — No FiLM Conditioning 4.69 17.33 1.683 M Saves 123K params, no EER change on this test set No Speaker Head 4.69 N/A 1.806 M KWS-only mode; speaker verification disabled No Temporal Block 11.22 25.48 1.796 M 🔴 Keyword EER ↑6.53pp, Speaker EER ↑8.15pp Equal Scorer Weights 4.69 17.33 1.806 M Calibrated 0.30/0.65 \u0026gt; equal 0.50/0.50 Key Insight: The temporal block is the single most critical component — removing it degrades keyword EER by 2.4× and speaker EER by 1.5×.\nAttribution This project builds upon and transfers weights from the open-source SpeechBrain repository (ECAPA-TDNN for speaker verification).\nCitation If you use DISENT-KWS in your research, please cite it:\n@misc{bt2026disentkws, title={DISENT-KWS: Speech Disentanglement for Robust Custom Word Detection}, author={Banerjee, Sohini and Tripathi, Swarnim}, year={2026}, howpublished={\\url{https://github.com/tripathiji1312/DISENT_KWS}}, note={BC-ResNet-2 backbone with Causal Conformer phonetic head, ECAPA-TDNN Lite speaker head, and GRL+CLUB disentanglement. 1.806M parameters.} } Novel Contributions Developed for This Solution Innovation Description Decoupled Dual-Head Architecture Separate Causal Conformer (phonetic) and ECAPA-TDNN Lite (speaker) heads on a shared BC-ResNet-2 backbone Feature Disentanglement Gradient Reversal Layer (GRL) + CLUB Mutual Information estimator enforces orthogonal latent spaces Dual-Gate Scorer Weighted cosine similarity (wₖw=0.30, wₛₚₖ=0.65) + EMA smoothing for stable real-time streaming Calibration Pipeline Grid-searched scorer weights + DET curve-driven threshold selection (τ=0.2222) Rejection Loss Contrastive triplet loss with hard-negative mining from LibriPhrase for confuser rejection GE2E Speaker Fine-tuning Generalized End-to-End loss for speaker head refinement in Phase 3 Mamba SSM Fallback Automatic fallback from Mamba to Dilated Conv1D for cross-platform compatibility ","permalink":"http://blog.swarnim.site/ref/readme/","summary":"\u003ch1 id=\"disent-kws--speech-disentanglement-for-robust-custom-word-detection\"\u003eDISENT-KWS — Speech Disentanglement for Robust Custom Word Detection\u003c/h1\u003e\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/training_phases.png\" alt=\"DISENT-KWS Training Pipeline\" width=\"800\"/\u003e\n\u003c/p\u003e\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Parameters-1.806M-green\" alt=\"1.806M params\"/\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Keyword%20EER-4.69%25-green\" alt=\"4.69% Keyword EER\"/\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Joint%20AUC-0.8425-green\" alt=\"0.8425 AUC\"/\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Model%20Size-0.60%20MB-blue\" alt=\"0.60 MB ONNX\"/\u003e\n  \u003cimg src=\"https://img.shields.io/badge/xRT-0.0132-blue\" alt=\"0.0132 real-time factor\"/\u003e\n  \u003cimg src=\"https://img.shields.io/badge/License-MIT-yellow\" alt=\"MIT License\"/\u003e\n\u003c/p\u003e\n\u003chr\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eDemo Video Link\u003c/strong\u003e — \u003ca href=\"https://youtu.be/dKjWTKI60Rw\"\u003eYouTube Demo Video\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSetup \u0026amp; Result Reproducibility Video Link\u003c/strong\u003e — \u003ca href=\"https://youtu.be/eU0mwZUcVaE\"\u003eYouTube Setup Video\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"quick-start\"\u003eQuick Start\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Install dependencies\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003euv sync --all-extras\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Run tests (verify 60+ tests pass)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emake test\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Option A: Live record 5 utterances from mic, then enroll\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epython src/demo.py record --model model_final.pt --out enrollment.pt\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Option B: Provide pre-recorded WAV files\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epython src/demo.py enroll \u003cspan style=\"color:#ae81ff\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003e\u003c/span\u003e    --recordings ./my_recordings/*.wav \u003cspan style=\"color:#ae81ff\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003e\u003c/span\u003e    --model model_final.pt \u003cspan style=\"color:#ae81ff\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003e\u003c/span\u003e    --out enrollment.pt\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Real-time detection\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epython src/demo.py detect --enrollment enrollment.pt --auto-threshold --vad-threshold 0.02\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"project-artifacts\"\u003eProject Artifacts\u003c/h2\u003e\n\u003ch3 id=\"technical-documentation\"\u003eTechnical Documentation\u003c/h3\u003e\n\u003cp\u003eAll technical documentation is organized in the \u003ca href=\"docs/\"\u003e\u003ccode\u003edocs/\u003c/code\u003e\u003c/a\u003e directory:\u003c/p\u003e","title":""},{"content":"Speech Disentanglement for Robust Custom Word Detection: Solution Architecture and Theoretical Foundations This document serves as a comprehensive technical treatise on the mathematical, architectural, and optimization foundations of the DISENT-KWS system.\n1. Problem Formulation and Theoretical Constraints Let $x(t)$ be a monaural, continuous-time acoustic signal recorded in a noisy reverberant environment. The signal is modeled as:\n$$x(t) = \\left( s_T(t) * h_T(t) \\right) + \\sum_{i=1}^{I} \\left( s_i(t) * h_i(t) \\right) + n(t)$$Where:\n$s_T(t)$ is the dry acoustic source signal of the target enrolled speaker. $s_i(t)$ represents the $i$-th interfering background speaker. $h_T(t)$ and $h_i(t)$ are the Room Impulse Responses (RIR) representing the acoustic paths from the respective sources to the microphone. $*$ denotes the mathematical convolution operator. $n(t)$ is additive environmental noise (e.g., babble, crowd, or traffic noise). The objective is to design a mapping $f(x) \\to D \\in {0, 1}$ that satisfies:\n$$D = 1 \\iff \\left( \\mathcal{K}(x) = k_T \\right) \\land \\left( \\mathcal{S}(x) = s_T \\right)$$Where $\\mathcal{K}(x)$ identifies the phonetic content (keyword), $k_T$ is the target custom word, $\\mathcal{S}(x)$ identifies the speaker identity, and $s_T$ is the target enrolled speaker.\nThe mapping must satisfy the following joint operational constraints:\nParameter Budget: $\\Theta(f) \u0026lt; 3.0 \\times 10^6$ parameters. Real-Time Factor (xRT): Let $\\Delta \\tau$ be the execution time of the model on a CPU for an audio segment of duration $T$. The real-time factor is bounded by: $$ \\text{xRT} = \\frac{\\Delta \\tau}{T} \u003c 0.20 $$ Robustness: Keyword and speaker verification performance must generalize across signal-to-noise ratios $\\text{SNR} \\in [-5, 30]\\text{ dB}$. Latency: End-to-end inference latency $\u0026lt; 200\\text{ ms}$ on a CPU for $2\\text{s}$ audio windows. 2. Acoustic Front-End Feature Extraction The raw digital waveform $x[n]$ sampled at $f_s = 16\\text{ kHz}$ is framed using a sliding Hamming window $w[n]$ of duration $25\\text{ ms}$ ($N = 400$ samples) with a hop size of $10\\text{ ms}$ ($R = 160$ samples).\nFor each frame $t$, the Short-Time Fourier Transform (STFT) is defined as:\n$$X(t, k) = \\sum_{n=0}^{N-1} x[t R + n] \\cdot w[n] \\cdot e^{-j \\frac{2 \\pi k n}{N}}$$The power spectrum $|X(t, k)|^2$ is mapped to a Mel-scale filterbank. The transformation from linear frequency $f$ to Mel frequency $m$ is defined by:\n$$m = 2595 \\cdot \\log_{10}\\left(1 + \\frac{f}{700}\\right)$$Applying a bank of $M = 80$ triangular filters $H_m(k)$, we compute the Log Filter-Bank Energies (LFBE):\n$$\\text{LFBE}(t, m) = \\log \\left( \\sum_{k=0}^{N/2} |X(t, k)|^2 \\cdot H_m(k) + \\epsilon \\right)$$Where $\\epsilon = 10^{-6}$ is a stability term to prevent logarithmic divergence. The resulting representation for a $2$-second audio segment is a tensor $\\mathbf{X} \\in \\mathbb{R}^{1 \\times 80 \\times 200}$.\n3. Neural Architecture Design ┌ │ │ └ ─ ─ ─ P ─ z ─ h ( ─ _ ─ o C ─ p ─ n o ─ h ─ e n ─ n ─ t f ─ ┌ ▼ ─ i o ┬ ▼ ∈ │ └ ─ ─ c r ─ ─ ─ ─ m ─ ℝ ─ ─ ─ H e ─ ^ ─ ─ ─ e r ─ 1 ─ ─ ─ a ) ─ 9 ─ ─ ─ d ─ 2 ─ ─ ─ ─ ─ ─ ┐ │ │ ┘ ─ ─ ─ ┌ │ │ └ ┌ │ └ ┌ │ └ ─ ─ ┌ │ └ ┌ │ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ G ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ R ─ ─ ─ ─ ─ ─ B ─ ─ D ─ ─ ─ ─ L ─ ─ ─ ─ ─ ─ C ─ ─ i ─ ─ ┐ ▼ ─ ─ ─ w ─ ─ A ─ ─ - ─ ─ l ─ ─ ─ \u0026amp; ─ ─ D _ ─ ─ c ─ ─ R ─ ─ a ┬ │ ┘ ─ ─ ─ u k ─ ─ o X ─ ─ e ─ ─ t ─ ─ C ─ ─ a w ─ ─ u ─ ─ s ─ ─ e ─ ─ L ─ ─ l = ─ D ─ s ∈ ─ ─ N ─ ─ d ─ ─ U ─ ─ - 0 ─ e ─ t ─ ─ e ─ ─ ─ ─ B ─ ─ G . ─ c ─ i ℝ ─ ─ t ─ ─ C ─ ─ ─ ─ a 3 ─ i ─ c ^ ─ ─ - ─ ─ o ─ ─ M ─ ─ t 0 ─ s ─ ( ─ ─ 2 ─ ─ n ─ ─ I ─ ─ e , ─ i ─ I B ┬ ▼ ─ ┬ ▼ ─ v ─ ─ ┬ ▼ ─ ┬ ▼ o ─ n ─ ─ S ─ ─ ─ ─ D ─ ─ S w ─ n ─ p × ─ ─ h ─ ─ T ─ ─ i ─ ─ c _ ─ ─ u ─ ─ a ─ ─ e ─ ─ s ─ ─ o s ─ ( ─ t 8 ─ ─ r ─ ─ m ─ ─ e ─ ─ r p ─ 0 ─ 0 ─ ─ e ─ ─ p ─ ─ n ─ ─ e k ─ / ─ ( ─ ─ d ─ ─ o ─ ─ t ─ ─ r = ─ 1 ─ L × ─ ─ ─ ─ r ─ ─ a ─ ─ 0 ─ ) ─ F ─ ─ E ─ ─ a ┬ │ └ ─ n ─ ─ . ─ ─ B T ─ ─ n ─ ─ l ─ ─ ─ g ─ ─ 6 ─ ─ E ) ─ ─ c ─ ─ ─ ─ ┌ ▼ ─ l ─ ─ 5 ─ ─ ) ─ ─ o ─ ─ B ─ ─ ─ ─ e ─ ─ ─ ─ ─ ─ d ─ ─ l ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ k ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ │ ┘ ┐ │ ┘ ┐ │ ┘ ─ ─ ┐ │ ┘ ┐ │ │ ┘ ─ ┌ │ │ └ ─ ─ ─ ─ ─ ─ ─ S ( ─ z ─ ─ ─ p E ─ _ ─ ─ ─ e C ─ s ─ ─ ─ a A ─ p ─ ─ ─ k P ─ k ─ ─ ─ e A ─ ─ ┐ ▼ ─ r - ┬ ▼ ∈ │ ┘ ─ T ─ ─ H D ─ ℝ ─ e N ─ ^ ─ a N ─ 1 ─ d ) ─ 9 ─ ─ 2 ─ ─ ┐ │ │ ┘ 3.1 Shared Encoder: Broadcasted Residual Network (BC-ResNet-2) To extract robust local features while minimizing computation, we employ a Broadcasted Residual Network (BC-ResNet) variant.\nLet $\\mathbf{x} \\in \\mathbb{R}^{C_{in} \\times F \\times T}$ be the input to a Broadcasted Residual Block (BCResBlock). The block processes features along two pathways:\nFrequency Broadcast Pathway: A 2D convolution operates on the frequency-time plane to capture local acoustic structures: $$ \\mathbf{y}_{2D} = \\text{ReLU}\\left(\\text{BatchNorm2D}\\left(\\text{Conv2D}(\\mathbf{x}; \\mathbf{W}_{2D})\\right)\\right) $$ Temporal Context Pathway: The frequency dimension is compressed via Average Pooling, and a 1D convolution captures temporal patterns: $$ \\mathbf{y}_{1D} = \\text{ReLU}\\left(\\text{BatchNorm1D}\\left(\\text{Conv1D}\\left(\\frac{1}{F}\\sum_{f=1}^F \\mathbf{x}[:, f, :]; \\mathbf{W}_{1D}\\right)\\right)\\right) $$ The temporal feature map $\\mathbf{y}{1D} \\in \\mathbb{R}^{C{out} \\times T}$ is broadcasted along the frequency dimension and combined with the 2D path:\n$$ \\mathbf{y}_{block} = \\mathbf{y}_{2D} + \\text{Broadcast}\\left(\\mathbf{y}_{1D}, \\text{target\\_shape}=(C_{out}, F', T)\\right) $$This mechanism allows the model to compute global temporal statistics while retaining spatial-frequency context using only $33.8\\text{ K}$ parameters.\n3.2 Phonetic Head: Causal Conformer The phonetic head maps the intermediate representations to a content-discriminative embedding $\\mathbf{z}_{phn} \\in \\mathbb{R}^{192}$. It comprises Causal Conformer blocks, which merge the global context modeling of Multi-Head Self-Attention (MHSA) with the local extraction of Convolutional modules.\nA Conformer block contains four modules arranged in a Macaron-style structure:\n$$\\tilde{\\mathbf{x}}_i = \\mathbf{x}_{i-1} + \\frac{1}{2} \\text{FFN}(\\mathbf{x}_{i-1})$$$$\\mathbf{x}'_i = \\tilde{\\mathbf{x}}_i + \\text{MHSA}(\\tilde{\\mathbf{x}}_i)$$$$\\mathbf{x}''_i = \\mathbf{x}'_i + \\text{CausalConv1D}(\\mathbf{x}'_i)$$$$\\mathbf{x}_i = \\text{LayerNorm}\\left(\\mathbf{x}''_i + \\frac{1}{2} \\text{FFN}(\\mathbf{x}''_i)\\right)$$Causal Attention Constraint: To prevent the model from looking at future frames during streaming real-time inference, the Multi-Head Self-Attention module employs a causal masking matrix $\\mathbf{M} \\in \\mathbb{R}^{T \\times T}$:\n$$A_{i,j} = \\frac{\\mathbf{q}_i \\mathbf{k}_j^T}{\\sqrt{d_k}} + M_{i,j}$$Where:\n$$ M_{i,j} = \\begin{cases} 0 \u0026 \\text{if } j \\le i \\\\ -\\infty \u0026 \\text{if } j \u003e i \\end{cases} $$This ensures that the attention weights for future frames ($j \u0026gt; i$) resolve to exactly zero after the softmax operation.\n3.3 Speaker Head: ECAPA-TDNN Lite The speaker head maps the representations to a speaker-discriminative embedding $\\mathbf{z}_{spk} \\in \\mathbb{R}^{192}$. It uses a 1D Squeeze-and-Excitation (SE) Dilated Conv block followed by Attentive Statistics Pooling (ASP).\nThe channel attention vector $\\mathbf{s} \\in \\mathbb{R}^C$ in the SE block is calculated as:\n$$\\mathbf{s} = \\sigma\\left(\\mathbf{W}_2 \\cdot \\text{ReLU}\\left(\\mathbf{W}_1 \\cdot \\left(\\frac{1}{T}\\sum_{t=1}^T \\mathbf{h}_t\\right)\\right)\\right)$$Where $\\sigma(\\cdot)$ is the sigmoid function, and $\\mathbf{h}_t$ represents the frame-level activations. The channel-wise scaling is defined by:\n$$\\tilde{\\mathbf{h}}_t = \\mathbf{s} \\odot \\mathbf{h}_t$$Attentive Statistics Pooling (ASP): Instead of calculating simple arithmetic means and standard deviations, ASP calculates temporal attention weights $\\alpha_t$ to focus on speaker-distinct frames:\n$$e_t = \\mathbf{v}^T \\tanh\\left(\\mathbf{W} \\tilde{\\mathbf{h}}_t + \\mathbf{b}\\right)$$$$\\alpha_t = \\frac{e^{e_t}}{\\sum_{\\tau=1}^T e^{e_{\\tau}}}$$The attention-weighted mean vector $\\boldsymbol{\\mu}$ and standard deviation vector $\\boldsymbol{\\sigma}$ are computed as:\n$$\\boldsymbol{\\mu} = \\sum_{t=1}^T \\alpha_t \\tilde{\\mathbf{h}}_t$$$$\\boldsymbol{\\sigma} = \\sqrt{\\sum_{t=1}^T \\alpha_t \\left(\\tilde{\\mathbf{h}}_t - \\boldsymbol{\\mu}\\right)^2}$$The speaker embedding is then projected to the final dimension:\n$$\\mathbf{z}_{spk} = \\mathbf{W}_{proj} \\cdot [\\boldsymbol{\\mu}; \\boldsymbol{\\sigma}] + \\mathbf{b}_{proj}$$ 4. Theory of Speech Disentanglement Standard keyword and speaker models trained jointly share acoustic traits, meaning phonetic representations often retain speaker identity information. To achieve clean disentanglement in the feature-space, we implement an adversarial constraint and a variational mutual information bound.\n4.1 Gradient Reversal Layer (GRL) Let $G_{back}$ be the shared encoder, $G_{phn}$ be the phonetic head, and $D_{spk}$ be an auxiliary adversarial speaker classifier. The network optimizes the classification loss $L_{kw}$ and an adversarial speaker classification loss $L_{adv}$:\n$$E(G_{back}, G_{phn}, D_{spk}) = L_{kw}(G_{back}, G_{phn}) - \\lambda L_{adv}(G_{back}, D_{spk})$$To train this end-to-end using standard backpropagation, we define the GRL mapping $R_\\lambda(\\mathbf{x})$:\nForward Propagation: $$ R_\\lambda(\\mathbf{x}) = \\mathbf{x} $$ Backward Propagation: $$ \\frac{d R_\\lambda(\\mathbf{x})}{d\\mathbf{x}} = -\\lambda \\mathbf{I} $$ During backward propagation, the gradients flowing from the adversarial speaker classifier are inverted and scaled by $-\\lambda$, forcing the shared encoder to delete speaker identity markers from the features sent to the phonetic head.\n4.2 Contrastive Log-ratio Upper Bound (CLUB) Mutual Information Minimizer The Mutual Information (MI) between the phonetic representation $\\mathbf{Z}{phn}$ and the speaker representation $\\mathbf{Z}{spk}$ is defined as:\n$$I(\\mathbf{Z}_{phn}; \\mathbf{Z}_{spk}) = \\mathbb{E}_{P(\\mathbf{Z}_{phn}, \\mathbf{Z}_{spk})} \\left[ \\log \\frac{P(\\mathbf{Z}_{phn} | \\mathbf{Z}_{spk})}{P(\\mathbf{Z}_{phn})} \\right]$$Since the true conditional probability $P(\\mathbf{Z}{phn} | \\mathbf{Z}{spk})$ is intractable, we approximate it using a variational neural network estimator $q_\\theta(\\mathbf{z}{phn} | \\mathbf{z}{spk})$. The CLUB upper bound estimator for a batch of size $B$ is formulated as:\n$$I_{CLUB}(\\mathbf{Z}_{phn}; \\mathbf{Z}_{spk}) = \\frac{1}{B} \\sum_{i=1}^B \\left[ \\log q_\\theta(\\mathbf{z}_{phn, i} | \\mathbf{z}_{spk, i}) - \\frac{1}{B} \\sum_{j=1}^B \\log q_\\theta(\\mathbf{z}_{phn, j} | \\mathbf{z}_{spk, i}) \\right]$$The optimization alternates between two steps:\nEstimator Update: Maximize the variational log-likelihood to fit the conditional distribution: $$ \\max_\\theta \\frac{1}{B} \\sum_{i=1}^B \\log q_\\theta(\\mathbf{z}_{phn, i} | \\mathbf{z}_{spk, i}) $$ Feature Disentanglement: Minimize the CLUB mutual information bound with respect to the encoder parameters: $$ \\min_{\\Theta} I_{CLUB}(\\mathbf{Z}_{phn}; \\mathbf{Z}_{spk}) $$ This minimizer penalizes statistical dependencies between $\\mathbf{z}{phn}$ and $\\mathbf{z}{spk}$, forcing the embedding heads to represent independent acoustic attributes.\n5. Loss Functions and Training Stages The system is trained using a multi-loss optimization function across four stages: Phase 1 (softmax pre-training), Phase 2 (disentanglement \u0026amp; joint fine-tuning), Phase 3a (GE2E speaker refinement), and Phase 3b (hard-negative GE2E). The complete training pipeline is illustrated below:\n5.1 Additive Angular Margin (AAM-Softmax) Loss Used in Phase 1 to train classification boundaries. The loss function projects embeddings onto a hypersphere and introduces an angular margin $m$:\n$$L_{AAM} = -\\frac{1}{B} \\sum_{i=1}^B \\log \\frac{e^{s \\cdot \\cos(\\theta_{y_i} + m)}}{e^{s \\cdot \\cos(\\theta_{y_i} + m)} + \\sum_{j \\neq y_i} e^{s \\cdot \\cos(\\theta_j)}}$$Where $\\theta_j$ is the angle between the embedding vector and the class weight vector $\\mathbf{W}_j$, $s$ is the scaling factor, and $m$ is the angular margin.\n5.2 Prototypical Triplet and Rejection Loss In Phase 2, we introduce triplet and rejection losses to reject unauthorized speakers and phonetically similar words. Let $\\mathbf{z}_a$ be an anchor keyword embedding, $\\mathbf{z}_p$ a positive (same keyword, same speaker) embedding, and $\\mathbf{z}_n$ a negative (confuser keyword or unauthorized speaker) embedding.\nWe optimize the triplet loss:\n$$L_{triplet} = \\max\\left(0, \\|\\mathbf{z}_a - \\mathbf{z}_p\\|_2^2 - \\|\\mathbf{z}_a - \\mathbf{z}_n\\|_2^2 + \\alpha\\right)$$To handle out-of-vocabulary and unauthorized speakers, we define the rejection loss $L_{reject}$:\n$$L_{reject} = \\max\\left(0, \\gamma - \\|\\mathbf{z}_a - \\mathbf{z}_{confuser}\\|_2^2\\right)$$Where $\\gamma$ is the rejection safety margin.\n5.3 Generalized End-to-End (GE2E) Loss In Phase 3a, the speaker head is refined using the GE2E loss. For a batch of $N$ speakers each with $M$ utterances, let $\\mathbf{x}{ji}$ denote utterance $i$ from speaker $j$. The speaker embedding for utterance $i$ of speaker $j$ is $\\mathbf{e}{ji} = f_{spk}(\\mathbf{x}_{ji}) \\in \\mathbb{R}^{192}$. The centroid (mean embedding) for speaker $j$ excluding utterance $i$ is:\n$$\\mathbf{c}_{j}^{(-i)} = \\frac{1}{M-1} \\sum_{m \\neq i} \\mathbf{e}_{jm}$$The similarity matrix $\\mathbf{S}$ with entries $S_{ji,k} = w \\cdot \\cos(\\mathbf{e}_{ji}, \\mathbf{c}_k) + b$ (where $w, b$ are learnable scale/bias) is optimized via softmax:\n$$L_{GE2E} = -\\frac{1}{N \\cdot M} \\sum_{j=1}^N \\sum_{i=1}^M \\log \\frac{e^{S_{ji,j}}}{\\sum_{k=1}^N e^{S_{ji,k}}}$$5.4 Hard-negative GE2E Loss In Phase 3b, the GE2E loss is extended with hard-negative mining from LibriPhrase. For each anchor utterance, a phonetically similar confuser utterance from a different speaker is retrieved. The similarity of the anchor to this hard negative is explicitly penalized:\n$$L_{hard} = L_{GE2E} + \\beta \\cdot \\max\\left(0, \\cos(\\mathbf{e}_{anchor}, \\mathbf{e}_{hardneg}) - \\delta\\right)$$Where $\\beta$ is the hard-negative weight and $\\delta$ is the margin.\n6. Scorer Calibration and Verification At evaluation time, the similarity of the test embedding against the enrolled reference prototypes is measured using cosine similarity:\n$$\\text{Sim}(\\mathbf{z}_1, \\mathbf{z}_2) = \\frac{\\mathbf{z}_1 \\cdot \\mathbf{z}_2^T}{\\|\\mathbf{z}_1\\|_2 \\|\\mathbf{z}_2\\|_2}$$The Dual-Gate Scorer combines the similarities linearly:\n$$\\text{Score} = w_{kw} \\cdot \\text{Sim}(\\mathbf{z}_{phn}, \\mathbf{p}_{kw}) + w_{spk} \\cdot \\text{Sim}(\\mathbf{z}_{spk}, \\mathbf{p}_{spk})$$Using grid search on joint verification trials, we optimized the parameters to:\n$$w_{kw} = 0.30, \\quad w_{spk} = 0.65, \\quad \\tau_{EER} = 0.2222$$For streaming verification, the score is smoothed over time using an Exponential Moving Average (EMA) with coefficient $\\alpha_{ema} = 0.7$:\n$$\\bar{S}_t = \\alpha_{ema} \\cdot \\bar{S}_{t-1} + (1 - \\alpha_{ema}) \\cdot \\text{Score}_t$$The output trigger decision is evaluated as:\n$$D = \\begin{cases} 1 \u0026 \\text{if } \\bar{S}_t \\ge \\tau_{EER} \\\\ 0 \u0026 \\text{if } \\bar{S}_t \u003c \\tau_{EER} \\end{cases}$$ 7. Experimental Results and Ablation Study To evaluate the effectiveness of the disentangled representation learning and the dynamic scorer, we benchmarked the DISENT-KWS model on the test sets of Google Speech Commands v2 (11,005 samples) and VoxCeleb1 (1,251 speakers). The optimal scorer calibration was obtained via exhaustive grid search over $10 \\times 10$ weight combinations, yielding $w_{kw}=0.30$, $w_{spk}=0.65$, and $\\tau_{EER}=0.2222$.\n7.1 Quantitative Benchmark Results Metric Value Notes Parameters 1.806 M Within the $\u0026lt; 3.0$ M budget ✅ ONNX Model Size 0.60 MB INT8 quantized export CPU Latency 26.43 ms p95: 28.29 ms, $\u0026lt; 200$ ms target ✅ Real-Time Factor (xRT) 0.0132 Well under $\u0026lt; 0.20$ budget ✅ Keyword EER (standalone) 4.69% Evaluated on 35-class GSC v2 test set (11,005 samples) Speaker EER (standalone) 17.86% Evaluated on VoxCeleb1 (1,251 speakers) Joint EER 23.47% Combined keyword + speaker verification trials Joint AUC 0.8425 Area under the joint DET curve Optimal Weights wₖw=0.30, wₛₚₖ=0.65 Grid-searched over $10 \\times 10$ combinations EER Threshold (τ) 0.2222 Operating point at equal error rate The Detection Error Trade-off (DET) curve illustrating the False Reject Rate (FRR) against the False Acceptance Rate (FAR) under joint keyword and speaker verification trials is shown below:\nThe parameter budget allocation across model components is visualized below:\n7.2 Ablation Study To measure the individual contributions of each architectural component, we systematically disabled modules and re-evaluated on the same test configuration:\nConfiguration Keyword EER (%) Speaker EER (%) Parameters Observations Full Model (baseline) 4.69 17.33 1.806 M Complete system with all components enabled. No FiLM Conditioning 4.69 17.33 1.683 M FiLM removal saves 123K parameters but does not affect EER on this test set. No Speaker Head 4.69 N/A 1.806 M KWS-only mode; speaker verification is entirely disabled. No Temporal Block 11.22 25.48 1.796 M Temporal context removal degrades both keyword (+6.53 pp) and speaker (+8.15 pp) EER significantly. Equal Scorer Weights 4.69 17.33 1.806 M Using $w_{kw}=0.50$, $w_{spk}=0.50$ instead of calibrated 0.30/0.65 weights. The ablation study results are also visualized in the chart below:\n7.3 SNR Robustness Evaluation To validate performance across varying acoustic conditions, we evaluated the system at signal-to-noise ratios from -5 dB to 30 dB using MUSAN noise (babble, music, environmental). The system maintains consistent keyword detection accuracy across the full SNR range, demonstrating the effectiveness of our multi-condition training strategy:\n8. Key Literature References Broadcasted Residual Networks: Kim, B. et al. (2021). BC-ResNet: Broadcasted Residual Learning for Lightweight Noise-Robust Keyword Spotting. In Proc. Interspeech. Conformer Architectures: Gulati, A. et al. (2020). Conformer: Convolution-augmented Transformer for Speech Recognition. In Proc. Interspeech. ECAPA-TDNN: Desplanques, B. et al. (2020). ECAPA-TDNN: Emphasized Channel Attention, Propagation and Aggregation in TDNN Based Speaker Verification. In Proc. Interspeech. Variational Mutual Information Bounds: Cheng, P. et al. (2020). CLUB: A Contrastive Log-ratio Upper Bound of Mutual Information. In Proc. ICML. AAM-Softmax: Deng, J. et al. (2019). ArcFace: Additive Angular Margin Loss for Deep Face Recognition. In Proc. CVPR. SpeechBrain Toolkit: Ravanelli, M. et al. (2021). SpeechBrain: A Multi-Task Speech Toolkit. arXiv preprint arXiv:2106.04624. ","permalink":"http://blog.swarnim.site/ref/solution_architecture/","summary":"\u003ch1 id=\"speech-disentanglement-for-robust-custom-word-detection-solution-architecture-and-theoretical-foundations\"\u003eSpeech Disentanglement for Robust Custom Word Detection: Solution Architecture and Theoretical Foundations\u003c/h1\u003e\n\u003cp\u003eThis document serves as a comprehensive technical treatise on the mathematical, architectural, and optimization foundations of the \u003cstrong\u003eDISENT-KWS\u003c/strong\u003e system.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-problem-formulation-and-theoretical-constraints\"\u003e1. Problem Formulation and Theoretical Constraints\u003c/h2\u003e\n\u003cp\u003eLet $x(t)$ be a monaural, continuous-time acoustic signal recorded in a noisy reverberant environment. The signal is modeled as:\u003c/p\u003e\n$$x(t) = \\left( s_T(t) * h_T(t) \\right) + \\sum_{i=1}^{I} \\left( s_i(t) * h_i(t) \\right) + n(t)$$\u003cp\u003eWhere:\u003c/p\u003e","title":""},{"content":"Testing Guide for DISENT-KWS Overview This project uses pytest for unit testing, GitHub Actions for CI-CD, and pre-commit hooks for local code quality enforcement.\nAll dependencies are managed via uv (fast Python package installer). Make sure uv is installed before running tests.\nQuick Start Run All Tests Locally # Option 1: Using make make test # Option 2: Using uv directly uv run pytest tests/ -v # Option 3: Using the Python runner python scripts/test.py Run Tests with Coverage # Using make make test-cov # Using uv directly uv run pytest tests/ -v --cov=data --cov=models --cov=training --cov-report=html # Using the Python runner python scripts/test.py --coverage Run Specific Tests # Run only dataloader tests make test-dataloaders # or uv run pytest tests/test_dataloaders.py -v # Run specific test by name uv run pytest tests/test_dataloaders.py::TestLFBETransform::test_transform_1d_input -v # Run tests matching a pattern uv run pytest tests/ -k \u0026#34;lfbe\u0026#34; -v Test Structure t ├ ├ └ s ├ └ e ─ ─ ─ c ─ ─ s ─ ─ ─ r ─ ─ t i s t t p r t / _ e e t u e i s s s n s n t t / _ t i _ _ t . t d m e p _ a o s y _ t d t . a e s p l l . y o s s a . h d p e y r s . p y # # # # D M S P a o h y t d e t a e l h l l o l n o t s a e c r d s r u e t i n r s p n t e t r e f s o w t r i s t l h o c d a e l t a t i e l s e t d i n o g u t p u t Test Files tests/test_dataloaders.py Tests for data loading and feature extraction:\nTestLFBETransform: Log-mel filterbank feature extraction TestGSCDataset: Google Speech Commands dataset TestVoxCelebDataset: VoxCeleb speaker dataset TestLibriPhraseDataset: LibriPhrase triplet dataset TestDataLoadersIntegration: Integration tests Running Tests Locally Prerequisites # Install dependencies using uv uv sync --all-extras Available Commands Using Makefile make help # Show all available commands make test # Run all tests make test-cov # Run with coverage report make test-v # Verbose output make test-dataloaders # Run only dataloader tests make lint # Run flake8 make format # Auto-format code make install # Sync dependencies with uv make pre-commit-install # Setup pre-commit hooks make clean # Remove build artifacts Using uv directly uv run pytest tests/ -v # Run all tests uv run pytest tests/test_dataloaders.py -v # Run specific test file uv run pytest tests/ -k test_lfbe -v # Run tests matching pattern uv run pytest tests/ -m \u0026#34;not slow\u0026#34; -v # Skip slow tests uv run pytest tests/ -v --tb=short # Short traceback format uv run pytest tests/ -v --tb=long # Long traceback format Using Python scripts python scripts/test.py # Basic tests python scripts/test.py --coverage # With coverage report python scripts/test.py --lint # Include linting python scripts/test.py --fast # Fast tests only Using shell script bash scripts/run_tests.sh # Basic tests bash scripts/run_tests.sh --coverage # With coverage bash scripts/run_tests.sh --lint # Include linting GitHub Actions CI-CD Workflow: .github/workflows/test.yml Runs automatically on:\nPush to main or develop branches Pull requests targeting main or develop Tests on:\nPython 3.10, 3.11, 3.12 Ubuntu Linux Steps:\nLinting: flake8 checks Testing: pytest with coverage Coverage Upload: to Codecov View Results Go to your GitHub repository Click Actions tab Click the workflow run to see detailed logs Local CI Simulation To test the same workflow locally:\n# Run linting + tests + coverage (simulates CI) python scripts/test.py --lint --coverage Pre-commit Hooks Pre-commit hooks automatically run checks before each commit.\nSetup # Install pre-commit hooks make pre-commit-install # or uv run pre-commit install What Runs black: Code formatting isort: Import sorting flake8: Linting mypy: Type checking pytest: Unit tests Bypass Hooks (if needed) git commit --no-verify Writing New Tests Structure import pytest import torch class TestMyFeature: \u0026#34;\u0026#34;\u0026#34;Test suite for my feature.\u0026#34;\u0026#34;\u0026#34; @pytest.fixture def setup_data(self): \u0026#34;\u0026#34;\u0026#34;Fixture for test setup.\u0026#34;\u0026#34;\u0026#34; return torch.randn(10, 80, 200) def test_my_feature_basic(self, setup_data): \u0026#34;\u0026#34;\u0026#34;Test basic functionality.\u0026#34;\u0026#34;\u0026#34; assert setup_data.shape == (10, 80, 200) @pytest.mark.parametrize(\u0026#34;param\u0026#34;, [1, 2, 3]) def test_my_feature_parametrized(self, param): \u0026#34;\u0026#34;\u0026#34;Parametrized test.\u0026#34;\u0026#34;\u0026#34; assert param \u0026gt; 0 Fixtures Common fixtures (add to conftest.py if needed):\n@pytest.fixture def lfbe_transform(): \u0026#34;\u0026#34;\u0026#34;LFBETransform instance.\u0026#34;\u0026#34;\u0026#34; from data.datasets import LFBETransform return LFBETransform() @pytest.fixture def dummy_audio(): \u0026#34;\u0026#34;\u0026#34;Dummy 2-second audio.\u0026#34;\u0026#34;\u0026#34; return torch.randn(1, 32000) Markers @pytest.mark.slow def test_slow_operation(): \u0026#34;\u0026#34;\u0026#34;Skip with: pytest -m \u0026#39;not slow\u0026#39;\u0026#34;\u0026#34;\u0026#34; pass @pytest.mark.gpu def test_on_gpu(): \u0026#34;\u0026#34;\u0026#34;Run only on GPU machines.\u0026#34;\u0026#34;\u0026#34; pass Coverage Reports Generate Coverage Report make test-cov View HTML Report # Generate pytest tests/ --cov=data --cov=models --cov=training --cov-report=html # View in browser open htmlcov/index.html # macOS xdg-open htmlcov/index.html # Linux start htmlcov/index.html # Windows Troubleshooting Tests Fail with \u0026ldquo;ModuleNotFoundError\u0026rdquo; # Install the package in editable mode pip install -e . \u0026ldquo;torch\u0026rdquo; not found # Install PyTorch pip install torch torchaudio Pre-commit hooks fail # Format and fix issues make format # Then commit again git add . git commit -m \u0026#34;Auto-formatted\u0026#34; Coverage report is low # Check which files are missing coverage pytest tests/ --cov=data --cov-report=term-missing Continuous Integration Best Practices Run locally before pushing: make test Add tests for new features: Coverage \u0026gt; 80% Use descriptive test names: test_feature_with_specific_condition Test edge cases: Empty inputs, very large inputs, etc. Use fixtures for setup: Keep tests DRY Mark slow tests: Use @pytest.mark.slow Next Steps Add model tests (tests/test_models.py) Add integration tests (tests/test_integration.py) Add performance benchmarks in tests/test_benchmarks.py For questions, see pytest --help or visit pytest docs.\n","permalink":"http://blog.swarnim.site/ref/testing/","summary":"\u003ch1 id=\"testing-guide-for-disent-kws\"\u003eTesting Guide for DISENT-KWS\u003c/h1\u003e\n\u003ch2 id=\"overview\"\u003eOverview\u003c/h2\u003e\n\u003cp\u003eThis project uses \u003cstrong\u003epytest\u003c/strong\u003e for unit testing, \u003cstrong\u003eGitHub Actions\u003c/strong\u003e for CI-CD, and \u003cstrong\u003epre-commit hooks\u003c/strong\u003e for local code quality enforcement.\u003c/p\u003e\n\u003cp\u003eAll dependencies are managed via \u003cstrong\u003e\u003ccode\u003euv\u003c/code\u003e\u003c/strong\u003e (fast Python package installer). Make sure \u003ccode\u003euv\u003c/code\u003e is installed before running tests.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"quick-start\"\u003eQuick Start\u003c/h2\u003e\n\u003ch3 id=\"run-all-tests-locally\"\u003eRun All Tests Locally\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Option 1: Using make\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emake test\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Option 2: Using uv directly\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003euv run pytest tests/ -v\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Option 3: Using the Python runner\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epython scripts/test.py\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"run-tests-with-coverage\"\u003eRun Tests with Coverage\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Using make\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emake test-cov\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Using uv directly\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003euv run pytest tests/ -v --cov\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003edata --cov\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003emodels --cov\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003etraining --cov-report\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003ehtml\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Using the Python runner\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epython scripts/test.py --coverage\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"run-specific-tests\"\u003eRun Specific Tests\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Run only dataloader tests\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emake test-dataloaders\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# or\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003euv run pytest tests/test_dataloaders.py -v\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Run specific test by name\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003euv run pytest tests/test_dataloaders.py::TestLFBETransform::test_transform_1d_input -v\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Run tests matching a pattern\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003euv run pytest tests/ -k \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;lfbe\u0026#34;\u003c/span\u003e -v\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"test-structure\"\u003eTest Structure\u003c/h2\u003e\n\n\n\n\u003cdiv class=\"goat svg-container \"\u003e\n  \n    \u003csvg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      font-family=\"Menlo,Lucida Console,monospace\"\n      \n        viewBox=\"0 0 560 137\"\n      \u003e\n      \u003cg transform='translate(8,16)'\u003e\n\u003cpath d='M 32,24 L 40,24' fill='none' stroke='currentColor'\u003e\u003c/path\u003e\n\u003ctext text-anchor='middle' x='0' y='4' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='0' y='20' fill='currentColor' style='font-size:1em'\u003e├\u003c/text\u003e\n\u003ctext text-anchor='middle' x='0' y='36' fill='currentColor' style='font-size:1em'\u003e├\u003c/text\u003e\n\u003ctext text-anchor='middle' x='0' y='52' fill='currentColor' style='font-size:1em'\u003e└\u003c/text\u003e\n\u003ctext text-anchor='middle' x='0' y='84' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='0' y='100' fill='currentColor' style='font-size:1em'\u003e├\u003c/text\u003e\n\u003ctext text-anchor='middle' x='0' y='116' fill='currentColor' style='font-size:1em'\u003e└\u003c/text\u003e\n\u003ctext text-anchor='middle' x='8' y='4' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='8' y='20' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='8' y='36' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='8' y='52' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='8' y='84' fill='currentColor' style='font-size:1em'\u003ec\u003c/text\u003e\n\u003ctext text-anchor='middle' x='8' y='100' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='8' y='116' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='16' y='4' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='16' y='20' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='16' y='36' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='16' y='52' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='16' y='84' fill='currentColor' style='font-size:1em'\u003er\u003c/text\u003e\n\u003ctext text-anchor='middle' x='16' y='100' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='16' y='116' fill='currentColor' style='font-size:1em'\u003e─\u003c/text\u003e\n\u003ctext text-anchor='middle' x='24' y='4' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='24' y='84' fill='currentColor' style='font-size:1em'\u003ei\u003c/text\u003e\n\u003ctext text-anchor='middle' x='32' y='4' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='32' y='36' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='32' y='52' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='32' y='84' fill='currentColor' style='font-size:1em'\u003ep\u003c/text\u003e\n\u003ctext text-anchor='middle' x='32' y='100' fill='currentColor' style='font-size:1em'\u003er\u003c/text\u003e\n\u003ctext text-anchor='middle' x='32' y='116' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='40' y='4' fill='currentColor' style='font-size:1em'\u003e/\u003c/text\u003e\n\u003ctext text-anchor='middle' x='40' y='20' fill='currentColor' style='font-size:1em'\u003e_\u003c/text\u003e\n\u003ctext text-anchor='middle' x='40' y='36' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='40' y='52' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='40' y='84' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='40' y='100' fill='currentColor' style='font-size:1em'\u003eu\u003c/text\u003e\n\u003ctext text-anchor='middle' x='40' y='116' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='48' y='20' fill='currentColor' style='font-size:1em'\u003ei\u003c/text\u003e\n\u003ctext text-anchor='middle' x='48' y='36' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='48' y='52' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='48' y='84' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='48' y='100' fill='currentColor' style='font-size:1em'\u003en\u003c/text\u003e\n\u003ctext text-anchor='middle' x='48' y='116' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='56' y='20' fill='currentColor' style='font-size:1em'\u003en\u003c/text\u003e\n\u003ctext text-anchor='middle' x='56' y='36' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='56' y='52' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='56' y='84' fill='currentColor' style='font-size:1em'\u003e/\u003c/text\u003e\n\u003ctext text-anchor='middle' x='56' y='100' fill='currentColor' style='font-size:1em'\u003e_\u003c/text\u003e\n\u003ctext text-anchor='middle' x='56' y='116' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='64' y='20' fill='currentColor' style='font-size:1em'\u003ei\u003c/text\u003e\n\u003ctext text-anchor='middle' x='64' y='36' fill='currentColor' style='font-size:1em'\u003e_\u003c/text\u003e\n\u003ctext text-anchor='middle' x='64' y='52' fill='currentColor' style='font-size:1em'\u003e_\u003c/text\u003e\n\u003ctext text-anchor='middle' x='64' y='100' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='64' y='116' fill='currentColor' style='font-size:1em'\u003e.\u003c/text\u003e\n\u003ctext text-anchor='middle' x='72' y='20' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='72' y='36' fill='currentColor' style='font-size:1em'\u003ed\u003c/text\u003e\n\u003ctext text-anchor='middle' x='72' y='52' fill='currentColor' style='font-size:1em'\u003em\u003c/text\u003e\n\u003ctext text-anchor='middle' x='72' y='100' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='72' y='116' fill='currentColor' style='font-size:1em'\u003ep\u003c/text\u003e\n\u003ctext text-anchor='middle' x='80' y='20' fill='currentColor' style='font-size:1em'\u003e_\u003c/text\u003e\n\u003ctext text-anchor='middle' x='80' y='36' fill='currentColor' style='font-size:1em'\u003ea\u003c/text\u003e\n\u003ctext text-anchor='middle' x='80' y='52' fill='currentColor' style='font-size:1em'\u003eo\u003c/text\u003e\n\u003ctext text-anchor='middle' x='80' y='100' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='80' y='116' fill='currentColor' style='font-size:1em'\u003ey\u003c/text\u003e\n\u003ctext text-anchor='middle' x='88' y='20' fill='currentColor' style='font-size:1em'\u003e_\u003c/text\u003e\n\u003ctext text-anchor='middle' x='88' y='36' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='88' y='52' fill='currentColor' style='font-size:1em'\u003ed\u003c/text\u003e\n\u003ctext text-anchor='middle' x='88' y='100' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='96' y='20' fill='currentColor' style='font-size:1em'\u003e.\u003c/text\u003e\n\u003ctext text-anchor='middle' x='96' y='36' fill='currentColor' style='font-size:1em'\u003ea\u003c/text\u003e\n\u003ctext text-anchor='middle' x='96' y='52' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='96' y='100' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='104' y='20' fill='currentColor' style='font-size:1em'\u003ep\u003c/text\u003e\n\u003ctext text-anchor='middle' x='104' y='36' fill='currentColor' style='font-size:1em'\u003el\u003c/text\u003e\n\u003ctext text-anchor='middle' x='104' y='52' fill='currentColor' style='font-size:1em'\u003el\u003c/text\u003e\n\u003ctext text-anchor='middle' x='104' y='100' fill='currentColor' style='font-size:1em'\u003e.\u003c/text\u003e\n\u003ctext text-anchor='middle' x='112' y='20' fill='currentColor' style='font-size:1em'\u003ey\u003c/text\u003e\n\u003ctext text-anchor='middle' x='112' y='36' fill='currentColor' style='font-size:1em'\u003eo\u003c/text\u003e\n\u003ctext text-anchor='middle' x='112' y='52' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='112' y='100' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='120' y='36' fill='currentColor' style='font-size:1em'\u003ea\u003c/text\u003e\n\u003ctext text-anchor='middle' x='120' y='52' fill='currentColor' style='font-size:1em'\u003e.\u003c/text\u003e\n\u003ctext text-anchor='middle' x='120' y='100' fill='currentColor' style='font-size:1em'\u003eh\u003c/text\u003e\n\u003ctext text-anchor='middle' x='128' y='36' fill='currentColor' style='font-size:1em'\u003ed\u003c/text\u003e\n\u003ctext text-anchor='middle' x='128' y='52' fill='currentColor' style='font-size:1em'\u003ep\u003c/text\u003e\n\u003ctext text-anchor='middle' x='136' y='36' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='136' y='52' fill='currentColor' style='font-size:1em'\u003ey\u003c/text\u003e\n\u003ctext text-anchor='middle' x='144' y='36' fill='currentColor' style='font-size:1em'\u003er\u003c/text\u003e\n\u003ctext text-anchor='middle' x='152' y='36' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='160' y='36' fill='currentColor' style='font-size:1em'\u003e.\u003c/text\u003e\n\u003ctext text-anchor='middle' x='168' y='36' fill='currentColor' style='font-size:1em'\u003ep\u003c/text\u003e\n\u003ctext text-anchor='middle' x='176' y='36' fill='currentColor' style='font-size:1em'\u003ey\u003c/text\u003e\n\u003ctext text-anchor='middle' x='264' y='36' fill='currentColor' style='font-size:1em'\u003e#\u003c/text\u003e\n\u003ctext text-anchor='middle' x='264' y='52' fill='currentColor' style='font-size:1em'\u003e#\u003c/text\u003e\n\u003ctext text-anchor='middle' x='264' y='100' fill='currentColor' style='font-size:1em'\u003e#\u003c/text\u003e\n\u003ctext text-anchor='middle' x='264' y='116' fill='currentColor' style='font-size:1em'\u003e#\u003c/text\u003e\n\u003ctext text-anchor='middle' x='280' y='36' fill='currentColor' style='font-size:1em'\u003eD\u003c/text\u003e\n\u003ctext text-anchor='middle' x='280' y='52' fill='currentColor' style='font-size:1em'\u003eM\u003c/text\u003e\n\u003ctext text-anchor='middle' x='280' y='100' fill='currentColor' style='font-size:1em'\u003eS\u003c/text\u003e\n\u003ctext text-anchor='middle' x='280' y='116' fill='currentColor' style='font-size:1em'\u003eP\u003c/text\u003e\n\u003ctext text-anchor='middle' x='288' y='36' fill='currentColor' style='font-size:1em'\u003ea\u003c/text\u003e\n\u003ctext text-anchor='middle' x='288' y='52' fill='currentColor' style='font-size:1em'\u003eo\u003c/text\u003e\n\u003ctext text-anchor='middle' x='288' y='100' fill='currentColor' style='font-size:1em'\u003eh\u003c/text\u003e\n\u003ctext text-anchor='middle' x='288' y='116' fill='currentColor' style='font-size:1em'\u003ey\u003c/text\u003e\n\u003ctext text-anchor='middle' x='296' y='36' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='296' y='52' fill='currentColor' style='font-size:1em'\u003ed\u003c/text\u003e\n\u003ctext text-anchor='middle' x='296' y='100' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='296' y='116' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='304' y='36' fill='currentColor' style='font-size:1em'\u003ea\u003c/text\u003e\n\u003ctext text-anchor='middle' x='304' y='52' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='304' y='100' fill='currentColor' style='font-size:1em'\u003el\u003c/text\u003e\n\u003ctext text-anchor='middle' x='304' y='116' fill='currentColor' style='font-size:1em'\u003eh\u003c/text\u003e\n\u003ctext text-anchor='middle' x='312' y='52' fill='currentColor' style='font-size:1em'\u003el\u003c/text\u003e\n\u003ctext text-anchor='middle' x='312' y='100' fill='currentColor' style='font-size:1em'\u003el\u003c/text\u003e\n\u003ctext text-anchor='middle' x='312' y='116' fill='currentColor' style='font-size:1em'\u003eo\u003c/text\u003e\n\u003ctext text-anchor='middle' x='320' y='36' fill='currentColor' style='font-size:1em'\u003el\u003c/text\u003e\n\u003ctext text-anchor='middle' x='320' y='116' fill='currentColor' style='font-size:1em'\u003en\u003c/text\u003e\n\u003ctext text-anchor='middle' x='328' y='36' fill='currentColor' style='font-size:1em'\u003eo\u003c/text\u003e\n\u003ctext text-anchor='middle' x='328' y='52' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='328' y='100' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='336' y='36' fill='currentColor' style='font-size:1em'\u003ea\u003c/text\u003e\n\u003ctext text-anchor='middle' x='336' y='52' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='336' y='100' fill='currentColor' style='font-size:1em'\u003ec\u003c/text\u003e\n\u003ctext text-anchor='middle' x='336' y='116' fill='currentColor' style='font-size:1em'\u003er\u003c/text\u003e\n\u003ctext text-anchor='middle' x='344' y='36' fill='currentColor' style='font-size:1em'\u003ed\u003c/text\u003e\n\u003ctext text-anchor='middle' x='344' y='52' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='344' y='100' fill='currentColor' style='font-size:1em'\u003er\u003c/text\u003e\n\u003ctext text-anchor='middle' x='344' y='116' fill='currentColor' style='font-size:1em'\u003eu\u003c/text\u003e\n\u003ctext text-anchor='middle' x='352' y='36' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='352' y='52' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='352' y='100' fill='currentColor' style='font-size:1em'\u003ei\u003c/text\u003e\n\u003ctext text-anchor='middle' x='352' y='116' fill='currentColor' style='font-size:1em'\u003en\u003c/text\u003e\n\u003ctext text-anchor='middle' x='360' y='36' fill='currentColor' style='font-size:1em'\u003er\u003c/text\u003e\n\u003ctext text-anchor='middle' x='360' y='52' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='360' y='100' fill='currentColor' style='font-size:1em'\u003ep\u003c/text\u003e\n\u003ctext text-anchor='middle' x='360' y='116' fill='currentColor' style='font-size:1em'\u003en\u003c/text\u003e\n\u003ctext text-anchor='middle' x='368' y='100' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='368' y='116' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='376' y='36' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='376' y='116' fill='currentColor' style='font-size:1em'\u003er\u003c/text\u003e\n\u003ctext text-anchor='middle' x='384' y='36' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='384' y='100' fill='currentColor' style='font-size:1em'\u003ef\u003c/text\u003e\n\u003ctext text-anchor='middle' x='392' y='36' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='392' y='100' fill='currentColor' style='font-size:1em'\u003eo\u003c/text\u003e\n\u003ctext text-anchor='middle' x='392' y='116' fill='currentColor' style='font-size:1em'\u003ew\u003c/text\u003e\n\u003ctext text-anchor='middle' x='400' y='36' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='400' y='100' fill='currentColor' style='font-size:1em'\u003er\u003c/text\u003e\n\u003ctext text-anchor='middle' x='400' y='116' fill='currentColor' style='font-size:1em'\u003ei\u003c/text\u003e\n\u003ctext text-anchor='middle' x='408' y='36' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='408' y='116' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='416' y='100' fill='currentColor' style='font-size:1em'\u003el\u003c/text\u003e\n\u003ctext text-anchor='middle' x='416' y='116' fill='currentColor' style='font-size:1em'\u003eh\u003c/text\u003e\n\u003ctext text-anchor='middle' x='424' y='100' fill='currentColor' style='font-size:1em'\u003eo\u003c/text\u003e\n\u003ctext text-anchor='middle' x='432' y='100' fill='currentColor' style='font-size:1em'\u003ec\u003c/text\u003e\n\u003ctext text-anchor='middle' x='432' y='116' fill='currentColor' style='font-size:1em'\u003ed\u003c/text\u003e\n\u003ctext text-anchor='middle' x='440' y='100' fill='currentColor' style='font-size:1em'\u003ea\u003c/text\u003e\n\u003ctext text-anchor='middle' x='440' y='116' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='448' y='100' fill='currentColor' style='font-size:1em'\u003el\u003c/text\u003e\n\u003ctext text-anchor='middle' x='448' y='116' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='456' y='116' fill='currentColor' style='font-size:1em'\u003ea\u003c/text\u003e\n\u003ctext text-anchor='middle' x='464' y='100' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='464' y='116' fill='currentColor' style='font-size:1em'\u003ei\u003c/text\u003e\n\u003ctext text-anchor='middle' x='472' y='100' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='472' y='116' fill='currentColor' style='font-size:1em'\u003el\u003c/text\u003e\n\u003ctext text-anchor='middle' x='480' y='100' fill='currentColor' style='font-size:1em'\u003es\u003c/text\u003e\n\u003ctext text-anchor='middle' x='480' y='116' fill='currentColor' style='font-size:1em'\u003ee\u003c/text\u003e\n\u003ctext text-anchor='middle' x='488' y='100' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='488' y='116' fill='currentColor' style='font-size:1em'\u003ed\u003c/text\u003e\n\u003ctext text-anchor='middle' x='496' y='100' fill='currentColor' style='font-size:1em'\u003ei\u003c/text\u003e\n\u003ctext text-anchor='middle' x='504' y='100' fill='currentColor' style='font-size:1em'\u003en\u003c/text\u003e\n\u003ctext text-anchor='middle' x='504' y='116' fill='currentColor' style='font-size:1em'\u003eo\u003c/text\u003e\n\u003ctext text-anchor='middle' x='512' y='100' fill='currentColor' style='font-size:1em'\u003eg\u003c/text\u003e\n\u003ctext text-anchor='middle' x='512' y='116' fill='currentColor' style='font-size:1em'\u003eu\u003c/text\u003e\n\u003ctext text-anchor='middle' x='520' y='116' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003ctext text-anchor='middle' x='528' y='116' fill='currentColor' style='font-size:1em'\u003ep\u003c/text\u003e\n\u003ctext text-anchor='middle' x='536' y='116' fill='currentColor' style='font-size:1em'\u003eu\u003c/text\u003e\n\u003ctext text-anchor='middle' x='544' y='116' fill='currentColor' style='font-size:1em'\u003et\u003c/text\u003e\n\u003c/g\u003e\n\n    \u003c/svg\u003e\n  \n\u003c/div\u003e\n\u003ch3 id=\"test-files\"\u003eTest Files\u003c/h3\u003e\n\u003ch4 id=\"teststest_dataloaderspy\"\u003e\u003ccode\u003etests/test_dataloaders.py\u003c/code\u003e\u003c/h4\u003e\n\u003cp\u003eTests for data loading and feature extraction:\u003c/p\u003e","title":""}]