本記事では、Google Colaboratoryで日本語版BERTを使用し、livedoorニュースに対して、教師なし学習の情報量最大化クラスタリングを実施すると、どのようにクラスタリングされるのかを実装、解説します。
- Google Colaboratoryで日本語版のBERTを利用し、文章をベクトル化するまでの内容、
- MNISTでの教師なし学習の情報量最大化クラスタリング
について、これまでの連載記事で解説したので、まずはこちらをご覧ください。
連載一覧
[1]【実装解説】日本語版BERTをGoogle Colaboratoryで使う方法(PyTorch)
[2]【実装解説】日本語版BERTでlivedoorニュース分類:Google Colaboratoryで(PyTorch)
[3]【実装解説】脳科学と教師なし学習。情報量最大化クラスタリングでMNISTを分類
[4] ※本記事【実装解説】日本語BERT × 教師なし学習(情報量最大化クラスタリング)でlivedoorニュースを分類
前回は、MNISTの手書き数字画像に対して、論文
Invariant Information Clustering for Unsupervised Image Classification and Segmentation
の、相互情報量を利用した教師なし学習のクラスタリング
IIC(Invariant Information Clustering)
を実施しました。
今回は、livedoorニュースの各ニュース記事を日本語BERTでベクトル化し、それをIICで教師なし学習(クラスタリング)した場合にどうなるのかを示します。
MNISTの場合はクラスタリング結果が、教師あり学習の数字ラベル通りに分かれ、教師あり学習にも使えそうな良い感じになりましたが、テキストデータの場合はどうなるのでしょうか?
これを試します。
本実装の流れは、
- livedoorニュースをダウンロードして、PyTorchのDataLoaderに変換
- BERTでlivedoorニュースの記事をベクトル化
- IICのディープラーニングモデルを用意
- IICのネットワークを学習させる
- テストデータを推論
となります。
なお本投稿内容の実装コードは以下のGitHubリポジトリに置いています。
GitHub:日本語版BERTのGoogle Colaboratoryでの使用方法:実装コード
の、4_BERT_livedoor_news_IIC_on_Google_Colaboratory.ipynbです。
1. livedoorニュースをダウンロードして、PyTorchのDataLoaderに変換
ここまでの内容は、
[2]【実装解説】日本語版BERTでlivedoorニュース分類:Google Colaboratoryで(PyTorch)
の通りなので、本記事への掲載は省略します。
最後に、以下を実施して、訓練、検証、テストのDataLoaderをそれぞれ作成します。
# DataLoaderを作成します(torchtextの文脈では単純にiteraterと呼ばれています)
batch_size = 16 # BERTでは16、32あたりを使用する
dl_train = torchtext.data.Iterator(
dataset_train, batch_size=batch_size, train=True)
dl_eval = torchtext.data.Iterator(
dataset_eval, batch_size=batch_size, train=False, sort=False)
dl_test = torchtext.data.Iterator(
dataset_test, batch_size=batch_size, train=False, sort=False)
# 辞書オブジェクトにまとめる
dataloaders_dict = {"train": dl_train, "val": dl_eval}
BERTでlivedoorニュースの記事をベクトル化
日本語版BERTでlivedoorニュースの記事本文をベクトル化します。
ドキュメントのベクトル化となります。
今回はシンプルに、BERTの先頭単語([CLS])の埋め込みベクトル768次元をドキュメントベクトルとして扱うことにします(ドキュメントベクトルの作り方や妥当性はいろいろあります)。
IICによるクラスタリングのニューラルネットワークを学習させる際に、毎回文書データをベクトル化するのは時間がもったいないので、BERTでドキュメントベクトルに変換したDataLoaderを作成します。
実装は次の通りです。
まずはBERTの本体を用意します。東北大学の日本語学習済みパラメータ版です。
from transformers.modeling_bert import BertModel
# BERTの日本語学習済みパラメータのモデルです
model = BertModel.from_pretrained('bert-base-japanese-whole-word-masking')
model.eval()
print('ネットワーク設定完了')
続いて、BERTでベクトル化する関数を定義します。
# BERTでベクトル化する関数を定義
def vectorize_with_bert(net, dataloader):
# GPUが使えるかを確認
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print("使用デバイス:", device)
print('-----start-------')
# ネットワークをGPUへ
net.to(device)
# ネットワークがある程度固定であれば、高速化させる
torch.backends.cudnn.benchmark = True
# ミニバッチのサイズ
batch_size = dataloader.batch_size
# データローダーからミニバッチを取り出すループ
for index, batch in enumerate(dataloader):
# batchはTextとLableの辞書オブジェクト
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
inputs = batch.Text[0].to(device) # 文章
labels = batch.Label.to(device) # ラベル
# 順伝搬(forward)計算
with torch.set_grad_enabled(False):
# Berに入力
result = net(inputs)
# sequence_outputの先頭の単語ベクトルを抜き出す
vec_0 = result[0] # 最初の0がsequence_outputを示す
vec_0 = vec_0[:, 0, :] # 全バッチ。先頭0番目の単語の全768要素
vec_0 = vec_0.view(-1, 768) # sizeを[batch_size, hidden_size]に変換
# ベクトル化したデータをtorchリストにまとめる
if index == 0:
list_text = vec_0
list_label = labels
else:
list_text = torch.cat([list_text, vec_0], dim=0)
list_label = torch.cat([list_label, labels], dim=0)
return list_text, list_label
定義した関数で、訓練、検証、テストのDataLoaderをそれぞれベクトル化します。
# DataLoaderをベクトル化版に変換
# 少し時間がかかります5分弱
list_text_train, list_label_train = vectorize_with_bert(model, dl_train)
list_text_eval, list_label_eval = vectorize_with_bert(model, dl_eval)
list_text_test, list_label_test = vectorize_with_bert(model, dl_test)
次に、このリストをPyTochのDatasetにします。
# torchのリストをDatasetに変換
from torch.utils.data import TensorDataset
dataset_bert_train = TensorDataset(
list_label_train.view(-1, 1), list_text_train)
dataset_bert_eval = TensorDataset(list_label_eval.view(-1, 1), list_text_eval)
dataset_bert_test = TensorDataset(list_label_test.view(-1, 1), list_text_test)
最後にDatasetをDataLoaderにします。このDataLoaderをIIC(Invariant Information Clustering)のネットワーク学習に使用します。
# Dataloaderにする
from torch.utils.data import DataLoader
batch_size = 1024
dl_bert_train = DataLoader(
dataset_bert_train, batch_size=batch_size, shuffle=True, drop_last=True)
# drop_lastは最後のミニバッチがbatch_sizeに足りない場合は無視する
dl_bert_eval = DataLoader(
dataset_bert_eval, batch_size=batch_size, shuffle=False)
dl_bert_test = DataLoader(
dataset_bert_test, batch_size=batch_size, shuffle=False)
以上で、livedoorニュースの本文を日本語BERTでベクトル化できました。
あとはこのベクトル化されたデータをクラスタリングするだけです。
準備3:IICのディープラーニングモデルを用意
次にIICのディープラーニングモデルを用意します。
この部分は基本的に、
【実装解説】脳科学と教師なし学習。情報量最大化クラスタリングでMNISTを分類
と同じ構成です。
ニューラルネットワークのモデルは768次元のベクトルに対応した内容にします。
今回は1dの畳み込みを繰り返して、特徴量を変換させています。
overclusteringで実際に推定するクラスタリング数の10倍のクラスタリングも同時に実施し、微細な特徴を捉えるネットワークを学習させます。
import torch.nn as nn
import torch.nn.functional as F
OVER_CLUSTRING_RATE = 10
class NetIIC(nn.Module):
def __init__(self):
super(NetIIC, self).__init__()
# multi-headは今回しない
self.conv1 = nn.Conv1d(1, 400, kernel_size=768, stride=1, padding=0)
self.bn1 = nn.BatchNorm1d(400)
self.conv2 = nn.Conv1d(1, 300, kernel_size=400, stride=1, padding=0)
self.bn2 = nn.BatchNorm1d(300)
self.conv3 = nn.Conv1d(1, 300, kernel_size=300, stride=1, padding=0)
self.bn3 = nn.BatchNorm1d(300)
self.fc1 = nn.Linear(300, 250)
self.bnfc1 = nn.BatchNorm1d(250)
# livedoorニュースの9カテゴリに対応するかな?と期待する9分類
self.fc2 = nn.Linear(250, 9)
# overclustering
# 実際の想定よりも多めにクラスタリングさせることで、ネットワークで微細な変化を捉えられるようにする
self.fc2_overclustering = nn.Linear(250, 9*OVER_CLUSTRING_RATE)
def forward(self, x):
x = x.view(x.size(0), 1, -1)
x = F.relu(self.bn1(self.conv1(x)))
x = x.view(x.size(0), 1, -1)
x = F.relu(self.bn2(self.conv2(x)))
x = x.view(x.size(0), 1, -1)
x = F.relu(self.bn3(self.conv3(x)))
x = x.view(x.size(0), -1)
x_prefinal = F.relu(self.bnfc1(self.fc1(x)))
# multi-headは使わず
y = F.softmax(self.fc2(x_prefinal), dim=1)
y_overclustering = F.softmax(self.fc2_overclustering(
x_prefinal), dim=1) # overclustering
return y, y_overclustering
モデルの重みパラメータの初期化関数を定義します。
import torch.nn.init as init
def weight_init(m):
"""重み初期化"""
if isinstance(m, nn.Conv1d):
init.normal_(m.weight.data)
if m.bias is not None:
init.normal_(m.bias.data)
elif isinstance(m, nn.BatchNorm1d):
init.normal_(m.weight.data, mean=1, std=0.02)
init.constant_(m.bias.data, 0)
elif isinstance(m, nn.Linear):
# Xavier
# init.xavier_normal_(m.weight.data)
# He
init.kaiming_normal_(m.weight.data)
if m.bias is not None:
init.normal_(m.bias.data)
IID用の損失関数の計算方法を定義します。
対象のベクトル化データをNetIIDに入力した際の出力(x_out)と、対象のベクトル化データに変換をかけたデータをNetIIDに入力した際の出力(x_tf_out)の、相互情報量を求めます。
ここの実装のさらなる詳細については、前回記事をご覧ください。
【実装解説】脳科学と教師なし学習。情報量最大化クラスタリングでMNISTを分類
# IISによる損失関数の定義
# 参考:https://github.com/RuABraun/phone-clustering/blob/master/mnist_basic.py
import sys
def compute_joint(x_out, x_tf_out):
bn, k = x_out.size()
assert (x_tf_out.size(0) == bn and x_tf_out.size(1) == k), '{} {} {} {}'.format(
bn, k, x_tf_out.size(0), x_tf_out.size(1))
p_i_j = x_out.unsqueeze(2) * x_tf_out.unsqueeze(1) # bn, k, k
p_i_j = p_i_j.sum(dim=0) # k, k
p_i_j = (p_i_j + p_i_j.t()) / 2. # symmetrise
p_i_j = p_i_j / p_i_j.sum() # normalise
return p_i_j
def IID_loss(x_out, x_tf_out, EPS=sys.float_info.epsilon):
# has had softmax applied
bs, k = x_out.size()
p_i_j = compute_joint(x_out, x_tf_out)
assert (p_i_j.size() == (k, k))
p_i = p_i_j.sum(dim=1).view(k, 1).expand(k, k)
p_j = p_i_j.sum(dim=0).view(1, k).expand(k, k)
# avoid NaN losses. Effect will get cancelled out by p_i_j tiny anyway
# これはPyTorchのバージョン1.3以上だとエラーになる
# https://discuss.pytorch.org/t/pytorch-1-3-showing-an-error-perhaps-for-loss-computed-from-paired-outputs/68790/3
#p_i_j[(p_i_j < EPS).data] = EPS
#p_j[(p_j < EPS).data] = EPS
#p_i[(p_i < EPS).data] = EPS
p_i_j = torch.where(p_i_j < EPS, torch.tensor(
[EPS], device=p_i_j.device), p_i_j)
p_j = torch.where(p_j < EPS, torch.tensor([EPS], device=p_j.device), p_j)
p_i = torch.where(p_i < EPS, torch.tensor([EPS], device=p_i.device), p_i)
# https://qiita.com/Amanokawa/items/0aa24bc396dd88fb7d2a
# 参考に、重みalphaを追加
alpha = 2.0
loss = (- p_i_j * (torch.log(p_i_j) - alpha *
torch.log(p_j) - alpha*torch.log(p_i))).sum()
return loss
次に、対象のベクトル化データにかける変換を定義します。
この変換関数がIICのキモになります。
画像データの場合は通常のデータオーギュメンテーションに従った、アフィン変換(回転・伸ばす)、アスペクト比の変更、クリッピング(切り抜き)、ノイズ付与を与えたりします。
テキストデータの場合、どうしましょう・・・
Kaggleなどのコンペでのデータオーギュメンテーションでは、一度他の言語に翻訳して戻すなどを行ったりします。
今回は、単純に全ベクトルデータの標準偏差に基づくノイズを加えることにします。
# データにノイズを加える関数の定義
device = 'cuda' if torch.cuda.is_available() else 'cpu'
tensor_std = list_text_train.std(dim=0).to(device)
def perturb_data(x):
y = x.clone()
noise = torch.randn(len(tensor_std)).to(device)*tensor_std*2.0
noise = noise.expand(x.shape[0], -1)
y += noise
return y
4:IICのネットワークを学習させる
以上でDataLoaderとIICのモデルが用意できたので、IICのモデルの重みを学習させます。
まず、訓練の関数を定義します。
# 学習関数の定義
def train(total_epoch, model, train_loader, optimizer, device):
# ネットワークを訓練モードに
model.train()
# 学習率のスケジューラーCosAnnealing
scheduler = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(
optimizer, T_0=2, T_mult=2, eta_min=0)
for epoch in range(total_epoch):
for batch_idx, (target, data) in enumerate(train_loader):
# 学習率変化
scheduler.step()
data_perturb = perturb_data(data) # ノイズを与え、変換したデータを作る
# GPUに送れる場合は送る
data = data.to(device)
data_perturb = data_perturb.to(device)
# 最適化関数の初期化
optimizer.zero_grad()
# ニューラルネットワークへ入れる
output, output_overclustering = model(data)
output_perturb, output_perturb_overclustering = model(data_perturb)
# 損失の計算
loss1 = IID_loss(output, output_perturb)
loss2 = IID_loss(output_overclustering,
output_perturb_overclustering)
loss = loss1 + loss2
# 損失を減らすように更新
loss.backward()
optimizer.step()
# ログ出力
if epoch % 50 == 0:
print('Train Epoch {} \tLoss1: {:.6f} \tLoss2: {:.6f} \tLoss_total: {:.6f}'.format(
epoch, loss1.item(), loss2.item(), loss1.item()+loss2.item()))
return model, optimizer
上記の訓練で、学習率は、schedulerのCosineAnnealingWarmRestartsを用意し、変化させています。
これは以下のように学習率を変化させ、小さい値から急激に大きくしたときに局所解から抜け出し、大域的な極小解へとパラメータを学習させやすくする工夫です。
図:引用
https://www.kaggle.com/c/imet-2019-fgvc6/discussion/94783
学習を実施します。
今回はバッチサイズが1,024でデータ自体が4,000ほどなので、epoch数を多くしています。
学習にかかる時間は5分弱です。
# 学習の実施(5分弱)
total_epoch = 1000
optimizer = torch.optim.Adam(net.parameters(), lr=5e-4) # 最適化関数
model_trained, optimizer = train(
total_epoch, net, dl_bert_train, optimizer, device)
学習が完了したので、テストデータで推論してみます。
結果を把握しやすいように、ミニバッチサイズ1のテスト用のDataLoaderを用意しなおして、1つずつ推論し、
結果を格納します。
その関数を以下に定義します。
# モデル分類のクラスターの結果を確認する
import numpy as np
# ミニバッチサイズ1のテスト用のDataLoaderを用意
dl_bert_test = DataLoader(
dataset_bert_test, batch_size=1, shuffle=False)
def test(model, device, test_loader):
model.eval()
out_targs = []
ref_targs = []
# 出力用のリストを用意
total_num = len(test_loader)
# index, (target_label, inferenced_label)
output_list = np.zeros((total_num, 2))
with torch.no_grad():
for batch_idx, (target, data) in enumerate(test_loader):
data = data.to(device)
target = target.to(device)
outputs, outputs_overclustering = model(data)
# 分類結果をリストに追加
out_targs.append(outputs.argmax(dim=1).cpu())
ref_targs.append(target.cpu())
# 結果をリストにまとめる
output_list[batch_idx, 0] = target.cpu() # 正解ラベル
output_list[batch_idx, 1] = outputs.argmax(dim=1).cpu() # 予測ラベル
out_targs = torch.cat(out_targs)
ref_targs = torch.cat(ref_targs)
return out_targs.view(-1, 1).numpy(), ref_targs.numpy(), output_list
5. テストデータを推論
テストデータで推論を実行します。
# テストデータで推論を実施
out_targs, ref_targs, output_list = test(model_trained, device, dl_bert_test)
テストデータでの推論結果の混同行列的な頻度表を確認します。
# 混同行列(的な)を作る
matrix = np.zeros((9, 9))
# 縦にlivedoorニュースの正解クラスを、横に判定されたクラスの頻度表を作成
for i in range(len(out_targs)):
row = ref_targs[i]
col = out_targs[i]
matrix[row][col] += 1
np.set_printoptions(suppress=True)
print(matrix)
出力結果は以下の通りです。
[[ 55. 0. 1. 0. 4. 47. 2. 76. 0.]
[ 3. 40. 4. 0. 14. 1. 1. 0. 116.]
[ 7. 39. 21. 4. 16. 3. 6. 3. 1.]
[ 11. 60. 16. 4. 13. 8. 27. 2. 17.]
[ 8. 6. 20. 107. 1. 8. 16. 0. 1.]
[ 11. 17. 15. 0. 40. 6. 78. 7. 0.]
[ 18. 3. 65. 40. 13. 15. 14. 3. 1.]
[ 63. 7. 45. 11. 2. 42. 7. 1. 1.]
[ 27. 0. 6. 0. 4. 61. 1. 61. 1.]]
なお、縦軸は、dic_id2cat
の中身を確認し、以下の順番です。
{0: 'sports-watch',
1: 'dokujo-tsushin',
2: 'livedoor-homme',
3: 'peachy',
4: 'smax',
5: 'movie-enter',
6: 'it-life-hack',
7: 'kaden-channel',
8: 'topic-news'}
まず、MNISTのときのようにクラスと推定されたクラスタはきれいに一致していません。
これはまあ、そんなものかなと思います。
「大量の文書を数種類に分類せよ」と命じられると、人間でも様々なパターンで分類(クラスタリング)すると思います。
livedoorニュースを9分類せよ、と言われると、今回はこのような結果になりました。
推定されたクラスタの2番目は「it-life-hack」と「kaden-channel」が多いです。
推定されたクラスタの3番目は「smax(スマホやガジェット系)」が多いです。
推論された最後のクラスタは、「独女通信」が多いクラスタのようです。
最後から2番目は「sports-watch」と「topic-news」が多いです。
「sports-watch」と「topic-news」のクラスは、推定されたクラスタでは、
0番目、5番目、7番目に分離されています。
「sports-watch」の5番目のクラスタの文章、7番目のクラスタの文章
「topic-news」の5番目のクラスタの文章、7番目のクラスタの文章
を確認して、クラスタ5とクラスタ7の特徴を見てみます。
# クラスタの結果を確認
#「sports-watch」の5番目のクラスタの文章、7番目のクラスタの文章
#「topic-news」の5番目のクラスタの文章、7番目のクラスタの文章
# を確認して、クラスタ5とクラスタ7の特徴を見てみます。
import pandas as pd
df2 = pd.DataFrame(output_list)
df2.columns=["正解クラス", "推定クラスタ"]
df2.head()
df2[(df2['正解クラス']==0) & (df2['推定クラスタ']==5)].head()
出力は以下となります。
正解クラス 推定クラスタ
21 0.0 5.0
59 0.0 5.0
126 0.0 5.0
142 0.0 5.0
153 0.0 5.0
テストデータセットの文章の21番目、59番目を見てみましょう。
# dfに元文書を入れている。300文字ほど見る。
print(df.iloc[21, 0][:300])
print(df.iloc[59, 0][:300])
「sports-watch」の5番目のクラスタの文章1つ目は、
先月29日、プロレスラーで、その引退後は、選手育成や解説者、レフェリーとしてファンに愛された山本小鉄さんが低酸素脳症で急逝、ファン&関係者に深い悲しみを与えた。そんな折、今週7日発売の「週刊アサヒ芸能」は、「NEWS SHOT!」のコーナーにおいて、山本さんの“死去直前”の豪傑ぶりをうかがわせる驚くべき行動を報じた。同誌にコメントを寄せた、元週刊プロレス編集長・ターザン山本氏は、「身長170センチに体重113キロの体は、いまだに現役時代を彷彿とさせる筋肉を維持していました。若手同様の過酷なトレーニングをし、68歳とは思えぬ食欲でしたが、実は山本さんは糖尿病だったんです」と明かし、また、先月行わ
となりました。
同様にして、
「sports-watch」の7番目のクラスタの文章1つ目は、
「あれで全てが狂った」「ピッチャーのことは触ったこともない」「誰も信用してない」など、とにかく衝撃的な言葉が相次ぎ飛び出した中日ドラゴンズ元監督・落合博満氏のインタビュー。日本テレビ「Going! Sports&News」では、野球解説者の江川卓氏が聞き手となり、その模様は二夜(17日、18日)に渡って放送された。初回放送分では、「来年ユニフォーム着るってなったら、ここまでは喋らない」とまでいい、知られざるエピソードを明かした落合氏だったが、後編の放送は、これを上回るといっていい更に驚くべき内容となった。以下にインタビューの要約を掲載する。江川:今年は日本一になれると思っていた?思っていなかっ
です。「topic-news」の5番目のクラスタの文章1つ目は、
韓国では一般的に僧侶は独身であることが原則となっているが、「異性からモテず、絶望し出家を決意した」と語る僧侶のFacebookが韓国のネット掲示板で話題になっている。ヒョボンという名前のこの僧侶は、19日、Facebookで「20代の私は異性に人気がありませんでした。(中略)結局どうしようもないという結論に至り、出家を決心しました。皆さんも諦めて出家しなさい。今になって考えれば若いころ、ラップやパンクをすべきだった」と、僧侶になった背景を語っている。また、僧侶生活に関しては「早く出家するほど偉くなるのも早い。偉くなったらご飯と洗濯を悩まなくてもいい」とも語り、ほかにも「アイドルのPVはセクシー
です。「topic-news」の7番目のクラスタの文章1つ目は、
18日、日本経済新聞が「被災地もう一つの異常事態 復興特需・原発賠償金・・・マネー流入ゆがむ再生」と題した記事で被災地の歪んだ現実を報じ、ネット掲示板で物議を醸している。同記事では、賠償を受ける対象の家族が五人家族であれば月80万円が懐に入ると指摘し、「東電から金もらって、働かなくてもパチンコしたり、すし食ったりスマホとかばんばん新しくしている」という現地の人の言葉を掲載。この被災者に流れる賠償金とその使い方を「ゆがむ再生」として位置づけている。ネット掲示板では「一切募金しなかったおれに死角はなかったな」「震災さまさまやな」「スマホと寿司は消費だからかまわんのだが、パチンコはダメだって・・・。
です。
文章を見ただけでは、あまり特徴は分かりませんね。。。
ただ、スポーツとニュースは、記事の雰囲気がとても似ていることは分かります。
これ以上に、IICされたクラスタの特徴をきちんと把握するには、
- wordcloudで単語の頻度の傾向を見てみる
- クラスタ文書の全ベクトルをいじいじして、なんらかクラスタのベクトルを作って、代表文書を決めたり近い単語を出す
などの操作が考えられます。
本記事でそこまでやると長すぎるので、今回はここまでで。
なお本投稿内容の実装コードは以下のGitHubリポジトリに置いています。
GitHub:日本語版BERTのGoogle Colaboratoryでの使用方法:実装コード
の、4_BERT_livedoor_news_IIC_on_Google_Colaboratory.ipynbです。
以上、日本語BERTでの教師なし学習のIICクラスタリングとして、livedoorニュースを対象とした実装例と解説でした。
ご一読いただき、ありがとうございました。
【備考】私がリードする、AIテクノロジー部開発チームはメンバ募集中です。興味がある方はこちらから
【免責】本記事の内容そのものは著者の意見/発信であり、著者が属する企業等の公式見解ではございません