Skip to content

Commit bd9eeab

Browse files
committed
feat: implement CR-CTC (Consistency-Regularized CTC) loss
CR-CTC improves CTC training by computing KL divergence between encoder outputs from augmented and clean inputs. This enforces prediction consistency regardless of data augmentation. k2/icefall reports 3.98% CER on AISHELL-1 test with Zipformer + CR-CTC. Usage: Add model_conf.cr_ctc_weight=0.2 to enable CR-CTC during training. The clean encoder pass is done without SpecAug while the augmented pass uses the normal SpecAug pipeline. Also fixes: add_sos_and_eos function for EParaformer compatibility.
1 parent 3bc9018 commit bd9eeab

3 files changed

Lines changed: 98 additions & 0 deletions

File tree

funasr/losses/cr_ctc.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Consistency-Regularized CTC (CR-CTC) loss.
2+
3+
Based on: "Improving CTC-based Speech Recognition via Consistency Regularization"
4+
Key idea: Run encoder twice (with/without SpecAug), compute KL divergence between
5+
the two CTC outputs as a consistency regularization term.
6+
7+
Usage in training:
8+
cr_loss = cr_ctc_loss(ctc_logprobs_aug, ctc_logprobs_clean, input_lengths)
9+
total_loss = ctc_loss + cr_loss_scale * cr_loss
10+
"""
11+
12+
import torch
13+
import torch.nn.functional as F
14+
15+
16+
def cr_ctc_loss(
17+
log_probs_aug: torch.Tensor,
18+
log_probs_clean: torch.Tensor,
19+
input_lengths: torch.Tensor,
20+
) -> torch.Tensor:
21+
"""Compute CR-CTC consistency regularization loss.
22+
23+
Computes symmetric KL divergence between augmented and clean encoder outputs.
24+
25+
Args:
26+
log_probs_aug: CTC log probabilities from augmented input (B, T, V)
27+
log_probs_clean: CTC log probabilities from clean input (B, T, V)
28+
input_lengths: Valid lengths for each sample (B,)
29+
30+
Returns:
31+
Scalar loss value (mean over batch and time).
32+
"""
33+
batch_size, max_len, _ = log_probs_aug.shape
34+
35+
# Create mask for valid positions
36+
mask = torch.arange(max_len, device=input_lengths.device)[None, :] < input_lengths[:, None]
37+
mask = mask.unsqueeze(-1) # (B, T, 1)
38+
39+
# Convert log probs to probs for KL computation
40+
probs_aug = log_probs_aug.exp()
41+
probs_clean = log_probs_clean.exp()
42+
43+
# Symmetric KL divergence: 0.5 * (KL(p||q) + KL(q||p))
44+
# KL(p||q) = sum(p * (log_p - log_q))
45+
kl_aug_to_clean = (probs_aug * (log_probs_aug - log_probs_clean)) * mask
46+
kl_clean_to_aug = (probs_clean * (log_probs_clean - log_probs_aug)) * mask
47+
48+
# Mean over valid positions
49+
num_valid = mask.sum()
50+
if num_valid > 0:
51+
loss = 0.5 * (kl_aug_to_clean.sum() + kl_clean_to_aug.sum()) / num_valid
52+
else:
53+
loss = torch.tensor(0.0, device=log_probs_aug.device)
54+
55+
return loss

funasr/models/transformer/model.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
# from funasr.models.e2e_asr_common import ErrorCalculator
1515
from funasr.train_utils.device_funcs import force_gatherable
16+
from funasr.losses.cr_ctc import cr_ctc_loss
1617
from funasr.utils.load_utils import load_audio_text_image_video, extract_fbank
1718
from funasr.utils import postprocess_utils
1819
from funasr.utils.datadir_writer import DatadirWriter
@@ -127,6 +128,7 @@ def __init__(
127128
self.vocab_size = vocab_size
128129
self.ignore_id = ignore_id
129130
self.ctc_weight = ctc_weight
131+
self.cr_ctc_weight = kwargs.get("cr_ctc_weight", 0.0)
130132
self.specaug = specaug
131133
self.normalize = normalize
132134
self.encoder = encoder
@@ -235,6 +237,22 @@ def forward(
235237
# calculate whole encoder loss
236238
loss_ctc = (1 - self.interctc_weight) * loss_ctc + self.interctc_weight * loss_interctc
237239

240+
# CR-CTC: consistency regularization
241+
loss_cr_ctc = None
242+
if self.cr_ctc_weight > 0.0 and self.training and self.ctc_weight != 0.0:
243+
# Second forward pass WITHOUT SpecAug
244+
specaug_backup = self.specaug
245+
self.specaug = None
246+
encoder_out_clean, encoder_out_lens_clean = self.encode(speech, speech_lengths)
247+
self.specaug = specaug_backup
248+
if isinstance(encoder_out_clean, tuple):
249+
encoder_out_clean = encoder_out_clean[0]
250+
# Compute CTC log probs for both augmented and clean
251+
ctc_logprobs_aug = self.ctc.log_softmax(encoder_out)
252+
ctc_logprobs_clean = self.ctc.log_softmax(encoder_out_clean).detach()
253+
loss_cr_ctc = cr_ctc_loss(ctc_logprobs_aug, ctc_logprobs_clean, encoder_out_lens)
254+
stats["loss_cr_ctc"] = loss_cr_ctc.detach()
255+
238256
# decoder: Attention decoder branch
239257
loss_att, acc_att, cer_att, wer_att = self._calc_att_loss(
240258
encoder_out, encoder_out_lens, text, text_lengths
@@ -248,6 +266,10 @@ def forward(
248266
else:
249267
loss = self.ctc_weight * loss_ctc + (1 - self.ctc_weight) * loss_att
250268

269+
# Add CR-CTC loss
270+
if loss_cr_ctc is not None:
271+
loss = loss + self.cr_ctc_weight * loss_cr_ctc
272+
251273
# Collect Attn branch stats
252274
stats["loss_att"] = loss_att.detach() if loss_att is not None else None
253275
stats["acc"] = acc_att

funasr/models/transformer/utils/add_sos_eos.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,24 @@ def add_sos_eos(ys_pad, sos, eos, ignore_id):
2929
ys_in = [torch.cat([_sos, y], dim=0) for y in ys]
3030
ys_out = [torch.cat([y, _eos], dim=0) for y in ys]
3131
return pad_list(ys_in, eos), pad_list(ys_out, ignore_id)
32+
33+
def add_sos_and_eos(ys_pad, sos, eos, ignore_id):
34+
"""Add <sos> at the beginning and <eos> at the end (length + 2).
35+
36+
Unlike add_sos_eos which returns (ys_in, ys_out) separately,
37+
this returns a single sequence with both sos and eos added.
38+
39+
:param torch.Tensor ys_pad: batch of padded target sequences (B, Lmax)
40+
:param int sos: index of <sos>
41+
:param int eos: index of <eos>
42+
:param int ignore_id: index of padding
43+
:return: ys_in with sos prepended (B, Lmax+1)
44+
:return: ys with both sos and eos (B, Lmax+2)
45+
"""
46+
_sos = ys_pad.new([sos])
47+
_eos = ys_pad.new([eos])
48+
ys = [y[y != ignore_id] for y in ys_pad]
49+
ys_in = [torch.cat([_sos, y], dim=0) for y in ys]
50+
ys_both = [torch.cat([_sos, y, _eos], dim=0) for y in ys]
51+
return pad_list(ys_in, eos), pad_list(ys_both, ignore_id)
52+

0 commit comments

Comments
 (0)