VGG16を用いた話者分類やってみた(声紋認証)

# 動機
Alexaに話者登録をするとき、簡単な言葉を２、3文発するだけで登録できてしまいます。
最近の話者認識はすごいなあ。どうやってるんだろうなあ。なんかやってみよ。

# 話者認識について
話者認識の特徴量と言えばi-vectorをよく聞きますよね。
音素にはMFCC。画像にはHOG。話者にはi-vector。そんな時代もありました(なお現役?)。
いまはディープラーニング主流の時代ということで、
スペクトログラムを用いた話者認識(分類問題)をやってみようと思います。
文字通り，声紋認証ですね！

# 学習データについて
男女5人ずつの音声データを用意します。ひとりあたり60~80秒。相当短いです。
VGG16は入力サイズが(224,224,3)なので，それに合わせてスペクトログラムをつくります。

# 使用モデルについて
世の偉人の力を借りて、画像分類モデルVGG16を用いた転移学習(Transfer Learning)しようと思います。
学習データが相当少ないこともあり、転移学習の威力を実感するにはちょうど良い？
学生時代は「少ない話者データから頑健な音響モデルをつくる」みたいなことをやっていたので、なんだか哀愁も漂ってきました。


# 特徴量抽出
繰り返しになりますが、音を画像モデルとして扱うので、特徴量はスペクトログラムを用います。
周波数はメル変換せず、対数スペクトログラムでやってみます。
パラメータ設定は音声信号処理でよく使われる下記の設定にしました。

|パラメータ|変数名|値[単位]||
|---|---|---|---|
|サンプリング周波数|FS|16000[Hz]||
|FFTポイント数|FFTSIZE|512[pt]|0.032[s]|
|STFTフレーム長|FRAMESEC|0.025[s]|
|STFTオーバーラップ割合|OVERLAPRATIO|0.6|60%|
オーバーラップ割合はフレーム長に対しての割合、つまり15msになります。
FFTの窓関数はhann窓を使います。

```python:
from scipy.signal import stft
import numpy as np

#-- constant parameters
FS=16000
FFTSIZE=512
FRAMESEC=.025
OVERLAPRATIO=.6

#-- function for feature extraction
def normalize8(X):
  '''
  normalize 0-1 unsigned 8 bit
  '''
  mn,mx = X.min(),X.max()
  mx -= mn
  X = ((X - mn)/mx)*255
  return X.astype(np.uint8)/255

def make_spectrogram_224(x):
  f,t,S=stft(x,
             fs=FS,
             nperseg=int(FRAMESEC*FS),
             noverlap=int(FRAMESEC*FS*OVERLAPRATIO),
             nfft=FFTSIZE) #(257,226)
  S_mag=np.abs(S)
  S_log=20*np.log10(S_mag)
  S_8bit=normalize8(S_log)[4:228,:] #125[Hz]~7125[Hz]
  S_8bit_3d=np.dstack((S_8bit,S_8bit,S_8bit)) #画像を3次元にする
  #-- スペクトログラムを224列ごとに区切る
  _S=S_8bit_3d[:,:224*(S_8bit_3d.shape[1]//224)] #余りを切り捨て 
  S_8bits=np.array_split(_S,_S.shape[1]//224,axis=1) #224列ごとに区切る
  return f[:224],t[:224],np.array(S_8bits)
```

- 画像サイズを(224,224)にするために、前述のパラメータ条件下でお行儀悪くハードコーディングしています。
- make_spectrogramでスペクトログラムを実際に計算して、時間方向に224個ずつに区切ってリスト化しています。
このとき、dstackで画像を３次元にしています(VGG16の入力サイズ(224,224,<b>3</b>)の部分を作っています)。
- normalize8は、値をunsignedの8bitに正規化して、画像と同じ値域[0,255]にする役割を担います。
ここでは計算したスペクトログラムに対して8bit正規化を行っています。

ちなみに、一枚の画像は↓こんな感じになります。
今回のパラメータでは、時間は約2.23秒間&周波数は125~7125Hzです。
![female_gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1989761/021777b9-c846-1b13-2587-f22f5a51e8c5.png)
なんと美しい声紋なのでしょうか。

# モデル定義
ではモデルをつくっていきます。今回はVGG16が元なので、kerasでimportしてきます。
なんと便利な世の中なのでしょうか。

```python
# keras VGG16
from keras.models import Model
from keras.layers import Dense, GlobalAveragePooling2D,Input
from keras.applications.vgg16 import VGG16

n_categories=10
n_nodes=1024
base_model=VGG16(weights='imagenet',include_top=False,
                 input_tensor=Input(shape=(224,224,3)))

#-- 自作のモデルを連結
x=base_model.output
x=GlobalAveragePooling2D()(x)
x=Dense(n_nodes,activation='relu')(x)
output=Dense(n_categories,activation='softmax')(x)
model=Model(inputs=base_model.input,outputs=output)

#VGG16のレイヤー重みを固定
for layer in base_model.layers[:15]:
    layer.trainable=False

model.compile(optimizer='Adam',
              loss='categorical_crossentropy',
              metrics=['accuracy'])

model.summary()
```
モデルの後半はこんな感じになります↓

```
 block5_pool (MaxPooling2D)  (None, 7, 7, 512)         0                                                                          
 global_average_pooling2d (G  (None, 512)              0         
 lobalAveragePooling2D)                                                                                                           
 dense (Dense)               (None, 1024)              525312                                                                     
 dense_1 (Dense)             (None, 10)                10250    
``` 

# モデル学習
データを訓練/テストに分けて学習します。

```python
#-- split parameters
from sklearn.model_selection import train_test_split
from keras.utils import np_utils
Y=np_utils.to_categorical(Y, len(id_all)) #one-hot
X_train, X_test, y_train, y_test = train_test_split(X, Y,test_size=.2,random_state=42)

#-- model training
result=model.fit(X_train, y_train, batch_size=32, epochs=40)

#-- model plot
plt.plot(result.history['loss'])
plt.plot(result.history['accuracy'])
```
![loss_plot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1989761/0f4ad192-dd59-973d-e194-1e475ca4c07e.png)
エポックごとのlossと正解率の推移は↑のような感じになりました。順調に学習が進んでいます(か?)

↓そしてモデルの汎化精度は90%程度でした。
訓練データでは100%を達成しているので、過学習のニオイもします...

```
#-- model evaluate
>>model.evaluate(X_test, y_test)
3/3 [==============================] - 1s 229ms/step - loss: 0.2917 - accuracy: 0.9136
[0.2916698753833771, 0.9135802388191223]
```

# 中間特徴量の可視化
出力で１０ノードにする手前の、n_nodes次元の特徴量を可視化してみます。
精度100%の訓練データを用いることにします。
二次元に可視化するためにお馴染みのPCAを用います。
話者毎とは言わずとも、男女毎に線形分離可能っぽい分かれ方をしていると良いなあ

```python
#-- 中間層の値を出力
layer_name = 'dense' # n_nodes数を持つDenseLayerの名前を指定
hidden_layer_model = Model(inputs=model.input, outputs=model.get_layer(layer_name).output) # 新たにモデルを生成
hidden_output = hidden_layer_model.predict(X_train)

#-- PCA
from sklearn.decomposition import PCA
pca = PCA(n_components=3)
X_pca=pca.fit_transform(hidden_output)

#-- PCA処理結果をプロット
_y=np.argmax(y_train,axis=1) #one-hotを1次元ベクトルに戻す
fig = plt.figure(figsize=(8,6))
ax = fig.add_subplot(111)
ax.scatter(X_pca[:, 0], X_pca[:, 1], c=_y,cmap='jet')
ax.set_xlabel('Principal Component 1', size=14)
ax.set_ylabel('Principal Component 2', size=14)
ax.set_title('PCA', size=16)
plt.show()
```

![pca_2dim.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1989761/4af32d63-d745-3f7a-2638-b1cfbc7a5225.png)
座標的には密集していますね...
三次元でみたらどうなる？
![pca_3d.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1989761/a6c130b8-ee4c-7a63-db05-a5ff124159bb.png)
X-Y平面→X-Z平面に。こう見ると、しっかりくっきり分かれていそうです！
カーネル法の説明図みたいになってしまいましたが、3次元空間でもそれなりに特徴が捉えられていることがわかります。ちなみにこの3次元の累積寄与率は80%でした。
じゃあn_nodesもそんなに大きくなくて良いかも。

# まとめ
今回は、学習済みモデルであるVGG16を用いた声紋認証を行ってみました。
男女各5人ずつ、そして１人あたりの声紋画像は25~30枚程度(1枚2.2秒)と<b>非常に少ないデータ数</b>でした。
それでも、<b>汎化性能で90%</b>と高い正解率。元モデルが画像分類用(≠音声)のモデルであることも興味深いです。
VGGishやYAMNet、ASTなど音声分類用の学習済モデルもあるので、それらを用いることでさらなる精度向上や応用もできそうですね。
とりあえず今回のモデルを組み込んで声紋認証でロック解除!とかできたら面白そうです。