astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

BERTの事前学習済みモデルを使って特許検索に挑戦してみる

f:id:astamuse:20200108191002p:plain

ご挨拶

新年、明けましておめでとうございます
本年もよろしくお願いいたします

データエンジニアのaranです
昨年の6月以来、2回目の登場になります

去年の1月に入社して、ちょうど1年経ち
月日の流れの早さを感じています

いきなりですが、皆様は年末年始をどのように過ごされましたでしょうか?
私は、家族の1人が、1月2日誕生日なこともあり
年始は、家族全員が本家に集まります

月日の経過と共に、甥っ子・姪っ子も増え、彼らも歳を重ねて行くので
年始の出費が、年々厳しくなっています
(財布から諭吉がいっぱい消えてゆく・・・涙)

年始の出費が気にならないぐらい稼ぎたいって心に誓い、新年を過ごしました

前書き

前回はコレスポンデンス分析についてお話しさせて頂きましたが
今回は、BERTを使った文章検索についてお話しさせて頂きます

何でまた?

弊社では、特許情報を取り扱っていて
ある特定の業界・分野の特許群を検索することがあります

目的の特許を検索するには
J-PlatPatと同じように単語マッチングで検索することが多いのですが
見つけたい特許を検索するには
どの単語を選択し、どのような組み合わせ条件にするかが重要で
これには、高度なスキルと経験を必要とします

このスキルに依存する状況を、少しでも改善させるベく
ある分野に関する1つ特許の要約・請求項・詳細説明等の文章から
その分野に関連する特許一覧を検索できれば
検索品質の平準化と作業工数の削減が期待できます

そこで、文章分類・質問応答・固有表現抽出等で
公開当時の最高性能(SOTA: State of the Art)を達成した
BERTに目をつけ、特許検索に使えるかどうか遊んで検証してみました
BERTを触っていると言えると、カッコつけられるとかでは・・

BERTとは

BERT(Bidirectional Encoder Representations from Transformers)は
2018年10月にGoogleの研究チームが公開した言語表現モデルです

論文:BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

今回、BERTを利用するに辺り、こちらのサイトで勉強させて頂きました www.ogis-ri.co.jp

BERTの特徴を1つあげるとすれば
事前学習(ラベルが不要)、ファインチューニング(ラベルが必要)と
2段階の学習ができる構成になります

事前学習は、大量データが必要のため、学習時間もリソース(お金)も必要としますが
事前学習済みモデルがGitにて公開されています
お試し段階では、これは非常に助かります!!
今回は、こちらの学習済みモデルを利用しました

何のデータを使うの?

弊社は国内特許のみならず、海外特許も取り扱います
検索対象にする特許情報は、EPO(欧州特許庁)のPATSTAT データベースを利用しました

PATSTATは、100ヶ国以上の特許庁において公開された特許が
収録されている特許データベースになります

どんな事をやるの?

PATSTATは英語の特許が多いので
特許の要約(英文)で検索できるようにして、類似した順に表示することを目的とし
複数の事前学習済みモデルで精度を比較してみました

やってみる

  1. 制約
    まず、BERTのREADME を読みますと、検索する文章を512トークン以下にする制約がありました
    (トークンについては、リンクを参照願います)

  2. 前提条件
    PATSATにある特許の要約(英文のみ)で
    512トークン以下になるだろう割合は99.5%以上を占めるため
    今回は、上記制約を考慮していません

  3. 評価データを用意
    1分野につき約20件、9分野で約180件データを用意してもらいました
    (お忙しい中、他部署の方にご協力頂きました!ありがとうございます)

  4. 評価データの妥当性チェック
    評価データ約180件を、プログラム上で9つに分類できないと
    特徴がない(同じような)文章になり、評価が難しくなります
    今回用意した評価データだけ、正しく分類できるかどうかを確認しました

  5. 非評価データの作成
    評価データ20件とは違う分野の特許を1,000件を作成しました
    非評価データの抽出方法は、 評価データに付与されている国際特許分類のクラスが違う特許を
    ランダムサンプリングしました

  6. 特許の要約をベクトル化
    評価データ20件、非評価データ1,000件の要約を
    BERTを使い、768次元のベクトルに変換しました

  7. 変換したベクトルデータを近傍探索
    評価データ20件+非評価データ1,000件を対象として
    全評価データで検索しました

  8. 検索結果を評価
    検索結果TOP20に、評価データが何件含まれるかでモデルを評価しました
    全評価データ20件を検索しているので、精度はアベレージになります

どうやって?

特許の要約をベクトル化するには bert-as-serviceを使いました

import os
import csv
from bert_serving.client import BertClient

# CSVファイル(アブスト一覧)を読み込む
with open('./abstracts.csv', encoding="utf_8") as f:
    abstracts = list(csv.reader(f, delimiter=",", doublequote=True, lineterminator="\r\n", quotechar='"', skipinitialspace=True))

# 要約をベクトル化する
with BertClient() as bc:
    vectors = bc.encode(abstracts)

評価データの妥当性チェックには
K-Meansでクラスタ分析しました

import pandas as pd
from sklearn.cluster import KMeans

# TSVファイル(ベクトル化データ+正解ラベル付)を読み込む
vec = pd.read_csv('./vec.tsv', sep='\t', header=None)

# クラスタ分析
km = KMeans(n_clusters=9, init='k-means++', n_init=10, max_iter=10000, tol=1e-06, n_jobs=-1)
pred = km.fit_predict(vec.iloc[:, :-1])

尚、評価データ(要約)に対してデータクレンジングすると
F1スコアが0.75から0.93まで上がりました

これにより、評価データを十分な精度でクラスタリングできるため
有効なデータと判断しました

文章の類似度判定には
単純に検索する文章ベクトルとのユーグリッド距離で
類似度をスコアリングしました

import pandas as pd

# 評価データ20件+非評価データ1,000件のベクトルデータを読み込む
vectors = pd.read_csv('./vectors.tsv', sep='\t', header=None)

# 評価データ各20件を文章検索する
# ※search_keyは、検索するINDEXを任意に設定しています
search_abstracts = vectors.iloc[search_key, :768].values
target_abstracts = vectors.iloc[:, :768].values
# 類似度スコアはユーグリッド距離とする
result ={i:np.linalg.norm(search_abstracts - target_abstracts[i]) for i in range(len(target_abstracts))}

まとめ

最終的に3つの学習済モデルで、精度検証しました
学習済みモデル別の各9分野の精度結果は、こちらになります

f:id:astamuse:20200109012715p:plain

具体的な分野名を明示できず、申し訳ないのですが
各分野で精度にバラツキがあり、類似する特許を検索しやすい分野と
しにくい分野があることがわかりました

また、本来なら 要約だけではなく タイトル、本文、請求項、出願者等の要素も考慮した上で
類似度スコアリングをしますが
要約だけでも、ある程度類似する特許を検索できたことで
BERTのポテンシャルの高さを感じました
(今回は評価データが少ないため、偏った結果になっているかもしれませんが)

最後に

今回のお話で
俺・私・ミーの方がもっとできるよって思う人はたくさんいると思います
そんな方は是非、弊社で一緒に働きませんか?

ここにジョインするまで、自然言語処理は未経験でしたが
手を挙げればチャレンジさせてもらえる弊社は
エンジニアライフを楽しめる会社だと、勝手に自負しています!!

アスタミューゼでは、エンジニア・デザイナーを募集中です
ご興味のある方は遠慮なく採用サイトからご応募ください。お待ちしています

GitLab CIを四倍速にした話

f:id:astamuse:20191220191229p:plain

chotaroです。豊洲PITでたくさんの人と頭の中のOMOIDEを共有してから一週間、興奮冷めやらぬ日々です。 astamuse lab年内最後の記事になります!

さて先日、ICPチームで利用しているGitLab CIの実行時間を4分の1にしました。 一度CIを動かすと1時間強待ちが発生する状況になっていたため、改善が急務でした。改善の結果と、どんなことをしたか自分の備忘も兼ねて書いておこうと思います。
悩める人の参考になれば嬉しいです。

before/after

10月に改善を実施したので、9月と11月の比較になります。

pipeline実行回数 合計実行時間(時間[分]) 平均実行時間
9月 164回 114時間[6880分] 0:41:57
11月 106回 15時間[945分] 0:08:55

1回のpipelineにかかる時間がなんと4分の1以下になりました(๑•̀ㅂ•́)و✧
せめて平均30分以内、を目指していたので大成功です!

改善ポイント

以下が主なポイントになります。

  • 無駄なjobは動かさないようにしましょう
  • 一度pullしたdocker imageを再利用するようにしましょう。
  • executorのスペックを見直しましょう。
  • 毎回利用する外部リソース(依存するjarとか)が存在する場合、cacheを活用して使いまわしましょう。

当たり前ですね!(※できてなかった

基本的には公式documentの記述をもとにそれぞれの環境に当てはめていけばいい感じになります。

それでは良いCIライフを!アデュー!

......というのもあんまりなのでかなり泥臭い内容ですが、私の闘いの記録を残します。

改善する前の状況

  • 言語は java / scala
  • マルチプロジェクトな構成のリポジトリ
  ~/pjroot
.
├── .git
├── .gitlab-ci.yml
├── api
│   ├── src
│   └── test
├── batch
│   ├── src
│   └── test
├── commonLibrary
│   ├── src
│   └── test
└── web
    ├── src
    └── test
  • 各PJで自動testを実施
  • どんな小さな変更でもすべてのテストを実施(README変更でもpipelineが実行されてしまう)
  • 一度のpipelineですべてのプロジェクトについて順次テストを実行
  • コミットのたび毎回全部のjobが実行される

->諸々の結果、一回あたり1時間強かかる上、pipelineの実行回数が無駄に多い

GitLab CIについて

利用方法

GitLab上で利用できるCIです。
ドキュメントはこちら
紙面の都合から詳細は省きますm( )m

ざっくり概要

  • ymlでjobや環境の設定を管理することができる
  • gitlab-runnerというCIランナーをgitlab上のGUIで管理して利用する。
  • executorがjobを実際に実行する。
    • executorには様々なものを設定できる。dockerや仮想マシン インスタンスなど。(インスタンスについてはawsやgcpが利用可能。)

我々の利用方法

  • GCPインスタンス上でgitlab-runnerが動いている
  • executorはdocker-machine形式により実行。
    • インスタンスを立ち上げ, dockerコンテナをそれ上で動かしてjobを実行する。

現状を確認してボトルネックを明確にする

jobの実行をgitlab上で観測する

まずどう動いているのかがわからなかったので、ひたすら眺めてみました。

  • gitlab-ciのjobのログをひたすら眺める
    • どこで詰まっているのかを調査するため、timeコマンドを挟むなどして実行時間を確認する
  • gitlab-runnerにモニタリングツールを挿してもらう
    • オートスケールの動き方など、どのようにexecutorがjobを処理していくのかをGUIで観測する

手元でrunnerを立てて観測してみる

リソースの状況などもっと詳細を確認したかったので手元でrunner、およびexecutorを立てて眺めてみました。

  • gitlab-runnerは手元で実行することが可能
    • gitlab-runner registerで対話形式で登録
  • executorをdockerにして実行
    • remote上で動かすときも実体はdockerコンテナ上でtestを実行しているだけなので、それをローカルで再現
    • 手元であればdocker psdocker statsで観測が可能

で、結局何がボトルネックになっていそうなのか

  • pipeline一本あたりに実行されるjobが多すぎる(再確認

複数PJが一つのリポジトリに乗っかっていて、一つのPJ修正でも全部のテストが起動している

  • 一度に並列に実行されるjobが少なく、待ちが発生している

jobが2並列しか実行できないのにpiplineの同じステージに5個jobが積まれている、、、

  • docker imageをpullするのに毎回時間がかかっている

前回実行時に取得したimageなどを使えないのか?

  • 依存するライブラリの取得に時間がかかっている

cacheで共有化できないか?

  • CPUの使用率がコンパイルのタイミングで跳ね上がる

最小のインスタンスを使っている現状ではスペック不足なのでは?

ボトルネックをそれぞれ対処してみる

pipelineで実行されるjobの制御

更新されているものが何か、によって実行JOBを制御します。

  • タイミングの制御:MRを立てている場合だけ実行する
  • 実行JOBの制御:該当directoryを変更している場合のみ実行する

各JOBについて、以下のようにonlyを記載することで制御します。

job:
    only:
        changes:
            - batch/**/*
        refs:
            - merge_request

並列実行数を増やす

これはgitlab-runnerのconcurrent設定で実現できます
ただし、このconcurrentを増やすほどexecutorが並列実行される=リソースを食い合うので、設定には注意しましょう

/[config_directory]>/config.toml

concurrent = 5

docker imageを使い回す

利用しようとしているimageがすでにpullされている場合、新たにpullするのをやめるようにします。
これはgitlab-runnerのpull_policy設定で実現できます

※公式documentに記載有り

/[config_directory]>/config.toml

[[runners]]
  [runners.docker]
    pull_policy = "if-not-present"

cacheの仕組みを利用して、一度取得したjarを別のpipelineでも使い回す

cacheを利用して、scalaの依存解決速度を爆速にします
※cacheについての公式ドキュメントはこちら

cacheの保存場所の設定を行わない場合、実行する環境でしかそのcacheを使い回せません。
そのため、gcs上にcacheを保存するbucketを作成して、そこに保存するようにします。runnerの設定にて指定することができます。

/[config_directory]>/config.toml

  [runners.cache]
    Type = "gcs"
    Path = "cache"
    Shared = true
      [runners.cache.gcs]
        CredentialsFile = "<credential fileを配置したpath>"
        BucketName = "<cacheを保存先のbucketのname>"

cacheの保存先の設定ができたら、次にcacheを利用するようjobの設定を行います。
ivyでの依存解決に時間がかかっていることがわかったので、.ivy2配下のcacheをpipelineを跨いで使い回すように設定します。

# 環境変数でsbtの参照先を上書きします
variables:
    JAVA_TOOL_OPTIONS: "-Dsbt.ivy.home=${CI_PROJECT_DIR}/.ivy2/ -Dsbt.global.base=./.sbtboot -Dsbt.boot.directory=./.boot"
    # coursierを利用する場合はこちらも指定してあげましょう
    COURSIER_CACHE: "${CI_PROJECT_DIR}/.cache"

...

job:
    cache:
        # jobの名前単位でkeyを設定することで、pipelineを跨いで同じjobでcacheを再利用させます
        key: app-dependencies-cache-${CI_JOB_NAME}
        paths:
            - ${CI_PROJECT_DIR}/.ivy2/

設定したら何度かjobを実行してみます。

cacheを利用できている場合、開始時にDownloading cache.zip from ~~, jobの終了時にUploading cache.zip to ~~~と表示されるので確認しましょう。

注意事項

cacheが効いてない?と感じる場合、以下のことを疑いましょう

  • cacheするディレクトリは間違っていませんか?
  • 使いまわしたいリソースを上書きしていませんか?
    • keyを元にpathを作成し、そのpathにzipファイルを保存しにいくため、同じkeyを用いてしまうと、cacheの内容が上書きされてしまいます
    • 改善策:jobごとにcache-keyを作成して、jobごとに保存するようにする(上記のkey設定を参考にしてください)

executorのスペックを見直してコンパイル速度を改善する 金で殴るともいう

ローカルでdockerに4コア使わせたところ、300%ほど利用していることがわかったので、スペックの調整をします。
executorが起動するインスタンスの設定を修正します。

docker-machineのオートスケールはDockerMachineの仕組みに依存するとのことなので、DockerMachineのgce向けのドキュメントを参考にインフラ担当に設定してもらいます(ありがとうございました!)

/[config_directory]>/config.toml

[[runners]]
    [runners.machine]
        MachineOptions = [
        ...

job設定に環境のスペックを確認するコマンドを入れるなどして反映されていることを確認しましょう。

まとめとして

これらの結果が前述の成果になります(再掲

pipeline実行回数 合計実行時間(時間[分]) 平均実行時間
9月 164回 114時間[6880分] 0:41:57
11月 106回 15時間[945分] 0:08:55

とにかく改善しないと開発のスピードがバリヤバいという危機感から、一つ一つ調べたり、仮説を立てて実践を繰り返しました。

一番効果があったのは何かと言われると明確ではないのですが、

  • executorのスペックを見直してコンパイル速度を改善する

が一つの鍵になり、そこ以外の地味な部分がすべて活用されるようになった結果劇的にjob時間が短くなった印象です。

成果だけみると月間100時間短縮して素晴らしい改善効果なんですが、一方で「改善をあとに回した結果、数十ないし数百時間を無駄にしてしまっていた」という事実も浮き彫りに。。。

もっと早く問題意識を持って改善すべきだった事案です。反省。

gitlab-ciはまだ不慣れな面も多々あるのですが、今回の件でだいたいお友達になれたような気がします。 来年はDeployフローの改善に着手したいところです。

次回更新は年明け予定です。それでは皆様、良いお年を!

AI社員のつくり方

フロントエンドエンジニアをしているkitoです。
今回は、Slackにいると便利なAI社員ことチャットボットについて書きたいと思います。
事の発端は、メルカリさんの「AI社員」が入社! 翻訳から日々の業務サポートまで大活躍中というブログのエントリーを読んだことです。Dialogflowを知らなかったこともあり、非常に興味深い内容で、弊社にもAI社員がいたら便利なのでは? と漠然と思うようになりました。
そして実際、HISASHIくんにインスパイアされたAI社員をつくってみたので、その概要を書きたいと思います。

Dialogflow

Dialogflowは、Googleが運用開発している自然言語解析プラットフォームです。自然言語処理を丸投げできるので、対話型botをつくる際には本当に有り難いサービスです。これなしで気軽にチャットボットはつくれないでしょう。しかも、Standard Editionの範囲なら無料で利用できます。今回、こちらのサービスを利用します。

さて、社内のヘルプデスク業務などを行うAI社員に必要な機能として最低限下記が必要かなと思います。

  • 気軽に話かけられる
  • メンションつきの質問に答えられる
  • 正しい答えを学習させられる

まず、気軽に話しかけるか否かですが、あまり難しく考えても仕方がないので、HISASHIくん同様いらすとやのイラストをアイコンにして、mizekoという名前にしました。(弊社、アスタミューゼなので。。)

f:id:astamuse:20191212111053p:plain

DialogflowとSlackの連携

メンションつきの質問に答えられるようにするのは、予想外に骨が折れました。
Dialogflowには、Slack、Facebookなどと簡単に連携できるintegrationsという機能があり、当初これを利用すれば簡単にAI社員ができるのではと思っていました。

しかし、実際には、@mizekoのようなメンションつきのテキストに反応するだけではなくて、あらゆるテキストに反応してしまい、チャットの邪魔になってしまいました。通常のチャットボットのユースケースならば発言する人が一人いて、それに随時反応すれば良いのかもしれませんが、会社のSlackで使う場合、様々な人がチャットに参加しているので、メンションつきもしくは特定のテキストにだけ反応して応答するようにしなければなりません。
これを解決するために、integrationsを使うのではなく、すべてAPIを通じて受け答えするように変更しました。
SlackのAPIでパブリックなチャンネルのテキストを取得し、@mizekoのメンションがついていた場合、DialogflowのAPIにテキストを投げ、解析の結果をSlackのAPIを通じて該当チャンネルに投稿するという流れです。

SlackのAPI

まず、SlackのEvent APIでパブリックなチャンネルのメッセージを待ち受けて、メッセージに@mizekoのメンションがあれば、DialogflowのAPIを叩く必要があるので、Request URLを待ち受けるサーバーが必要になります。
開発中や手元で試してみるなら、ngrokを使うのが簡単でしょう。本格的に試すならfirebaseが第一選択として考えられます。ただし、外部APIを呼ぶ場合は、有料プランのFlameかプランBlazeプランにする必要がある点注意です。Blazeプランの無料枠があるので、使い方によっては良い選択肢だと思います。私は、重量課金が少し気になったので他で用意しました。

さて、肝心のSlackのEvent APIですが、まずこちらでCreate New APPから新しいAPPを作成します。
OAuth & PermissionsにあるOAuth Access Tokenを控えておきましょう。 f:id:astamuse:20191212111130p:plain 次に、Event Subscriptionsの「Subscribe to workspace events」でSubscribeするEventからmessage.channelsを選びます。
f:id:astamuse:20191212111154p:plain 同じページの一番上にあるEnable EventのスイッチをONにして、Event Subscriptionsから、Request URLを指定します。
私の場合、Express.jsで下記のようなコードでRequest URLをSubscribeしました。

const express = require("express");
const app = express();
const bodyParser = require("body-parser");
app.use(bodyParser.json());

app.post("/api/channels.history", function(req, res) {
    res.setHeader("Content-Type", "text/plain");
    res.send(req.body.challenge);

    //ここでDialogflowの処理

});

app.listen(process.env.PORT || 8080);

channels.historyのレスポンスは、公式のSlack APIを参考にしてください。

DialogflowのAPI

Dialogflowは、先にも書いたように自然言語解析プラットフォームでIntentというユーザーのリクエスト単位で管理されています。「今日の東京の天気教えて?」というユーザリクエストに、「東京は今日晴れです」と答えるのがひとつのIntentになります。
今回のAI社員には、新規Intentを覚えさせ、またIntentをUpdateして再学習させなければなりません。
要は、Slackのメッセージに応じて、DialogflowのAPIでIntentをCreateしたりUpdateしたりすることになるのですが、例えばAI社員が間違った回答をしたとき何を基準にIntentをUpdateすれば良いのでしょうか?

メルカリさんのHISASHIくん場合、ブログのスクリーンショットから察すると、ある特定のチャンネルでの質問に対して、HISASHIくんの回答にスレッド形式で:hisashi:という絵文字をつけると正しい答えとしてみなして、後で?Updateしているようでした。

このスレッド形式での正しい答えを絵文字つきで教える方式は、クレバーなやり方だと思ったので、弊社のmizekoでも採用しました。
ただ、特定のチャンネルではなくすべてのチャンネルで、かつリアルタイムで学習して欲しかったので、APIでIntentをCreateもしくはUpdateするようにしました。SlackのAPIの設定で、Event Subscriptionsを「Subscribe to workspace events」で設定したのは、すべてのパブリックなチャンネルで、AI社員mizekoに質問でき学習させるためです。

DialogflowのAPIに関しては、公式のリファレンスが参考になります。

公式のサンプルをそのまま使えるので、最初の設定さえ間違えなければそれほど迷わないと思います。 ポイントとしては、detectTextIntentでintentの検出を行い、帰って来た結果をSlackで返信するところです。

function detectTextIntent(projectId, sessionId, queries, languageCode) {
  const dialogflow = require('dialogflow');
  const sessionClient = new dialogflow.SessionsClient();

  async function detectIntent(
    projectId,
    sessionId,
    query,
    contexts,
    languageCode
  ) {
    //省略  
    const responses = await sessionClient.detectIntent(request);
    return responses[0];
  }

  async function executeQueries(projectId, sessionId, queries, languageCode) {
       //省略

       //slackのチャンネルに返信
       await slack.chat.postMessage({
                    token: botToken,
                    channel: _channel,
                    text: intentResponse.queryResult.fulfillmentText
                })

  }
  executeQueries(projectId, sessionId, queries, languageCode);

}

また、Slackのメッセージがスレッドであるかは、channels.historyレスポンスのメッセージにthread_tsが含まれているかどうかで判断できます。 スレッド上でのメッセージの場合は、:mizeko:という絵文字があれば、それは正しい答えを教えようとしているのだと判断できます。 どのthreadが該当のthreadであるかは、conversations.repliesから導きだせます。

f:id:astamuse:20191212111217p:plain

Threadで正解を教え、もう一度同じ質問をすると正しく答えてくれます。(IntentのUpdateで上書きしているので、後から教えた方を覚えます) f:id:astamuse:20191212124201p:plain

まとめ

メルカリのHISASHIくんにインスパイアされたAI社員をつくり、APIを通じて簡単な学習機能をつけました。社内で広く使われていくためには、 Dialogflowコンソールからの手入力による柔軟な運用が必要になるのではないかという気もしますが、自動化できる部分は自動化して勝手に育ってくれると嬉しいですね。
これからのオフィスでは、生産性を上げるためにBotがさらに活用されていくことでしょう。Botとうまく付きあって生産性を上げていきたいですよね? f:id:astamuse:20191212120422p:plain

アスタミューゼでは、エンジニア・デザイナーを募集中です。ご興味のある方は遠慮なく採用サイトからご応募ください。お待ちしています。

Copyright © astamuse company, ltd. all rights reserved.