#目次
1.概要
2.環境・採用技術
3.開発の流れ
4.コード解説
5.質問の意味を理解できるか?
6.IBM Cloud CodeEngineにデプロイしてみた。
7.最後に
#1. 概要
AIチャットボットのWebアプリケーションを作り、クラウド上にデプロイします。
シナリオベースのチャットボットはいくつか試しましたが、シナリオを用意するのに手間がかかったり、用意したシナリオ通りの質問しか回答できない等の不便さを感じたため、それらを解消するチャットボットを自分で開発してみます。
今回実現したいのは、以下2点です。
- すぐに使い始められる。
- シナリオを用意したりQAデータを整形したりせずに、普段の問い合わせ対応業務で使っているエクセルのQA表を食わせるだけでサービスを開始できるようにします。
- 質問の「表現揺れ」に対応する。
- 「類義語検索」と「自然言語処理」を用いて、用意したQAに厳密に合致しない質問をされても回答できるようにします。
####AIチャットボットとは
Q:東京タワーの住所を教えてください。→A:東京都港区芝公園4丁目2−8です。
というQAからチャットボットを作ったとして、
用意したシナリオ通りに「東京タワーの住所を教えてください。」と質問しないと回答できないのが人工無能。
「東京タワーってどこ?」と質問しても、「東京タワーの場所が知りたい」という意味を理解して回答できるのが人工知能(AI)です。
今回は、回答のロジックにWord2Vecという自然言語処理の技術を用いることで、
質問の意味を理解して回答を返すチャットボットを作っていきます。
####Word2Vecとは
大量の文章をニュートラルネットワークを介して学習させることで、その文章中で扱われている言葉の「意味」をベクトル空間上に表現する技術。
意味を数値化できるので、計算により以下のようなことができる。
- 単語同士の足し算、引き算(「国王」ー「男性」+「女性」=「女王」)
- 単語や文章同士の「意味の近さ」を数値的に測る。
今回は、白ヤギコーポレーションさんの「Wikipedia日本語版学習済みモデル」を使用させて頂きました。
https://aial.shiroyagi.co.jp/2017/02/japanese-word2vec-model-builder/
#2. 環境 • 採用技術
- 開発端末:Macbook Air(M1チップ)
- 言語:HTML + Javascript + Python:3.9.6
- Webフレームワーク:Flask:2.0.1
- ライブラリ
- 機械学習:gensim:3.8.3
- 形態素解析:janome:0.4.1
- エクセル操作:openpyxl:3.0.7
- チャットボット風UI:BotUI
- 類義語検索:WordNet
- デプロイ環境:IBM Cloud CodeEngine
#3. 開発の流れ
①画面を作る
・HTMLを用意
・BotUIでチャットボット風の対話UIを作成
②サーバ側処理を書いて画面と繋ぐ
・Flaskで画面表示
・Ajaxで質問をサーバ側にPOSTする。(JSON形式)
・回答を画面に返す。
③QA表から回答を生成する
・質問を形態素解析し、単語に分解する。(分かち書き)
・類義語を検索する。
・質問から分解した単語+その類義語でQA表を検索
・回答候補が複数ある場合、質問と候補の「意味の類似度」が高いものを回答する。
④Adminページを作り、設定中のQA表を確認・更新できるようにする
・Adminページの表示
・QA表のアップロード
・QA表の分かち書き
・日本語wikiモデルに追加学習
#4. コード解説
それでは、上記の流れに沿ってコード解説していきます。
##①画面を作る
BotUIは、簡単にチャットボット風の対話UIを作るjavascriptのフレームワークです。
こんなHTMLを用意して、
<div class="botui-app-container" id="chatbot">
<bot-ui></bot-ui>
</div>
<script src="https://cdn.jsdelivr.net/vue/latest/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/botui/build/botui.js"></script>
こんな感じでpromiseを繋いでボット側とユーザー側の会話フローを作っていきます。
$(function(){
var botui = new BotUI('chatbot');
// 初期メッセージ
botui.message.add({
content: 'こんにちは。chatbotです。'
}).then(
botui.message.add({
delay:1000,
content: '質問を入力してください。'
}).then(function() {
return botui.action.text({
delay:1000,
action: {
placeholder: '質問を入力...'
}
}).then(function(res) {
//質問に対する回答を生成
})
})
);
});
【参考記事】
JavaScriptだけで本格的なチャットボットを開発できるライブラリ「BotUI」を使ってみた!
##②サーバ側処理を書いて画面と繋ぐ
Flaskは、Webアプリを簡単に作れるPythonフレームワークです。
今後色々追加しますが、最小構成のファイル階層はこちらです。
app.pyにはルーティング等のサーバ側処理を書きます。
コンテキストルートにアクセスしたらtemplatesフォルダ配下のchat.htmlを表示するよう設定します。
from flask import Flask, render_template
app = Flask(__name__)
# 初期表示
@app.route('/')
def page_load():
return render_template('chat.html')
if __name__ == "__main__":
app.run(debug=True)
準備ができたら、以下を実行します。
% python app.py
* Serving Flask app 'app' (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
実行後、http://127.0.0.1:5000/ にアクセスすると、chat.htmlが表示されます。
次に、画面から入力した質問をAjaxでPOSTし、サーバ側で処理、回答できるようにします。
$(function(){
~~中略~~
}).then(function(res) {
//質問に対する回答を生成
var answer;
$.ajax("/question", {
type: "post",
data: {"question":res.value}, // 質問
dataType: "json",
}).done(function(data) { // 通信成功
console.log("Ajax通信 成功");
information = JSON.parse(data.values).information
hit_question = JSON.parse(data.values).hit_question
hit_answer = JSON.parse(data.values).hit_answer
botui.message.add({
delay:1000,
type: "html",
content: `${information}<br>
質問:<br>
<b>${hit_question}</b><br>
回答:<br>
<b>${hit_answer}</b>
`
})
}).fail(function(data) {
console.log("Ajax通信 失敗");// 通信失敗
return botui.message.add({
delay:1000,
type: "html",
content: `<b>すみません。通信に失敗しました。</b>`
})
}).always(function(){
askEnd()//質問を終了するかどうか確認
})
})
~~中略~~
from flask import Flask, render_template, jsonify, request
# 質問を受け取って回答を返す
@app.route('/question', methods=['POST'])
def answer():
question = request.form['question']
return_json = {
"information":"最も関連度の高い回答はこちらです。",
"hit_question": question,
"hit_answer": "Answer"
return jsonify(values=json.dumps(return_json))
これで、サーバ側との通信ができるようになりました。
【参考記事】
FlaskとAjaxを使って非同期にサイトの表示を変える
##③QA表から回答を生成する
以下の流れで、質問の「意味」に対し関連度の高いQAのセットを返します。
(=質問の意味を理解して回答する)
1.質問を形態素解析し、単語に分解する。(分かち書き)
2.類義語を検索する。
3.質問から分解した単語+類義語でQA表を検索
4.回答候補が複数ある場合、質問と候補の「意味の類似度」が高いものを回答する。
1.質問を形態素解析し、単語に分解する。(分かち書き)
形態素解析とは、文章を品詞ごとに分解して解析する技術です。
「月が綺麗ですね。」を形態素解析すると以下のようになります。
月 名詞,一般,,,,,月,ツキ,ツキ
が 助詞,格助詞,一般,,,,が,ガ,ガ
綺麗 名詞,形容動詞語幹,,,,,綺麗,キレイ,キレイ
です 助動詞,,,,特殊・デス,基本形,です,デス,デス
ね 助詞,終助詞,,,,,ね,ネ,ネ
。 記号,句点,,,,,。,。,。
これを利用して、質問に含まれる「名詞」と「形容詞」だけ抽出することで、質問が用意したQAに厳密に一致していなくても回答がヒットするようにします。
from janome.analyzer import Analyzer# 形態素解析ライブラリ 「pip install janome」
from janome.charfilter import *
from janome.tokenfilter import *
# 形態素解析の設定
token_filters = [CompoundNounFilter(),# 連続する名詞の複合名詞化
POSKeepFilter(['名詞','形容詞']), # 抽出する品詞の指定
UpperCaseFilter()] # アルファベットを大文字に変換
a = Analyzer(token_filters=token_filters)
# 分かち書き
def separate_word(question):
word_list = []
for token in a.analyze(question):
print(str(token))
word_list.append(str(token).split()[0])
return word_list
2.類義語を検索する。
分解した単語だけではなく、その類義語も含めてQA表を検索することで、回答を返せる質問の表現の範囲が広くなります。
類義語検索には日本語WordNetを使います。
まず単語の「上位概念」を検索し、その概念に属する単語を検索することで、類義語を抽出します。
例えば、「太陽」で検索すると、こんな言葉が出てきます。
「太陽」の上位概念:太陽系の惑星の熱光源である星
「太陽系の惑星の熱光源である星」に属する単語:
1 : sun
2 : お日さま
3 : 火輪
4 : ソレイユ
5 : 日天子
6 : お天道様
7 : 天道
8 : 日輪
公式サイトからDB形式のWordNetをDLし、sqliteで使っていきます。
import sqlite3
conn = sqlite3.connect("./wordnet/wnjpn.db",check_same_thread=False)
c = conn.cursor()
def get_synonyms(word):
synsets = []
word_id = 99999999
# 単語IDを取得
wordid_rows = c.execute("select wordid from word where lemma = '%s'" % word)
for wordid_row in wordid_rows:
word_id = wordid_row[0]
if word_id == 99999999:return synsets
# 単語IDから「概念」を取得
synset_rows = c.execute("select synset from sense where wordid = '%s'" % word_id)
for synset_row in synset_rows:
synsets.append(synset_row[0])
synonym_list = []
for synset in synsets:
# 「概念」に属する単語IDを取得
synonym_ids = conn.execute("select wordid from sense where (synset='%s' and wordid!=%s)" % (synset,word_id))
for synonym_id in synonym_ids:
# 単語IDから単語を取得
synonym = conn.execute("select lemma from word where wordid=%s" % synonym_id[0])
for row in synonym:
synonym_list.append(row[0])
return synonym_list
【参考記事】日本語WordNetを使って、類義語を検索できるツールをpythonで作ってみた
3.単語化した質問+類義語でQA表を検索
まずは、QA表を検索する部分です。
pythonでExcelを扱うopenpyxlを使います。
import openpyxl
# 問い合わせ台帳の読み込み
excel_path = "./data/QA.xlsx"
wb = openpyxl.load_workbook(excel_path)
sheet = wb.worksheets[0]
q_col = "B"# 質問の列(ABC...)
a_col = "C"# 回答の列(ABC...)
# エクセルを単語リストで検索し、行番号とヒット数を返す
def search_question(word_list):
row_points = []
for i in range(200):#検索対象の行数(いったん200)
point = 0
q_cell = sheet[q_col + str(i+1)]
a_cell = sheet[a_col + str(i+1)]
if q_cell.value is not None and a_cell.value is not None:
for keyword in word_list:
if keyword.casefold() in q_cell.value.casefold():# 大文字小文字区別しない
point += 1
if point > 0: row_points.append([i+1,point])
return row_points
これらを組み合わせて、質問から抽出した単語とその類義語でQA表を検索し、ヒット率の高いものを回答します。
import wordnet
@app.route('/question', methods=['POST'])
def answer():
question = request.form['question']
# 質問を形態素解析して単語リストに変換
word_list = separate_word(question)
# 類義語を追加
synonym_word_list = []
for word in word_list:
synonym_word_list += wordnet.get_synonyms(word)
word_list = word_list + synonym_word_list
print('■debug:質問の名詞+類義語')
# 問い合わせ台帳を検索
row_points = search_question(word_list)
# 関連度の高い質問と回答のセットを返却
top_points = []
top_row = [0,0]# [行番号,類似度]
if len(row_points) > 0 :
row_points.sort(key=lambda x: x[1],reverse=True)# ヒット数でソート(降順)
return_json = {
"information":"最も関連度の高い回答はこちらです。",
"hit_question": sheet[q_col + str(row_points[0][0])].value,
"hit_answer": sheet[a_col + str(row_points[0][0])].value
}
else:# 1件もヒットしない場合はsorry回答
return_json = {
"information":"すみません。「" + question + "」に関連する回答はありません。",
"hit_question": "",
"hit_answer": ""
}
return jsonify(values=json.dumps(return_json))
4.回答候補が複数ある場合、質問と候補の「意味の類似度」が高いものを回答する。
冒頭で紹介した、Word2Vecという言葉の意味をベクトル化(数値化)する仕組みを使って、質問と回答候補の「意味の類似度」を評価します。
Word2Vecを使うには、gensimという自然言語処理のためのpythonライブラリを使う必要がありますが、M1チップのMacbookでは一手間入れないとインストールできなかったので、その経緯をメモしておきます。
①普通にpip install gensim
→エラー発生:numpy,scipyが必要のためインストールできない
②pip install numpy
→エラー発生:ARMアーキテクチャ(M1チップ)ではインストールできない(scipyも同様)
③miniforgeでconda環境を作って、conda install numpy
→インストール成功(scipyも同様)
※miniforgeとは、、、
Anaconda:データサイエンス向けのPythonライブラリパッケージ
Miniconda:Anacondaの最小構成版
Miniforge:MinicondaのArmアーキテクチャ対応版
【参考記事】M1 MacにPythonインストールして開発環境構築してみた
④pip install gensim
→インストール成功
※こちらの記事より、gensimはcondaよりpipでインストールした方が高速らしいのでそのようにしました。
gensimがインストールできたら、以下のコードを書いていきます。
単語のベクトルを抽出
from gensim.models import word2vec
# 日本語wikipedia学習済みモデルを読み込み
wiki_model = word2vec.Word2Vec.load('./model/latest-ja-word2vec-gensim-model/word2vec.gensim.model')
# 文章のベクトル平均を計算
def get_vector(text):
sum_vec = np.zeros(50)#読み込んだモデルの次元数に合わせる
word_count = 0
for token in a.analyze(text):
try:
sum_vec += wiki_model[str(token).split()[0]]
word_count += 1
except KeyError:#モデルに単語が存在しない
print('■debug:KeyError発生 ' + str(token).split()[0])
return sum_vec / word_count
文章同士の意味の類似度を比較
(類似度=文章に含まれる単語のベクトル平均値の、コサイン類似度)
import numpy as np
# cos類似度を計算
def cos_sim(v1, v2):
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
この2つを使って、回答候補が複数ある場合の処理を書いていきます。
# 質問を受け取って回答を返す
@app.route('/question', methods=['POST'])
~~中略~~
# 最高点が複数存在する場合は、入力された質問とベクトル類似度が高いQAを返す
if len(top_points) > 1:
m_vec = get_vector(question)
for row in top_points:
q_vec = get_vector(sheet[q_col + str(row[0])].value)
if top_row[1] < cos_sim(m_vec, q_vec):
top_row[0] = row[0]
top_row[1] = cos_sim(m_vec, q_vec)
~~中略~~
【参考記事】【Python】Word2Vecの使い方
##④Adminページを作り、設定中のQA表を確認・更新できるようにする
アプリの趣旨からすると少し蛇足なので解説は一旦割愛しますが、以下の機能を実装しています。
・Adminページの表示
・QA表のアップロード
・QA表の分かち書き
・日本語wikiモデルに追加学習
以上が、コード解説になります。
ソース完全版は、こちらです。
#5. 質問の意味を理解できるか?
今回、アプリに使う「問い合わせ台帳」のデータとして、ソフトバンクのFAQから質問と回答のセットを抽出し、エクセルに100個ほどまとめました。
ここで、「iPhoneがすぐ電池切れになるのを改善したい」という意味の質問をした場合、
QA表に用意されている以下のQAを回答したいです。
Q:[iPhone]電池の減りが早いです。改善方法はありますか?
A:マルチタスクで起動しているアプリケーションの終了など、電池の減りが早くなる原因の確認と対策で電池の減りを抑えることができます。
ここで、回答ロジックに仕込んだ「形態素解析」「類義語検索」「意味の類似度比較」で、「意味を理解して回答」が実現できているのか確認してみます。
質問:「iPhoneのバッテリーがすぐに無くなる。」
1.形態素解析(名詞と形容詞のみ抽出)
IPHONE 名詞,固有名詞,組織,,,,IPHONE,,*
バッテリー 名詞,一般,,,,,バッテリー,バッテリー,バッテリー
2.類義語を検索する。
IPHONE:類義語なし
バッテリー:battery,electric_battery,乾電池,蓄電池,電池
3.単語化した質問+類義語でQA表を検索
ヒットした質問1:[iPhone]画面右上の電池残量の横に矢印マークが出ますが、何のマークですか?
ヒットした質問2:[iPhone]電池の減りが早いです。改善方法はありますか?
4.回答候補が複数ある場合、質問と候補の「意味の類似度」が高いものを回答する。
ヒットした質問1と入力した質問の類似度:0.6174943081577355
ヒットした質問2と入力した質問の類似度:0.7242112553944476
→最も意味の類似度が高い「ヒットした質問2」と、その回答のセットを表示
#6. IBM Cloud CodeEngineにデプロイしてみた。
せっかくなので、作ったアプリをクラウド上にデプロイしてみようと思います。
私はITインフラの知識を持っていないので、「ソースコードだけでデプロイできる」というIBM Cloud CodeEngineを利用してみます。
基本、こちらの記事を参考にすれば問題ないと思いますが、
私は記事にある「コンテナイメージからデプロイ」ではなく「ソースコードからデプロイ」で実施しましたので、以下にポイントのみ記載しておきます。
まずは、こちらから無料アカウントを作成します。
CodeEngineは無料枠がありますが、「従量課金アカウント」でないと使えないので、
管理→アカウント→アカウント設定からクレジットカードを登録し、アカウントをアップグレードします。
※登録直後はアカウントが「保留中」となります。数日間変わらない場合は、サポート→Case作成で対応を依頼しましょう。
※アップグレードされてから35日間、$200の無料クレジットが付きます。
次に、今回はGitリポジトリに含めたDockerfileからコンテナイメージを作成しますので、
アプリの実行環境を作るためのDockerfileを作成します。
ローカル実行環境を作った時と同じものをインストールするよう記述し、コンテキストルートに配置します。
FROM condaforge/miniforge3
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
WORKDIR /chatbot
COPY ./ /chatbot
RUN apt-get update
RUN apt-get -y install gcc
RUN apt-get -y install g++
RUN conda install numpy
RUN pip install scipy
RUN pip install gensim==3.8.3
RUN pip install janome
RUN pip install Flask
RUN pip install Werkzeug
RUN pip install openpyxl
CMD ["python", "run.py"]
従量課金アカウントへのアップグレードが完了したら、以下の流れでデプロイを進めていきます。
1.CodeEngineのページで「ソースコードから始める」を選択
ソースコードには、コードを登録したGithubのリポジトリURLを指定します。
2.ロケーションとプロジェクト名を入力してプロジェクトを作成
これで、Dockerfileを元にコンテナイメージが作成されます。
4.アプリケーションの作成
先ほど作ったイメージを指定
デプロイが完了すると、以下の状態になります。
右上の「アプリケーションURLを開く」をクリックすると、デプロイされたアプリにブラウザでアクセスすることができます。
上記のように、ITインフラ知識の無いアプリ開発者でも、簡単な設定でアプリをクラウド上にデプロイすることができました。
CodeEngineのメリットはデプロイの簡単さだけでなく、以下のようなインフラ専門家を呼ばないとできないようなインフラ設定を勝手にやってくれます。
・自動スケールアップ、スケールダウン
・ロードバランシング
・SSL通信設定
特筆すべきは「未使用状態だと"サーバ0台"までスケールダウンし、無課金状態になる」ということです。
今回開発したアプリのように、機械学習や自然言語処理のようなマシンスペックを必要とするアプリほどコストメリットがありそうです。
#7. 最後に
Word2Vecの「言葉の意味を数値化できる」というところに興味を持ち、今回のアプリ開発をはじめました。
実際に使ってみると、想像を超える答え、思っても無いような答えを返してくる時があり、何かの生命体を作っているようなワクワク感と楽しさがありました。
まだまだAI開発としては最初の一歩ですが、学習させるデータにより色々なことができそうです。
- 特定ジャンルのTweetを学習させてマーケティング分析
- ヒット曲の歌詞やコードを学習させて作詞作曲
- ユーザーごとのコンバージョン情報を学習させて、サジェストの精度向上や好みにあった商品を提案するチャットボット
今後もいろいろ試してみようと思います。