Skip to content

3. MFCCs


Info

Mel-Frequency Cepstral Coefficients(MFCC)는 음성 인식과 관련해 불필요한 정보는 버리고 중요한 특질만 남긴 피처(feature)이다. MFCC는 기존 시스템은 물론 최근 엔드투엔드(end-to-end) 기반 모델에 이르기까지 음성 인식 시스템에 널리 쓰이는 피처인데, 뉴럴네트워크 기반 피처 추출 방식과는 달리 음성 도메인의 지식과 공식에 기반한 피처 추출 방식이다(deterministic). 이번 글에서는 MFCC를 추출하는 방법을 알아본다.


1. Framework

Mel-Frequency Cepstral Coefficients(MFCC)를 만드는 전체 과정을 도식화한 그림은 그림1과 같다. MFCC는 입력 음성을 짧은 구간(대개 25ms 내외)으로 나눈다. 이렇게 잘게 쪼개진 음성을 프레임(frame)이라고 한다. 프레임 각각에 푸리에 변환(Fourier Transform)을 실시해 해당 구간 음성(frame)에 담긴 주파수(frequency) 정보를 추출한다. 모든 프레임 각각에 푸리에 변환을 실시한 결과를 스펙트럼(spectrum)이라고 한다.


그림1 FRAMEWORK

001


스펙트럼에 사람의 말소리 인식에 민감한 주파수 영역대는 세밀하게 보고 나머지 영역대는 상대적으로 덜 촘촘히 분석하는 필터(Mel Filter Bank)를 적용한다. 이를 멜 스펙트럼(Mel Spectrum)이라고 한다. 여기에 로그를 취한 것이 바로 로그 멜 스펙트럼(Log-Mel Spectrum)이다. MFCC는 로그 멜 스펙트럼에 역푸리에 변환(Inverse Fourier Transform)을 적용해 주파수 도메인의 정보를 새로운 시간(tiime) 도메인으로 바꾼 것을 가리킨다. MFCC는 기존 음성 인식 시스템에서 가우시안 믹스처 모델(Gaussian Mixture Model)의 입력으로 쓰인다.


MFCC는 인간의 말소리 인식에 중요한 특질들이 추출된 결과이다. 음성학, 음운론 전문가들이 도메인 지식을 활용해 공식화한 것이라고 볼 수 있겠다. 상당히 오랜 시간동안 변화와 발전을 거듭해 그림1과 같은 피처 추출 프레임워크가 탄생하였고 그 성능 또한 검증되었다. 최근에는 뉴럴네트워크에 의한 피처 추출도 점점 관심을 받고 있지만, 로그 멜 스펙트럼이나 MFCC는 음성 인식 분야에서 아직까지 널리 쓰이고 있는 피처이다.


2. Raw Wave Signal

그럼 이제부터 MFCC 피처를 만드는 과정을 차례대로 살펴보자.


음성1은 한국정보화진흥원에서 구축해 2019년 공개한 '한국어음성 데이터'이다. 조용한 환경에서 2000여명이 발성한 한국어 대화 음성 1000시간짜리 데이터셋인데, 이 가운데 하나를 wav 파일로 변환한 것이다.


음성1 예시 음성과 전사 스크립트

example.wav 다운로드 링크

그래 가지고 그거 연습해야 된다고 이제 빡시게 모여야 되는데 내일 한 두시나 네 시에 모여서 저녁 여덟시까지 교회에 있을 거 같애


우선 위의 파일을 로컬에 다운로드 받는다. wav 파일을 읽는 파이썬 코드와 실행 결과는 코드1과 같다.


코드1 WAVE FILE 읽기

import scipy.io.wavfile
sample_rate, signal = scipy.io.wavfile.read("example.wav")
>>> sample_rate
16000
>>> signal
array([36, 37, 60, ...,  7,  9,  8], dtype=int16)
>>> len(signal)
183280
>>> len(signal) / sample_rate
11.455


sampling rate(sample_rate)는 16000(16KHz)이다. 음성 신호(signal)의 dtype을 보니 16비트로 양자화(quantization) 되어 있다. 다시 말해 실수(real number) 범위의 이산(discrete) 음성 신호가 -32768~32767 범위의 정수(integer)로 변환되었다는 뜻이다. 실제로 signal 변수는 18만 3280개 정수들의 시퀀스이다. sampling rate는 원시 음성 신호를 1초에 몇 번 샘플했는지 나타내는 지표이므로 signal 변수 길이를 sample_rate로 나누면 입력 음성이 몇 초짜리인지 확인할 수 있다(11.455초).


이 글에서는 11.455초 길이의 음성에서 초반 3.5초 구간만 잘라서 MFCC를 만들어 본다. 음성을 3.5초로 자르는 코드는 코드2와 같다. 코드2를 수행하면 signal 길이가 56000으로 짧아진 것을 볼 수 있다.


코드2 음성을 앞에서부터 3.5초로 자름

signal = signal[0 : int(3.5 * sample_rate)]
>>> len(signal)
56000


3. Preemphasis

사람 말소리를 스펙트럼(spectrum)으로 변환해서 관찰하면 일반적으로 저주파(low frequency) 성분의 에너지(energy)가 고주파(high frequency)보다 많은 경향이 있다. 이러한 경향은 모음(vowel)에서 자주 확인된다. 고주파 성분의 에너지를 조금 올려주면 음성 인식 모델의 성능을 개선할 수 있다고 한다. 고주파 성분의 에너지를 올려주는 전처리 과정을 preemphasis라고 한다. preemphasis를 실시하면 다음 세 가지 효과를 볼 수 있다고 한다.


1] 상대적으로 에너지가 적은 고주파 성분을 강화함으로써 원시 음성 신호가 전체 주파수 영역대에서 비교적 고른 에너지 분포를 갖도록 함.

2] 푸리에 변환 시 발생할 수 있는 numerical problem 예방.

3] Signal-to-Noise Ratio(SNR) 개선.


\(t\)번째 시점의 원시 음성 신호를 \(\mathbf{x}_{t}\)라고 할 때 preemphasis는 수식1과 같이 계산한다(first-order high-pass filter). preemphasis coefficient \(\alpha\)는 보통 0.95나 0.97을 사용한다고 한다. 예시 음성에 대한 preemphasis 수행 코드와 결과는 코드3에서 확인할 수 있다.


수식1 PREEMPHASIS

\[ \mathbf{y}_{t}=\mathbf{x}_{t}-\alpha \mathbf{x}_{t-1} \]


코드3 PREEMPHASIS

import numpy as np
pre_emphasis = 0.97
emphasized_signal = np.append(signal[0], signal[1:] - pre_emphasis * signal[:-1])
>>> emphasized_signal
array([  36.  ,    2.08,   24.11, ..., -233.76, -262.5 ,  -61.87])


4. Framing

MFCC를 만들 때 푸리에 변환을 실시한다. 그러면 마치 스냅샷을 찍는 것처럼 해당 음성의 주파수별 정보를 확인할 수 있다. 그런데 원시 음성 신호는 아주 빠르게 변화한다(non-stationary). 분석 대상 시간 구간이 지나치게 길 경우 빠르게 변화하는 신호의 주파수 정보를 정확히 캐치하기 어려울 뿐더러 주파수 정보가 전체 시간 구간에 걸쳐 뭉뚱 그려져 음성 인식 모델의 피처로서 제대로 작동하기 어려울 것이다.


이에 음성 신호가 stationary하다고 가정해도 좋을 만큼 원시 음성 신호를 아주 짧은 시간 단위(대개 25ms)로 잘게 쪼갠다. stationary란 '마치 지속적으로 울리는 사이렌 소리처럼 시점이 변하더라도 해당 신호가 변하지 않는다'는 걸 의미한다. 이렇게 음성 신호를 짧은 시간 단위로 잘게 쪼개는 과정을 framing이라고 한다. 코드4는 예시 음성 파일에 framing을 수행하는 코드와 그 수행 결과이다. framing을 할 때는 프레임을 일정 시간 단위(frame_size=25ms)로 자르되 일정 구간(frame_stride=10ms)은 겹치도록 한다. 일정 구간을 겹치게 처리하는 것은 바로 다음 파트인 windowing과 연관이 있다.


코드4 FRAMING

frame_size = 0.025
frame_stride = 0.01
frame_length, frame_step = frame_size * sample_rate, frame_stride * sample_rate
signal_length = len(emphasized_signal)
frame_length = int(round(frame_length))
frame_step = int(round(frame_step))
num_frames = int(np.ceil(float(np.abs(signal_length - frame_length)) / frame_step))
pad_signal_length = num_frames * frame_step + frame_length
z = np.zeros((pad_signal_length - signal_length))
pad_signal = np.append(emphasized_signal, z)
indices = (
    np.tile(np.arange(0, frame_length), (num_frames, 1))
    + np.tile(np.arange(0, num_frames * frame_step, frame_step), (frame_length, 1)).T
)
frames = pad_signal[indices.astype(np.int32, copy=False)]
>>> pad_signal
array([36.  ,  2.08, 24.11, ...,  0.  ,  0.  ,  0.  ])
>>> len(pad_signal)
56080
>>> indices
array([[    0,     1,     2, ...,   397,   398,   399],
       [  160,   161,   162, ...,   557,   558,   559],
       [  320,   321,   322, ...,   717,   718,   719],
       ...,
       [55200, 55201, 55202, ..., 55597, 55598, 55599],
       [55360, 55361, 55362, ..., 55757, 55758, 55759],
       [55520, 55521, 55522, ..., 55917, 55918, 55919]])
>>> indices.shape
(348, 400)
>>> frames
array([[  36.  ,    2.08,   24.11, ...,    4.56,    3.74,    2.89],
       [  16.43,  -32.15,  -47.2 , ...,  -13.06,  -16.45,    2.07],
       [  -9.  ,   -9.27,   11.46, ...,   -5.09,   -7.24,   -2.45],
       ...,
       [ 315.7 ,  130.65,  211.81, ..., -121.15,  -17.69, -195.02],
       [ 283.62, 1098.42,  815.34, ...,   20.53,  136.92,  150.79],
       [ -59.03, -212.81, -289.18, ..., -157.35,  -81.12,   24.54]])
>>> frames.shape
(348, 400)


pad_signal은 앞서 preemphasis를 수행한 신호(emphasized_signal)에 제로 패딩(z)를 이어붙인 것이다. emphasized_signal의 길이가 56000이라는 점을 감안하면 여기에 0이 80개 덧붙여진 것을 확인할 수 있다. indicespad_signal에서 인덱스(index) 기준으로 값을 참조하려고 만든 변수이다. 예컨대 indices가 [[0, 1], [1, 2]]라면 이로부터 만든 frames는 [[36., 2.08], [2.08, 24.11]]이 될 것이다. indices를 자세히 살펴보면 첫 번째 프레임을 만드는 데 관여하는 인덱스 범위가 0~399인데 두 번째 프레임에 관련된 범위는 160~559임을 확인할 수 있다. 다시 말해 이전 프레임과 다음 프레임이 서로 겹친다는 의미이다.


indicesframes의 shape는 \(348 \times 400\)이다. 후자(400)는 프레임 하나의 길이(frame_length)를 의미한다. 우리는 frame_size를 25ms로 정했고 예시 음성의 sampling rate는 16000이므로 이 둘을 곱한 400이 프레임당 길이가 된다. 전자(348)는 프레임 개수(num_frames)를 가리킨다. 코드4를 확인해보면 이전 프레임과 다음 프레임 사이의 간격(frame_step)과 직접 관련이 있다. 결론적으로 framing의 최종 수행 결과는 frames이 되는데, 원시 음성을 짧은 구간(25ms)으로 자르되 일부 구간(10ms)은 겹치게 처리한 것이다.


5. Windowing

Windowing이란 각각의 프레임에 특정 함수를 적용해 경계를 스무딩하는 기법이다. 대표적으로 해밍 윈도우(Hamming Window)라는 함수가 있다. 그림2와 같다. 개별 프레임에 그림2와 같은 해밍 윈도우를 적용하게 되면 프레임 중간에 있는 값들은 1이 곱해져 그대로 살아남고 양 끝 부분은 0에 가까운 값이 곱해져 그 값이 작아진다. 해밍 윈도우는 수식2, 그림2와 같다(\(n\)은 해밍 윈도우 값 인덱스, \(N\)은 프레임 길이).


수식2 HAMMING WINDOW

\[ w[n]=0.54-0.46 \cos \left(\frac{2 \pi n}{N-1}\right) \]


그림2 HAMMING WINDOW

002


해밍 윈도우 함수 같은 Windowing을 적용하는 이유는 그림3에서 유추해볼 수 있다. 우리가 앞서 원시 음성 신호를 짧은 구간 단위로 잘게 쪼개는 framing을 수행했는데, 이는 그림3에서 Rectangular Window를 적용한 셈이 된다. 그런데 Rectangular Window로 자른 프레임의 양끝에서는 신호가 살아 있다가 갑자기 죽는(=0) 상황이 발생하고 있다. 이같은 프레임에 이후 푸리에 변환을 실시하게 되면 불필요한 고주파(high frequency) 성분이 살아남게 된다. 직각에 가까운 신호(square wave)이기 때문인데, 해밍 윈도우를 적용하게 되면 그림3의 하단 우측처럼 양끝 부분이 스무딩되어 앞서 설명한 부작용을 완화할 수 있게 된다.


그림3 RECTANGULAR WINDOW VS HAMMING WINDOW

003


코드5는 해밍 윈도우 값들에 프레임에 곱해 windowing을 적용하는 코드이다. 해밍 윈도우 값들과 프레임의 개별 샘플(값)들을 element-wise 곱을 수행한다.


코드5 HAMMING WINDOW

frames *= np.array(
    [
        0.54 - 0.46 * np.cos((2 * np.pi * n) / (frame_length - 1))
        for n in range(frame_length)
    ]
)


다음은 이해를 돕기 위해 frame_length가 10일 때 개별 해밍 윈도우값들을 확인한 것이다.


array([0.08      , 0.18761956, 0.46012184, 0.77      , 0.97225861,
       0.97225861, 0.77      , 0.46012184, 0.18761956, 0.08      ])


6. Fourier Transform

푸리에 변환(Fourier Transform)이란 시간(time) 도메인의 음성 신호를 주파수(frequency) 도메인으로 바꾸는 과정을 가리킨다. 이산 푸리에 변환(Discrete Fourier Transform)이란 이산 신호(discrete signal)에 대한 푸리에 변환을 가리키는데, 그 컨셉은 그림4와 같다. 다시 말해 입력 프레임 각각의 주파수 정보를 확인하는 과정이다.


그림4 DISCRETE FOURIER TRANSFORM

004


푸리에 변환을 실제로 적용할 때는 고속 푸리에 변환(Fast Fourier Transform)이라는 기법을 쓴다. 기존 푸리에 변환에서 중복된 계산량을 줄이는 방법이다. 이는 파이썬 numpy 패키지에서 np.fft.fft 함수를 쓰면 된다. 코드6과 같다. NFFT는 주파수 도메인으로 변환할 때 몇 개의 구간(bin)으로 분석할지 나타내는 인자(argument)인데, 보통 256이나 512를 쓴다고 한다.


코드6 DISCRETE FOURIER TRANSFORM

NFFT = 512
dft_frames = np.fft.rfft(frames, NFFT)


그런데 코드6에서 실제 사용한 이산 푸리에 변환 함수는 np.fft.rfft인데, 푸리에 변환 결과는 켤레 대칭이기 때문에 np.fft.fft 함수의 변환 결과에서 켤레 대칭인 파트 계산을 생략한 것이 np.fft.rfft의 계산 결과이다. 따라서 np.fft.fft의 리턴 shape은 num_frames * NFFT, np.fft.rfft의 리턴 shape은 num_frames * NFFT / 2 + 1이 된다. 다음은 이해를 돕기 위해 프레임 하나([0.2, 0.7, 0.5, 0.3, 0.1])에 대해 이산 푸리에 변환을 수행한 것이다.


>>> np.fft.fft([0.2, 0.7, 0.5, 0.3, 0.1])
array([ 1.8+0.j        , -0.2-0.68819096j, -0.2-0.16245985j,
       -0.2+0.16245985j, -0.2+0.68819096j])
>>> np.fft.rfft([0.2, 0.7, 0.5, 0.3, 0.1])
array([ 1.8+0.j        , -0.2-0.68819096j, -0.2-0.16245985j])


np.fft.fft를 쓰든 np.fft.rfft를 쓰든 이후 계산해볼 멜 스펙트럼, 로그 멜 스펙트럼, MFCC 계산 결과가 달라지지 않는다(단 전자의 경우 Filter Bank의 필터 차원수가 NFFT, 후자는 NFFT / 2 + 1이 되어야 함). 계산량을 줄이려면 np.fft.rfft를 쓰는 것이 이득이다.


7. Magnitude

이산 푸리에 변환의 결과는 복소수(complex number)로 실수부(real part)와 허수부(imaginary part) 두 개로 나눠 생각해볼 수 있다. \(j\)는 제곱해서 -1이 되는 허수, \(k\)번째 주파수 구간(bin)에 해당하는 이산 푸리에 변환 결과를 \(X[k]=a+b \times j\)라고 할 때 이 주파수 성분의 진폭(magnitude)과 위상(phase)은 그림5와 같이 이해할 수 있다. 진폭은 이 주파수 성분의 크기를, 위상은 해당 주파수의 (복소평면상 단위원상)위치를 나타낸다고 해석할 수 있다는 이야기이다.


그림5 MAGNITUDE와 PHASE

005


그림5에서 진폭이 \(\sqrt{a^{2}+b^{2}}\)이 되는 것은 피타고라스 정리를 활용해 유도할 수 있다. arctan 함수(=\(\tan ^{-1}\))에 탄젠트 값을 넣으면 각도가 리턴되는데, \(\tan ^{-1}\left(\frac{b}{a}\right)\)는 탄젠트 값이 \(a / b\)\(\theta\)를 가리킨다. 어쨌든 MFCC를 구할 때는 음성 인식에 불필요한 위상 정보는 없애고 진폭 정보만을 남긴다. 코드7을 수행하면 된다. np.absolute 함수는 복소수 입력(\(a+b \times j\))에 대해 \(\sqrt{a^{2}+b^{2}}\)를 리턴한다.


코드7 MAGINTUDE

mag_frames = np.absolute(dft_frames)


8. Power Spectrum

\(k\)번째 주파수 구간(bin)에 해당하는 이산 푸리에 변환 결과를 \(X[k]\)라고 할 때 파워(Power)를 구하는 공식은 수식3, 코드8과 같다(\(N\)은 이산 푸리에 변환의 NFTT에 대응). 진폭(magnitude)의 제곱을 \(N\)으로 나눠준 값이다. 이산 푸리에 변환 결과(스펙트럼)에 수식3을 적용한 결과를 파워 스펙트럼(Power Spectrum)이라고 한다.


수식3 POWER

\[ \text { Power }=\frac{|X[k]|^{2}}{N} \]


코드8 POWER SPECTRUM

pow_frames = (1.0 / NFFT) * ((mag_frames) ** 2)


진폭을 구하든 파워를 구하든 복소수 형태인 이산 푸리에 변환 결과는 모두 실수(real number)로 바뀐다. 다음은 코드6 수행 결과(이산 푸리에 변환)와 코드8 결과(파워 스펙트럼)를 비교한 것이다.


>>> dft_frames
array([[  18.58681572  +0.j        ,  -14.01988178 -84.91698726j,
         -24.70392533+106.88963159j, ...,  -26.66158624  -5.85474324j,
           0.92680879 +28.72849855j,   32.82338322  +0.j        ],
       ...,
       [ -70.92601653  +0.j        ,  145.29755513 +30.95048802j,
           9.87980069 -51.04003282j, ...,  259.85097643-284.83125235j,
        -157.83633782 -92.86623919j,  -11.68459131  +0.j        ]])
>>> pow_frames
array([[6.74745544e-01, 1.44676793e+01, 2.35071822e+01, ...,
        1.45530898e+00, 1.61364376e+00, 2.10424704e+00],
       ...,
       [9.82519496e+00, 4.31041255e+01, 5.27870198e+00, ...,
        2.90334711e+02, 6.55008748e+01, 2.66659520e-01]])


9. Filter Banks

사람의 소리 인식은 1000Hz 이하의 저주파수(low frequency) 영역대가 고주파수(high frequency) 대비 민감하다고 한다. 이에 주파수 영역대별 에너지 정보가 있는 데이터(pow_frames)에서 저주파수 영역대를 고주파수 영역대 대비 상대적으로 세밀하게 볼 필요가 있다. 이때 적용하는 기법을 필터 뱅크(Filter Banks)라고 한다.


필터 뱅크는 멜 스케일(Mel Scale) 필터를 쓰게 되는데, 기존 주파수(\(f\), 단위는 헤르츠/Hz)를 멜(\(m\), 단위는 멜/mel)로, 멜을 헤르츠로 변환하는 공식은 수식4와 같다. 헤르츠 단위 주파수 \(k\)를 멜 단위 주파수 \(m\)에 대응시키는 필터를 만드는 공식은 수식5와 같다. 수식5의 필터를 파이썬으로 만드는 코드는 코드9와 같다. 수식4, 수식5, 코드9는 보기만 해도 머리가 아픈데, 일단은 그렇구나 하고 바로 다음으로 넘어간다.


수식4 MEL VS HERZ

\[ \begin{aligned}&m=2595 \log _{10}\left(1+\frac{f}{700}\right) \\&f=700\left(10^{m / 2595}-1\right)\end{aligned} \]


수식5 MEL SCALE FITER

\[ H_{m}(k)=\left\{\begin{array}{cl}0 & k<f(m-1) \\\frac{k-f(m-1)}{f(m)-f(m-1)} & f(m-1) \leq k<f(m) \\1 & k=f(m) \\\frac{f(m+1)-k}{f(m+1)-f(m)} & f(m)<k \leq f(m+1) \\0 & k>f(m+1)\end{array}\right. \]


코드9 MEL SCALE FILTER

nfilt = 40
low_freq_mel = 0
high_freq_mel = 2595 * np.log10(1 + (sample_rate / 2) / 700)  # Convert Hz to Mel
mel_points = np.linspace(
    low_freq_mel, high_freq_mel, nfilt + 2
)  # Equally spaced in Mel scale
hz_points = 700 * (10 ** (mel_points / 2595) - 1)  # Convert Mel to Hz
bin = np.floor((NFFT + 1) * hz_points / sample_rate)

fbank = np.zeros((nfilt, int(np.floor(NFFT / 2 + 1))))
for m in range(1, nfilt + 1):
    f_m_minus = int(bin[m - 1])  # left
    f_m = int(bin[m])  # center
    f_m_plus = int(bin[m + 1])  # right
    for k in range(f_m_minus, f_m):
        fbank[m - 1, k] = (k - bin[m - 1]) / (bin[m] - bin[m - 1])
    for k in range(f_m, f_m_plus):
        fbank[m - 1, k] = (bin[m + 1] - k) / (bin[m + 1] - bin[m])


코드9에서 멜 스케일 필터를 40개(nfilt) 쓰기로 정했다. 다음은 이 가운데 첫 번째와 마지막 필터를 파이썬 콘솔에서 확인해본 것이다. 각 필터의 길이는 NFFT / 2 + 1(257)이다(이산 푸리에 변환을 np.fft.fft 함수로 수행했다면 NFFT).


>>> len(fbank[0])
257
>>> len(fbank[39])
257
>>> fbank[0]
array([0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0.])
>>> fbank[39]
array([0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.06666667, 0.13333333, 0.2       , 0.26666667, 0.33333333,
       0.4       , 0.46666667, 0.53333333, 0.6       , 0.66666667,
       0.73333333, 0.8       , 0.86666667, 0.93333333, 1.        ,
       0.94117647, 0.88235294, 0.82352941, 0.76470588, 0.70588235,
       0.64705882, 0.58823529, 0.52941176, 0.47058824, 0.41176471,
       0.35294118, 0.29411765, 0.23529412, 0.17647059, 0.11764706,
       0.05882353, 0.        ])


코드10pow_frames에 필터 뱅크 기법을 적용한다. 앞서 만든 fbank와 내적(inner product)를 수행하는 것인데, 이를 앞의 fbank[0], fbank[39]와 연관지어 이해해 보자. fbank[0]pow_frames를 내적하면 이산 푸리에 변환으로 분석된 257개 주파수 영역대 가운데 2번째 주파수 구간(bin)만 남기고 모두 무시한다. fbank[39]pow_frames를 내적하면 257개 주파수 영역대 가운데 226~256번째 구간만 남기고 모두 무시하는데, fbank[39]의 각 요소값은 해당 주파수 구간을 얼마나 살필지 가중치 역할을 담당하게 된다.


요컨대 fbank[0]은 헤르츠 기준 저주파수 영역대를 세밀하게 살피는 필터이고, fbank[39]는 고주파수 영역대를 넓게 보는 필터라는 이야기이다. 이는 그림6에서도 확인할 수 있다. 각 삼각형의 아랫변 범위가 각 멜 스케일 필터가 담당하는 헤르츠 기준 주파수 영역대이다. 저주파수 영역대는 촘촘하게, 고주파수 영역대는 듬성듬성 보고 있다. 한편 코드10의 두 번째 줄은 필터 뱅크 수행 결과가 0인 곳에 작은 숫자를 더해주서 numerical problem을 예방하는 방어 장치이다.


코드10 FILTER BANKS

filter_banks = np.dot(pow_frames, fbank.T)
filter_banks = np.where(
    filter_banks == 0, np.finfo(float).eps, filter_banks
)  # Numerical Stability


그림6 MEL SCALE FILTERS

006


10. Log-Mel Spectrum

사람의 소리 인식은 로그(log) 스케일에 가깝다고 한다. 다시 말해 사람이 두 배 큰 소리라고 인식하려면 실제로는 에너지가 100배 큰 소리여야 한다는 이야기이다. 우리는 인간의 말소리 인식에 중요한 특질을 추출하는 데 관심이 있으므로 멜 스펙트럼에 로그 변환을 수행하게 된다. filter_banks코드11과 같이 적용한다.


코드11 LOG-FILTER BANKS

filter_banks = 20 * np.log10(filter_banks)  # dB


코드11까지 모두 수행한 결과는 다음과 같다. 로그를 취하기 전의 음성 피처를 멜 스펙트럼(Mel Spectrum), 로그까지 적용한 음성 피처를 로그 멜 스펙트럼(Log-Mel Spectrum)이라고 한다. 멜 스펙트럼, 로그 멜 스펙트럼 모두 수행 이후의 차원 수는 num_frames * nfilt이다.


>>> filter_banks
array([[ -5.51767373,  -3.4808014 , -44.47846101, ..., -24.56746926,
        -21.40441976, -13.11285479],
       [  2.69086582,  -4.26954232, -50.67573028, ..., -31.24448974,
        -29.33347116, -25.21368086],
       [-29.06676688,  -8.15062102, -29.10158336, ..., -29.13659861,
        -24.90521909, -21.75009495],
       ...,
       [  8.20606423,   5.58650835,  23.14688016, ...,  20.16656026,
          3.96069974,  15.00945812],
       [ 14.95999823,   5.85439839,  23.63060586, ...,  16.74452643,
         12.20950178,  23.60855813],
       [  3.96472556,  -7.7720567 ,  23.50733646, ...,  21.44398596,
          9.92422641,  17.84853868]])
>>> filter_banks.shape
(348, 40)


11. MFCCs

멜 스펙트럼 혹은 로그 멜 스펙트럼은 태생적으로 피처(feature) 내 변수 간 상관관계(correlation)가 존재한다. 그도 그럴 것이 멜 스케일 필터(수식5, 코드9)를 보면 주변 몇 개의 헤르츠 기준 주파수 영역대 에너지를 한 곳에 모아 보기 때문이다. 다시 말해 헤르츠 기준 특정 주파수 영역대의 에너지 정보가 멜 스펙트럼 혹은 로그 멜 스펙트럼의 여러 차원에 영향을 주는 구조이다. 이는 변수 간 독립(independence)을 가정하고 모델링하는 가우시안 믹스처 모델(Gaussian Mixture Model)에는 독이 될 수 있다.


로그 멜 스펙트럼에 역푸리에 변환(Inverse Fourier Transform)을 수행해 변수 간 상관관계를 해소한 피처를 Mel-Frequency Cepstral Coefficients(MFCCs)라고 한다. 로그 멜 스펙트럼에 역푸리에 변환을 실시하면 상관관계가 높았던 주파수 도메인 정보가 새로운 도메인으로 바뀌어 이전 대비 상대적으로 변수 간 상관관계가 해소되게 된다.


MFCCs를 만들 때는 코드12처럼 역이산 코사인 변환(Inverse Discrete Cosine Transform)을 쓴다. 코사인 변환은 푸리에 변환에서 실수 파트만을 수행한다. 마지막으로 MFCCs의 shape은 num_frames * nfilt인데, 이 행렬 가운데 2-13번째 열벡터들만 뽑아서 최종적인 MFCCs 피처로 사용하게 된다. 이외 주파수 영역대 정보들은 만들 때마다 워낙 빠르게 바뀌어 음성 인식 시스템 성능 향상에 도움이 되지 않기 때문이라고 한다.


코드12 MEL-FREQUENCY CEPSTRAL COEFFICIETNS

from scipy.fftpack import dct
num_ceps = 12
mfcc = dct(filter_banks, type=2, axis=1, norm="ortho")[
    :, 1 : (num_ceps + 1)
]  # Keep 2-13

멜 스펙트럼 혹은 로그 멜 스펙트럼 대비 MFCCs는 구축 과정에서 버리는 정보가 지나치게 많다. 이 때문인지 최근 딥러닝 기반 모델들에서는 MFCCs보다는 멜 스펙트럼 혹은 로그 멜 스펙트럼 등이 더 널리 쓰인다.


최종적으로 만들어진 MFCCs는 다음과 같다. 그 shape은 num_frames * num_ceps(12)이다.


>>> mfcc.shape
(348, 12)
>>> mfcc
array([[-70.61457095, -73.42417413,   6.03918874, ...,   0.41193953,
          0.52327877,   1.33707611],
       [-56.42592116, -68.28832959,   8.2060342 , ...,   8.15586847,
          0.12371646,  15.13425081],
       [-49.63784465, -62.84072546,  -1.38257895, ...,  -0.14776772,
         -0.92732454,  -7.98662188],
       ...,
       [-10.47629573, -43.35025103,  -2.78813316, ..., -15.00487819,
         -8.44861337, -18.41546277],
       [-13.00736419, -37.74980874,  -3.52627102, ...,  -9.43215238,
        -11.52338732, -14.32990337],
       [-14.05078172, -48.15574966,  -6.33121662, ..., -17.82431596,
        -10.26252646, -20.6654707 ]])


12. Post Processing

MFCCs를 입력으로 하는 음성 인식 시스템의 성능을 높이기 위해 몇 가지 후처리를 하기도 한다. 대표적으로 Lift, Mean Normalization 등이 있다. 전자는 MFCCs에, 후자는 멜 스펙트럼 혹은 로그 멜 스펙트럼에 실시한다. 각각 코드13, 코드14와 같다.


코드13 LIFT

(nframes, ncoeff) = mfcc.shape
cep_lifter = 22
n = np.arange(ncoeff)
lift = 1 + (cep_lifter / 2) * np.sin(np.pi * n / cep_lifter)
mfcc *= lift


코드14 MEAN NORMALIZATION

filter_banks -= np.mean(filter_banks, axis=0) + 1e-8

References