본문 바로가기

음성처리

[딥러닝]ASR 시스템, CTC, Transformer

728x90
728x90

 

 

1. ASR 시스템 구성 요소

 

  1. 음성 프론트엔드 (Feature Extraction)
    • 목적: 원시 파형에서 모델 학습/예측에 적합한 특징 벡터를 추출
    • 주요 기법:
      • MFCC: 인간 청각 특성을 묘사한 Mel 스케일러 주파수 축을 변환하고, 로그 스펙트럼의 DCT 계수를 취함
      • Mel-Spectrogram: STFT -> Mel 필터뱅크 -> 로그 스케일 -> 정규화
      • PLP (Perceptual Linear Prediction), Filterbank Energies, Pitch & Energy Features
  2. 어쿠스틱 모델 (Acoustic Model)
    • 목적: 추출된 특징이 특정 음소나 글자에 해당할 확률을 계산
    • 전통적 접근:
      • HMM-GMM: 은닉 마르코프 모델(HMM)으로 시뭔스 모델링, 혼합 가우시안(GMM)으로 각 상태의 출력 확률 모델링
      • DNN-HMM: GMM을 DNN(MLP, CNN, RNN)으로 대체하여 음향 모델 표현력 강화
    • 최신 end-to-end 접근:
      • CTC(Connectionist Temporal Classification): "정렬(label alignment)" 없이 입력-출력 시퀀스를 매핑, 프레임별 레이블 확률을 합산
      • Seq2Seq with Attention: 인코더-디코더 구조로, 입력 프레임 전체에 대한 어텐션 메커니즘을 통해 출력 문자 시퀀스 생성
      • RNN-Transducer(RNN-T): CTC의 실시간성 & Seq2Seq의 유연성 결합, 인코더/리플렉터/디코더 3축 구조 
  3. 언어 모델 (Language Model)
    • 목적: 음성 인식 결과에 대한 문법적/통계적 가능성을 부여
    • 주요 기법:
      • n-gram 모델: 고전적 확률 모델, 주변 단어 n-1개를 고려
      • Neural LM: RNN-LM, Transformer-LM
  4. 디코더
    • 목적: 음향 모델과 언어 모델 점수를 결합하여 최종 텍스트 시퀀스 탐색
    •  기법:
      • Viterbi 알고리즘
      • Beam Search

 

 


 

 

 

2. 주요 알고리즘

 

📌 HMM-GMM (Hidden Markov Model - Gaussian Mixture Model)

  • HMM: 음성은 시간이 흐르며 변화하는 시계열 데이터로, 숨겨진 상태의 연속으로 모델링
  • GMM: 각 상태에서 관찰되는 음향 특성을 가우시안 혼합 모델로 확률 분포화
  • 구조:
    • 시간 축 따라 상태 전이: HMM
    • 각 상태별로 특징 벡터를 확률적으로 설명: GMM
  • 단점:
    • GMM은 선형적/정규분포 가정 -> 복잡한 음향 패턴 표현에 한계
    • 특징 추출과 분류가 별개 단계

 

 

 

📌 DNN-HMM (Deep Neural Network - Hidden Markov Model)

  • GMM 대신 DNN을 사용하여 HMM의 상태확률을 예측
  • 즉, p(state | feature)를 DNN이 출력하고, HMM은 여전히 시간 구조를 모델링
  • 구조:
    • 음성 특징 -> DNN -> 상태 확률 출력
    • 상태 전이 및 정렬은 HMM으로 유지
  • DNN이 비선형적이고 복잡한 패턴을 더 잘 학습
  • HMM-GMM보다 성능이 현저히 우수
  • 여전히 HMM 구조를 기반으로 하기 때문에 프레임별 정렬이 필요
  • 단점
    • DNN-HMM은 프레임 단위 정렬(label-alignment)을 전처리에서 필요로 함 

 

 

 

📌 CTC (Connectionist Temporal Classification)

  • 프레임마다 라벨을 맞출 필요 없이, 전체 입력 시퀀스를 문자(또는 음소) 시퀀스로 직접 매핑
  • 입력-출력 길이가 다를 때 유용 (예: 긴 스펙트로그램 -> 짧은 문자 시퀀스)
  • 구조:
    • 입력: 스펙트로그램 시퀀스 (T개 프레임)
    • 출력: 가능한 출력 라벨 (예: 0~9, 알파벳 등) + blank
    • 중간에 blank, 반복 허용 -> 최종 output은 후처리
  • End-to-End 학습 가능
  • 라벨 시퀀스 정렬을 모델이 내부적으로 학습
  • 단점:
    • CTC는 프레임 간 독립성 가정 -> 긴 종속성 학습은 어려움

 

 

 

※ CTC 디코딩 시각화, 시뮬레이션

import os
import numpy as np
import matplotlib.pyplot as plt
import torchaudio
import torch
import torch.nn.functional as F

# 1. LibriSpeech test-clean에서 긴 문장 하나 로드
dataset = torchaudio.datasets.LIBRISPEECH(root="./", url="test-clean", download=True)
waveform, sample_rate, transcript, *_ = dataset[10]   # transcript: 해당 음성의 텍스트 정답
sentence = transcript.lower().strip()

# 2. Mel-Spectrogram으로 음성 특징 추출 
mel_tf = torchaudio.transforms.MelSpectrogram(
    sample_rate=sample_rate, n_mels=64)    # 64개의 Mel 주파수 채널로 변환 
mel = mel_tf(waveform).squeeze(0).transpose(0,1)  # [T, F]
mel_db = torchaudio.functional.amplitude_to_DB(     # 로그 스케일 
    mel, multiplier=10.0, amin=1e-10, db_multiplier=0.0)

# 3. CTC 클래스 정의 (문자 사전 + blank) 
chars = sorted(set(sentence))    # 음성 문장에 포함된 모든 문자 집합을 정렬하여 사용 
classes = ["<blank>"] + chars    # CTC에서 필수인 <blank>심볼을 가장 앞에 추가 
T = mel_db.shape[0]              # T: 전체 시간 프레임 수(Mel-spectrogram의 프레임 수)

# 4. 프레임별 cTC 확률 분포 시뮬레이션
probs = np.full((T, len(classes)), 0.02, dtype=np.float32)   # 각 프레임마다 클래스 확률 분포 생성 
positions = np.linspace(0, T-1, len(sentence), dtype=int)    
for pos, ch in zip(positions, sentence):
    idx = classes.index(ch)
    probs[pos, idx] = 0.8     # 각 문자 위치에 해당하는 프레임에 해당 문자 확률을 0.8로 설정 
    						  # 나머지는 <blank>확률로 계산 -> CTC 특성 반영 
probs[:,0] = 1.0 - probs[:,1:].sum(axis=1)

# 5. Greedy CTC 디코딩
best = np.argmax(probs, axis=1)    # 가장 확률이 높은 class를 프레임별로 선택 
decoded = []
prev = None
for idx in best:
    if idx != 0 and idx != prev:
        decoded.append(classes[idx])
    prev = idx
decoded_sentence = "".join(decoded)

# 6. 원본 문장과 예측 결과 출력 
print("REFERENCE:", sentence)
print("PREDICTED:", decoded_sentence)

# 7. 시각화
fig, (ax1, ax2) = plt.subplots(2,1, figsize=(12,8),
                               gridspec_kw={"height_ratios":[3,1]})

# (a) Mel-Spectrogram
im = ax1.imshow(mel_db.T, origin="lower", aspect="auto", cmap="magma")
ax1.set_title("Mel-Spectrogram")
ax1.set_ylabel("Mel bin")
fig.colorbar(im, ax=ax1, format="%+2.0f dB")

# (b) 확률 히트맵
ax2.imshow(probs.T, origin="lower", aspect="auto", cmap="Blues")
ax2.set_yticks(np.arange(len(classes)))
ax2.set_yticklabels(classes)
ax2.set_xlabel("Time Frame")
ax2.set_title("Simulated CTC Frame-wise Probabilities")

plt.tight_layout()
plt.show()

 

 

 

 

 

 

 

 

 📌 Seq2Seq Attention

  • 입력과 출력 시퀀스가 길이도 다르고, 프레임별 정렬 정보가 없을 때에도 효과적으로 학습하기 위함
  • RNN/CNN만으로는 장거리 의존성을 잘 포착하기 어려우므로, 디코더가 입력 전체를 동적으로 참조할 수 있게 함
  • Encoder
    • 음성 특징(frames)을 RNN/CNN/Transformer로 순차 처리하여 각 타임스텝별 잠재 표현을 생성
  • Attention
    • 디코더의 이전 상태와 모든 잠재 표현을 비교하여, 각 입력 위치의 중요도(어텐션 가중치)를 계산 
    • 가중합으로 문맥 벡터를 구성
  • Decoder
    • 매 스텝(문맥 벡터와 이전 출력 토큰 임베딩)를 입력으로 받아 새로운 상태를 계산
    • 최종 출력 분포는 softmax로 얻기
  • Loss(학습)
    • Teacher-forcing 기법으로 정답 이전 토큰을 디코더에 제공
    • 매 타임스텝 예측 분포에 대해 cross-entropy 손실을 계산하고, 시퀀스 전체를 합산
  • Inference(추론)
    • Beam Search를 사용하여 여러 후보 경로를 동시 탐색
    • 각 스텝마다 어텐션 가중치와 디코더 상태를 갱신하며 누적 확률이 높은 경로를 유지
    • 최종적으로 가장 높은 점수를 얻은 토큰 시퀀스를 선택 

 

 

 

📌 Full Transformer (Self-Attention 기반 인코더-디코더)

  1. 구조
    • Input Embedding + Positional Encoding:
      • 입력 음향 시퀀스를 연속된 벡터로 임베딩하고, 순서 정보를 더하기 위해 사인/코사인 기반 또는 학습 가능한 위치 인코딩을 추가
    • Encoding:
      1. Multi-Head Self-Attention: 각 위치가 전체 입력 시퀀스의 다른 위치들과의 연관성을 동시에 여러 "관점"(head)에서 계산
      2. Feed-Forward Network: 각 위치별로 독립적인 2-layer MLP (보통 차원 증가 -> GELU/ReLU -> 차원 복귀)
      3. LayerNorm + Residual: 각 서브레이어 후 잔차 연결과 층 정규화로 안정적인 학습
      4. Masked Multi-Head Self-Attention: 미래 토큰을 보지 않도록 상삼각(upper-triangle) 마스크 적용
      5. Encoder-Decoder Attention: 디코더 각 위치가 인코더 출력의 모든 위치를 참조하여 문맥 벡터 계산
      6. Feed-Forward Network + LayerNorm + Residual
  2. Loss
    • Teacher-Forcing Cross-Entropy: 디코더의 매 타임스텝 예측을 정답 토큰과 비교하여 크로스엔트로피 손실을 계산

 

※ Full - Transformer

import math
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchaudio
from torchaudio.datasets import LIBRISPEECH
from torch.utils.data import Dataset, DataLoader
from sklearn.model_selection import train_test_split
from tqdm import tqdm
import matplotlib.pyplot as plt
from IPython.display import Audio, display

# ───────── CONFIG ─────────
DEVICE      = torch.device("cuda" if torch.cuda.is_available() else "cpu")
SAMPLE_RATE = 16000
N_MELS      = 80
MAX_FRAMES  = 600
BATCH_SIZE  = 8
EPOCHS      = 5
LR          = 3e-4

# ───────── DATA & VOCAB ─────────
dataset = LIBRISPEECH(root="./", url="test-clean", download=True)
subset = [dataset[i] for i in range(500)]
transcripts = [t.lower().strip() for _, _, t, *_ in subset]   # 소문자 처리 및 공백 제거 
chars = sorted({c for utt in transcripts for c in utt})    # 중복 제거된 문자 집합 생성 
vocab = {"<pad>":0, "<sos>":1, "<eos>":2, **{c:i+3 for i,c in enumerate(chars)}}
inv_vocab = {i:c for c,i in vocab.items()}
VOCAB_SIZE = len(vocab)

# ───────── FEATURE & DATASET ─────────
# 오디오 신호를 Mel-spectrogram으로 변환 
mel_transform = torchaudio.transforms.MelSpectrogram(
    sample_rate=SAMPLE_RATE, n_mels=N_MELS)

class LibriSpeechASR(Dataset):
    def __init__(self, examples): self.examples = examples
    def __len__(self): return len(self.examples)
    def __getitem__(self, idx):      # 주어진 인덱스의 오디오와 텍스트를 가져옴 
        wav, sr, transcript, *_ = self.examples[idx]
        # resample if needed
        if sr != SAMPLE_RATE:
            wav = torchaudio.functional.resample(wav, sr, SAMPLE_RATE)
        # mel-spectrogram
        mel = mel_transform(wav).squeeze(0).transpose(0,1)
        mel = mel[:MAX_FRAMES]
        if mel.size(0) < MAX_FRAMES:
            pad = MAX_FRAMES - mel.size(0)
            mel = F.pad(mel, (0,0,0,pad))
        # CMVN
        mel = (mel - mel.mean()) / (mel.std() + 1e-5)
        # tokenize(텍스트를 문자 수준으로 token화)
        tokens = [vocab["<sos>"]] + \
                 [vocab.get(c, vocab["<pad>"]) for c in transcript] + \
                 [vocab["<eos>"]]
        return mel, torch.tensor(tokens, dtype=torch.long)   # 모델 입력용 Mel, 타깃 토큰 시퀀스를 리턴 

# 배치 정리용 
def collate_fn(batch):     
    mels, tgts = zip(*batch)   # 배치에서 Mel과 토큰을 각각 분리
    mels = torch.stack(mels).to(DEVICE)    # Mel은 스택해서 tensor로 만듦 
    lengths = [t.size(0) for t in tgts]    # 각 정답 시퀀스 길이
    max_len = max(lengths)
    padded = torch.full((len(tgts), max_len),
                       vocab["<pad>"], dtype=torch.long)   # 모든 정답을 동일 길이로 맞추기 위한 텐서 생성 
    for i,t in enumerate(tgts):
        padded[i, :t.size(0)] = t
    return mels, padded.to(DEVICE)

train_ex, val_ex = train_test_split(subset, test_size=0.2, random_state=42)
train_loader = DataLoader(LibriSpeechASR(train_ex), batch_size=BATCH_SIZE,
                          shuffle=True, collate_fn=collate_fn)
val_loader   = DataLoader(LibriSpeechASR(val_ex),   batch_size=BATCH_SIZE,
                          shuffle=False, collate_fn=collate_fn)
# ───────── MODEL ─────────
# Transformer는 순서를 인식할 구조가 없기 때문에, 토큰 위치 정보를 인코딩해줌 
class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len=1000):   # d_model: 임베딩 차원(Transformer 내부 차원)
        super().__init__()
        pe = torch.zeros(max_len, d_model)
        pos = torch.arange(0, max_len).unsqueeze(1)
        div = torch.exp(torch.arange(0, d_model, 2) *
                        -(math.log(10000.0) / d_model))
        pe[:,0::2] = torch.sin(pos * div)
        pe[:,1::2] = torch.cos(pos * div)
        self.pe = pe.unsqueeze(0)
    def forward(self, x):
        return x + self.pe[:,:x.size(1)].to(x.device)

# Transformer 구조를 기반으로 한 음성 인식 모델 
class TransformerASR(nn.Module):
    def __init__(self, vocab_size, d_model=256, nhead=4,
                 num_enc=4, num_dec=4, dim_ff=512, dropout=0.1):
        super().__init__()
        self.enc_in     = nn.Linear(N_MELS, d_model)  # 입력인 Mel-spectrogram을 d_model로 선형 변환 
        self.pos_enc    = PositionalEncoding(d_model, MAX_FRAMES)  # 위치 정보를 추가하는 인코딩 모듈 정의 
        self.tgt_embed  = nn.Embedding(vocab_size, d_model)   # 문자 타겟을 Transformer가 처리할 수 있게 임베딩 벡터로 변환
        self.transformer = nn.Transformer(d_model, nhead,
                                          num_enc, num_dec,
                                          dim_ff, dropout,
                                          batch_first=True)
        self.out        = nn.Linear(d_model, vocab_size)

    def forward(self, src, tgt):   # src: 입력 Mel_spectrogram, tgt: 정답 시퀀스 
        B, T_src, _ = src.size()
        T_tgt = tgt.size(1)
        # encode (Mel 입력을 d_model 차원으로 변환하고 위치 정보 추가)
        src = self.enc_in(src)
        src = self.pos_enc(src)
        # embed target (문자 토큰 입력을 임베딩하고 위치 정보 추가)
        tgt = self.tgt_embed(tgt)
        tgt = self.pos_enc(tgt)
        # causal mask for decoder
        tgt_mask = nn.Transformer.generate_square_subsequent_mask(
            T_tgt).to(DEVICE)
        out = self.transformer(src, tgt, tgt_mask=tgt_mask)  # Transformer에 입력 후, 출력 벡터를 vocab 사이즈로 투사 
        return self.out(out)

model     = TransformerASR(VOCAB_SIZE).to(DEVICE)
optimizer = torch.optim.Adam(model.parameters(), lr=LR)
criterion = nn.CrossEntropyLoss(ignore_index=vocab["<pad>"])
# ───────── TRAIN with Progress Bar ─────────
for epoch in range(1, EPOCHS+1):
    model.train()
    total_loss = 0.0
    loop = tqdm(train_loader, desc=f"Epoch {epoch}/{EPOCHS}", leave=False)
    for src, tgt in loop:   # 배치 단위로 Mel 입력(src)과 문자 시퀀스(tgt)를 가져옴 
        inp, outp = tgt[:, :-1], tgt[:, 1:]
        logits = model(src, inp)  # [B, T_tgt, VOCAB]
        loss = criterion(
            logits.reshape(-1, VOCAB_SIZE),
            outp.reshape(-1)
        )
        optimizer.zero_grad()    # 그래디언트 초기화
        loss.backward()          # 역전파
        optimizer.step()         # 파라미터 업데이트 
        total_loss += loss.item()
        loop.set_postfix(loss=loss.item())
    avg_loss = total_loss / len(train_loader)
    print(f"Epoch {epoch:02d} | Avg Loss: {avg_loss:.4f}")

# ───────── INFERENCE ─────────
wav, sr, ref, *_ = dataset[10]     # 10번째 음성 샘플 하나 추출 
if sr != SAMPLE_RATE:   # 샘플링 레이트가 다르면 16kHz로 맞춤 
    wav = torchaudio.functional.resample(wav, sr, SAMPLE_RATE)
mel = mel_transform(wav).squeeze(0).transpose(0,1)
mel = mel[:MAX_FRAMES]
if mel.size(0) < MAX_FRAMES:
    mel = F.pad(mel, (0,0,0, MAX_FRAMES-mel.size(0)))
mel = (mel - mel.mean())/(mel.std()+1e-5)
src = mel.unsqueeze(0).to(DEVICE)

# 디코딩 (Auto-Regressive 방식)
model.eval()
with torch.no_grad():
    seq = [vocab["<sos>"]]   # <sos> 토큰으로 디코딩 시작 
    for _ in range(200):
        tgt = torch.tensor(seq, device=DEVICE).unsqueeze(0)
        logits = model(src, tgt)
        nxt = logits[0, -1].argmax().item()   
        if nxt == vocab["<eos>"]:
            break
        seq.append(nxt)
    hyp = "".join(inv_vocab[i] for i in seq if i > 2)

print("Reference :", ref.lower())
print("Prediction:", hyp)

# play audio
display(Audio(wav.squeeze().cpu().numpy(), rate=SAMPLE_RATE))

# plot mel + text
db = torchaudio.functional.amplitude_to_DB(
    mel_transform(wav).squeeze(0), 10, 1e-10, 0
).cpu().numpy()
plt.figure(figsize=(12,4))
plt.imshow(db, origin='lower', aspect='auto', cmap='magma')
plt.title(f"Prediction: {hyp}")
plt.colorbar(format="%+2.0f dB")
plt.xticks([]); plt.yticks([])
plt.tight_layout()
plt.show()

 

 

# Pipeline
# [Raw WAV]
#    └─▶ MelSpectrogram → normalize → pad → src:[B,T_src,80]
#    └─▶ Text → tokenize+<sos>/<eos> → pad → tgt:[B,T_tgt]

# [Model]
#    src ─▶ Linear+PosEnc ─▶ TransformerEncoder ─┐
#                                               ├─▶ TransformerDecoder ─▶ Linear ─▶ logits
#    tgt ─▶ Embedding+PosEnc ─▶ Causal-mask ─────┘

# [Train]
#    logits, outp → CE Loss → backward → AdamW.step

# [Infer]
#    <sos> → Decoder 반복 호출 → argmax → decoded_text
#    ▶ play WAV, draw Mel+텍스트
728x90