Pythonで長い会議を見える化〜kmeans vs spectral clustering〜

先日の記事「[Pythonで長い会議を見える化〜kmeansで発言検出〜](https://qiita.com/toast-uz/items/1f5a559ec67b443167c4)」の続きです。今回は、全体の流れをブラッシュアップするとともに、**kmeansに加えてspectral clusteringも使って比較**してみました。

先日の記事をベースに、以下のような修正を加えました。

+ stft変換したデータを5秒単位に区切っていたが、細かすぎる特徴量を活かし切れていないと感じたため、最初から5秒ウインドウでのstft変換に変更
+ stft後に相対デシベルを計算する段階で、log計算エラー防止のため、ホワイトノイズを追加
+ 教師なしクラスタリングのアルゴリズムとしてspectral clusteringも追加（使い分けはコードのコメントアウトで対応）
+ 無言部分がクラスタ0になるように調整する部分を、spectral clusteringや多クラスタにも、そのまま対応できるコードに修正
+ 相対デシベルの描画、クラスタ変化点の描画、に加えて、クラスタ塗り分けの描画も追加（塗り分けはコメントアウトで無効化）

これらの多くは、最近の話者分離の技術動向を参考にしています。それについては別記事を参照ください。

別記事「[Pythonで長い会議を見える化〜話者ダイアリゼーションの動向〜](https://qiita.com/items/44c6a12dbf10cb3055ca)」

kmeansとspectral clusteringの違いは、「[スペクトラルクラスタリング入門](https://techblog.nhn-techorus.com/archives/5464)」の図をご覧いただくと、直感的に分かるかと思います。spectral clusteringは連結性を考慮した分離が可能であるため、話者分離に向いているとされています。

結果としては、**今回の修正を加えたもとでのkmeansが、発言検出では最も正しそう**だ、となりました。また**話者分離まで進むと、spectral clusteringの効果がありそう**だ、となりました。

なお、正解データと比較した精度については、話者分離の技術動向の中で厳密な定量定義がされてきていますが、ここでは「見た感じ」で判断しています。

以下がコードです。spectral clusteringを使う場合は、コメントアウトを修正してください。

```Python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import soundfile as sf
from scipy import signal as sg
from sklearn import preprocessing
from sklearn.cluster import KMeans, SpectralClustering

# 入力はwavファイルであること（mp3等は入力前にツールで変換する）
x, fs = sf.read('meetings/meeting1.wav', always_2d=True)
duration = len(x) // fs
print(f'Read a soundfile with shape {x.shape},'
      f'rate {fs}(f/s), duration {duration}(s)')
# 2CHの平均をとってモノラル化する
x = x.mean(axis=1)

# stft
# ウインドウサイズを広く（秒単位）取り、重なりの比率を下げる
nperseg = fs * 5
noverlap = nperseg // 10
f, t, Zxx = sg.stft(x, fs=fs, nperseg=nperseg, noverlap=noverlap)
print(f'Done stft with transfered shape {Zxx.shape}')

# 人間の声を分析するため7kHz以上の高音はカット
# 最終フレームは無意味な値なので捨てる
filter_fs = 7000
f = f[f < filter_fs]
t = t[:-1]
Zxx = Zxx[:len(f),:-1]

# 発言分析はstftのウインドウ単位に行う
# スペクトログラムを描画するために相対デシベルを求める
# white_noiseは背景ノイズ 兼 logでのアンダーフロー防止
white_noise = 1e-10
dB = 20 * np.log10(np.abs(Zxx.T) + white_noise)
interval = (nperseg - noverlap) / fs
# 正規化
rscaler = preprocessing.MinMaxScaler()
dB_s = rscaler.fit_transform(dB)
print(f'Done preprocessing with transfered shape {dB_s.shape}.')
# 会議冒頭で発言から入ると、発言をクラスタ0として分類してしまうため調整
# all-0のデータをクラスタリングして基準値にする
dB_0s = np.vstack([np.zeros(dB_s.shape[1]), dB_s])

# クラスタリング（教師なし学習）
n_clusters = 8
# k-means
#clustering = KMeans(n_clusters=n_clusters).fit(dB_0s)
# spectral clustering
clustering = SpectralClustering(
    n_clusters=n_clusters, affinity='nearest_neighbors').fit(dB_0s)

labels = clustering.labels_
# 基準値がクラスタ0でなければクラスタ番号を変更して基準値を0にする
if labels[0] != 0:
    tmp = labels[0]
    labels[labels == 0] = -1
    labels[labels == labels[0]] = 0
    labels[labels == -1] = tmp
# 単純に中央値で判別するコード（比較対象用）
#labels = (dB_0s.mean(axis=1) > 0.5).astype(int)
labels = labels[1:]       # all-0を削除

# クラスタ変化点を抽出
df = pd.DataFrame([t, labels]).T
df.columns = ['time', 'label']
df.label = df.label.astype(int)
df_sep = df[df.label.diff() != 0].copy().reset_index(drop=True)
df_sep['dtime'] = -df_sep.time.diff(-1)
print('Done clustering, show separators:')
pd.set_option('display.max_rows', None)
print(df_sep)
# dfやdf_separatorをcsvやxlsxに出力することで結果を活用可能
# pandasのメソッドで簡単に出力することができる

print('Creating graph view ...')
# グラフ描画を複数行にするための幅と行数を求める
plt_width = 300  # 5分
plt_num = duration // plt_width + 1
# スペクトログラムの描画
fig, ax = plt.subplots(plt_num)
if plt_num == 1:
    ax = (ax,)  # グラフ1つの時はaxがスカラーなので調整
for i in range(plt_num):
    # デシベル描画
    plt_t = t - i*plt_width
    plt_t = np.append(0, plt_t[(0 < plt_t) & (plt_t < plt_width)])
    plt_dB = dB_s[int((i * plt_width) / interval):][:len(plt_t)]
    ax[i].pcolormesh(plt_t, f, plt_dB.T, shading='auto', cmap='jet')
    # クラスタ描画のための準備計算
    plt_sep = df_sep.copy()
    plt_sep.time = plt_sep.time - i * plt_width
    plt_sep1 = plt_sep[plt_sep.time <= 0].tail(1)
    plt_sep2 = plt_sep[(0 < plt_sep.time) & (plt_sep.time < plt_width)]
    plt_sep = pd.concat([plt_sep1, plt_sep2]).reset_index(drop=True)
    # クラスタ変化時刻を縦棒で描画
    ax[i].vlines(plt_sep.time[1:], 0, filter_fs)
    # クラスタを色分けして描画
    #ax[i].bar(plt_sep.time, filter_fs, plt_sep.dtime, align='edge',
    #    color=plt.get_cmap('jet')(256 * plt_sep.label // n_clusters))
    # ラベルや軸の描画
    ax[i].set_xlim(0, plt_width)
    ax[i].tick_params(labelleft=False)
    ax[i].set_ylabel(f'{plt_width * i // 60}')  # 分で表示
    if i == 0:  # 最初のグラフだけ表題をつけて、全体の表題に見せる
        ax[i].set_title('Meeting spectrogram')
        ax[i].set_ylabel(f'{plt_width * i // 60}(m)')
    if i == plt_num - 1:  # 最後のグラフだけx軸ラベルをつける
        ax[i].set_xlabel('Time(s)')
    else:
        ax[i].tick_params(labelbottom=False)
plt.show()
```

以下が出力結果です、[前回記事](https://qiita.com/toast-uz/items/1f5a559ec67b443167c4)の結果や正解データと比較しつつ、ご覧ください。

kmeans、n_clusters=2です。前回よりも分割が抑えられて、正解データにより近くなったようです。**前回・今回通してベストの結果**です。
![スクリーンショット 2020-12-05 11.19.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156600/a5ede396-061e-6a35-5f58-c7b54b154db2.png)

spectral clustering、n_clusters=2です。kmeansよりもさらに分割が減っています。spectral clusteringの仕組み上、kmeansよりも入り組んだベクトルに対しての同一視が行われやすいため、と思います。ただし、正解データに対してはkmeansの方が近い感じがします。**発言と無言の分離程度では、kmeansで単純に分離した方がよい**のかもしれません。
![スクリーンショット 2020-12-05 11.25.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156600/2957f57e-0965-be80-271d-28bb49b7f4be.png)

kmeans、n_clusters=8です。クラスタ塗り分け表示をしてみました。前回同様に、単純にクラスタ数を増やしただけでは、分割が細かすぎになってしまうようです。ただし、発言の多い1番、5番、13番について、なんとなく識別ができている感じではあります。

![スクリーンショット 2020-12-05 14.54.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156600/72d3f3b8-d059-0c6e-e027-dc2517b4107f.png)

spectral clustering、n_clusters=8です。意外とよい結果になった気がします。2番、3番、6番、13番を同一視してしまっていますが、全体の雰囲気は正解データに近いイメージです。単純な発言と無言の分離ではなく、**話者分離まで進むと、spectral clusteringの効果が出てくる**ものと考えます。
![スクリーンショット 2020-12-05 14.56.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156600/c1c65e7c-652b-2998-d4f0-42d033266427.png)