Pythonで長い会議を見える化〜kmeansで発言検出〜

先日の記事「[Pythonで長い会議を見える化〜スペクトログラム描画の応用〜](https://qiita.com/toast-uz/items/2f9fc5a436bd1d9e97a5)」のさらに応用として、長い会議の発言検出をしてみました。

先日の記事をベースに、以下のような修正を加えました。**やっと機械学習っぽくなってきました**。

+ stft変換したデータを5秒単位に区切って、kmeansを使った教師なしクラスタリングにより、2クラスタに分割する
+ 発言部分がクラスタ1に、無言部分がクラスタ0になるように調整する（グラフ出力としては関係はないが、今後結果分析する際に有用）
+ クラスタ変化点をPandasのDataFrameで計算する
+ 10分単位に分割したグラフに、クラスタ変化点を縦棒で追記する

結果として、非常に良い感じに、発言部分と無言部分が分類されました。さらに、まだまだ初歩的な結果ですが、**会議のパターン分析につながるような、重要な結果が得られました**。

以下がコードです。ライブラリを使っているので当然ですが、実際の機械学習のコードは2行だけで、**ほとんどが前処理と見える化**です。

```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

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

# stft
nperseg = 256
noverlap = nperseg // 2
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]
Zxx = Zxx[:len(f)]

# スペクトログラムを描画するために相対デシベルを求める
dB = 20 * np.log10(np.abs(Zxx))
# interval単位に時間軸で区切った配列にする
interval = 5  # クラスタリングする単位秒
window = (interval * fs) // (nperseg - noverlap)
if dB.shape[1] % window != 0:
    dB = dB[:,:-(dB.shape[1] % window)]  # 余ったとこを捨てる
dB = np.reshape(dB.T, (-1, window, dB.shape[0])).transpose((0, 2, 1))
# 2次元化（interval単位での音声特徴量にする）
dB = dB.reshape(len(dB), -1)
dB[dB == -np.inf] = dB[dB != -np.inf].min()  # -np.infの置き換え
# 正規化
rscaler = preprocessing.MinMaxScaler()
dB_s = rscaler.fit_transform(dB)
print(f'Done preprocessing with transfered shape {dB_s.shape}.')

# クラスタリング（教師なし学習）
# k-means
clustering = KMeans(n_clusters=2).fit(dB_s)
labels = clustering.labels_
# 会議冒頭で発言から入ると、発言をクラスタ0として分類してしまうため調整
# all-0/all-1のデータをクラスタリングして、クラスタ0の無言/発言種別を判断
dB_zero, dB_one = np.zeros(dB_s.shape[1]), np.ones(dB_s.shape[1])
labels01 = clustering.predict(np.array([dB_zero, dB_one]))
assert labels01[0] != labels01[1]
if labels01[0] == 1:
    labels = 1 - labels   # all-0がクラスタ1であれば、クラスタを反転
# 単純に中央値で判別するコード（比較対象用）
#labels = (dB_s.mean(axis=1) > 0.5).astype(int)

# クラスタ変化点を抽出
df = pd.DataFrame([np.arange(0, len(labels)) * interval, labels]).T
df.columns = ['time', 'label']
df_separator = df[df.diff().label != 0]
print('Done clustering, show separators:')
pd.set_option('display.max_rows', None)
print(df_separator)
separator = np.array(df_separator.time)
# dfやdf_separatorをcsvやxlsxに出力することで結果を活用可能
# pandasのメソッドで簡単に出力することができる

print('Creating graph view ...')
# グラフ描画用に区切りをやりなおす
# interval単位に時間軸で区切った配列にする
interval = 300  # 5分
window = (interval * fs) // (nperseg - noverlap)
padding_size = window - Zxx.shape[1] % window
if padding_size != window:
    Zxx = np.append(Zxx, np.zeros((Zxx.shape[0], padding_size)), axis=1)
    t = np.linspace(0, Zxx.shape[1]/window*interval, Zxx.shape[1])
Zxx = np.reshape(Zxx.T, (-1, window, Zxx.shape[0])).transpose((0, 2, 1))
t = t[:window]
# スペクトログラムを描画するために相対デシベルを求める
dB = 20 * np.log10(np.abs(Zxx))
# スペクトログラムの描画
num_of_graph = dB.shape[0]
fig, ax = plt.subplots(num_of_graph)
if num_of_graph == 1:
    ax = (ax,)  # グラフ1つの時はaxがスカラーなので調整
for i in range(num_of_graph):
    ax[i].tick_params(labelleft=False)
    ax[i].set_ylabel(f'{interval*i//60}')  # 分で表示
    ax[i].pcolormesh(t, f, dB[i], shading='auto', cmap='jet')
    if i == 0:  # 最初のグラフだけ表題をつけて、全体の表題に見せる
        ax[i].set_title('Meeting spectrogram')
        ax[i].set_ylabel(f'{interval*i//60}(m)')
    if i == num_of_graph - 1:  # 最後のグラフだけx軸ラベルをつける
        ax[i].set_xlabel('Time(s)')
    else:
        ax[i].tick_params(labelbottom=False)
    # クラスタ変化時刻を縦棒で追記
    sub_separator = separator - i*interval
    sub_separator = sub_separator[(0<=sub_separator)&(sub_separator<interval)]
    ax[i].vlines(sub_separator, 0, filter_fs)
plt.show()
```

出力結果は以下です。ちなみにこの会議、36分55秒から55分30秒まで、1人の人がずっとしゃべっています。途中の「小休止」を検出していますが、**いい感じ**に、該当部分を連続した発言であると、分類しているようてす。

実は発言検出にあたって、stftを使った場合・使わなかった場合も比較してみたところ、**stftを使わない生データをクラスタリングしても、遜色無い結果**が出ました。スペクトログラムとして見える化するのにはstftは役立っていますが、発言検出くらいであれば関係ないようです。

![スクリーンショット 2020-12-02 20.19.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156600/14a9ced9-b2a8-7e5f-a095-9a4e326426ed.png)

一方、平均デシベルの中央値で固定的にクラスタ分割しようとすると、以下のような結果になります。分割されすぎですね。自動的に最適な閾値を求めることができるkmeansが、優れていることがわかります。

![スクリーンショット 2020-12-02 20.32.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156600/2b7bb622-d6dc-55ea-3cde-90d39e29a6d4.png)

また、話者分離をしようとしてkmeansを8クラスタにしてみると以下のような感じです。これは分割がすごいことになってしまっています。話者分離をするには、単純に今のやり方を延長するだけでは、うまくいかなさそうです。今後の課題です。

![スクリーンショット 2020-12-02 20.36.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156600/083b7818-d763-9d5d-f272-2bfaf8c6fcf4.png)

なお、以下が「正解データ」で、私が耳で聞き取って、話者ごとに通番を振ってみたものです（頻繁に出てくる1番は司会進行の人です）。最初の図のクラスタリング結果は、もちろん話者分離にはほど遠いてすが、それなりに良い感じに解析できていています。「長めの発言時間は独演状態になっている」「発言と無言が頻繁に切り替わっているところは議論が発生している」など、最初の結果だけでも、なんとなく「会議のパターン」が見えてくるように思います。

![会議.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156600/ba391151-dca8-d986-63f3-9d69ef4782e4.jpeg)