クラウド同上

AutoML Natural Language を使って文豪っぽさを推定する

Author
miyagi
Lv:6 Exp:11232

Java開発者、社内システム担当者、セキュリティ担当者などをやっていましたが、思い立って統計学、機械学習を学び、現在はデータエンジニアをやっています。

Googleが提供する AutoML のプロダクトを使えば、機械学習に関する専門的な知識が無くても、ユーザが独自のデータを用意してトレーニングさせる(トレーニングは簡単に実行できます!) ことで強力な機械学習モデルを構築できます。

本記事では、その中でもテキストデータを分類することができる AutoML Natural Language Classification(AutoML NL) を使い、夏目漱石、太宰治、宮沢賢治の作品をトレーニングデータとして、ある文章がこの3文豪のどの作風に近いか、を推定してみたいと思います(ちなみに AutoML Vision についてはこちらの記事で紹介しています!)。また、AutoML の実行は簡単なのですが、利用者側でデータを用意する必要があるので、今回は Python を使ったデータの前処理についてもご紹介します。

2019年4月の Google Cloud Next’19 でも、去年に引き続き Google Cloud Platform(GCP) の Machine Learning(ML) の多くのプロダクトが発表されました。数が多くて紹介しきれませんが、既存の AutoML Vision ClassificationAutoML Natural Language Classification (AutoML NL)、AutoML Translation のベータ版に加え、AutoML 関連だけでも5つの発表があり、公式ドキュメントを確認すると9つもプロダクトが確認できます。これを見ても分かる通り、Googleは AutoML に対して非常に力を入れていることが分かります。

1. AutoML NL ではテキストの分類が可能

GCP ではすでに Cloud Natural Language API という自然言語処理の機械学習 API が提供されていますが、こちらはテキストデータを分析することが目的となっています。この API を使用することによって、構文解析や、人、組織、場所などのエンティティ認識、および感情分析などが行えます。それに対し、AutoML NL ではテキストデータを分類することができます。テキストデータ分類の例としては、スパムメールの分類(あるメールがスパムなのかそうでないのか) などがあります。

また、AutoML NL でサポートされている言語は英語のみとなっていますが(2019年5月18日現在)、実は日本語でも学習が可能です。本記事で確認しますが、その学習済みモデルを使用した日本語の推定も問題はないようです。

AutoML NL でテキストの分類を行うまでの大まかな手順としては、以下のような流れとなります。2) と 3) の手順は AutoML NL で実現することはできないため、今回は Python にて実装しています。

  1. GCP の下準備(GCP プロジェクトごとに初回のみ)
  2. トレーニングデータの前処理
  3. トレーニングデータの GCS へのアップロード
  4. AutoML NL でトレーニングを実行
  5. AutoML NL で分類(推定)を実行

2. AutoML NL を使うための GCP での下準備

それでは早速 AutoML NL を使う手順の説明に入ります。AutoML NL を使う前に実行すべき手順は、こちらの公式ドキュメントではコマンドを使った方法の説明がされていますが、以下では GCPコンソールからの手順を説明します。

2.1. Google Cloud Storage(GCS) バケットを作成

AutoML NL を有効化する前に、AutoML NLで使用する GCS バケットを作成する必要があるので、GCP コンソール左上のハンバーガーアイコンをクリックし、[Storage] をクリックします。

alt_text

そして、[バケットを作成] をクリックし、

alt_text

ここが重要ですが、AutoML NL の仕様で、バケット名はプロジェクト名の後に “-lcm” を付けたものを指定する必要があります。

alt_text

後はストレージクラスや場所(ゾーン) を設定し、[作成] ボタンをクリックします。

2.2. AutoML NL を有効化

再度、GCP コンソールのハンバーガーアイコンをクリックします。そして人工知能カテゴリの [Natural Language] をクリックします。

alt_text

その先の画面で [API を有効にする] をクリックします。

alt_text

しばらくすると、以下の画面が表示されるので、[AutoML テキスト分類] の [アプリを起動] をクリックします。

alt_text

そのあとの画面で AutoML からアクセスをリクエストされたら [許可] をクリックします。

そして以下の画面が表示されたら [SET UP NOW] をクリックします。

alt_text

その後、有効化の処理が進むので完了するまで待つと、以下の画面が表示されます。利用する Project ID を入力して [CONTINUE] をクリックし、ここでの作業は一旦終了とします。

alt_text

3. トレーニングデータをPythonで前処理する

今回は青空文庫からテキストデータをダウンロードして使用しますが、ダウンロードしたままの形式ではトレーニングに使えないため、前処理が必要になります。前処理には、無料で Python の Notebook が実行できる Colaboratory を使用しました。

Python プログラム(バージョンは3です) の詳細な説明は割愛しますが、おおまかな処理の流れは以下のようになります。

  1. モジュールのインストール
  2. 青空文庫の zipファイルをダウンロード
  3. ダウンロードした zipファイルを解凍
  4. 解凍したテキストデータの前処理の実行
  5. データリストファイルの作成
  6. テキストデータファイルとデータリストファイルを GCS へアップロード

今回の前処理では、文章の段落での分割や、各作品のヘッダーやフッター、ルビなどの不要なテキストの削除、そして sjis から utf8 への変換などを行います。今回は改行までの1つの段落を1つのテキストデータ(トレーニングデータ)としています。

また、データリストファイルは AutoML NL で必須となる csvファイルで、1列めにテキストデータ、またはテキストデータファイルの GCS 上の url、2列めにそのラベル(今回は作者名)を指定します。

まず、ダウンロード処理で必要となるモジュールをインストールします。

# 必要なモジュールをインストール
!pip install lxml cssselect

つづいて、使用するモジュールの import、及び変数、関数を定義します。

ここで、”GS_DIR = ‘gs://<プロジェクト名>-lcm/aozora_dataset/’” の <プロジェクト名> は使用するプロジェクト名を設定してください。

import os
import re
import codecs
import requests
import urllib
from urllib.request import urlopen
import lxml.html
import zipfile

import numpy as np
import pandas as pd

from google.colab import auth

ROOT_DIR = '.' + os.path.sep + 'aozora' + os.path.sep
ZIP_DIR = ROOT_DIR + 'zip' + os.path.sep
TEXT_SJIS_DIR = ROOT_DIR + 'text_sjis' + os.path.sep
TEXT_UTF8_DIR = ROOT_DIR + 'text_utf8' + os.path.sep
GS_DIR = 'gs://<プロジェクト名>-lcm/aozora_dataset/'
DATA_LIST_FILENAME = 'data_list.csv'

base_url = 'http://www.aozora.gr.jp/'

author_dict = {
    'author' : ['natsumesoseki', 'dazaiosamu', 'miyazawakenji'], 
    'url' : ['http://www.aozora.gr.jp/index_pages/person148.html#sakuhin_list_1',
             'https://www.aozora.gr.jp/index_pages/person35.html#sakuhin_list_1',
             'https://www.aozora.gr.jp/index_pages/person81.html#sakuhin_list_1'
            ],
    'download_num' : [12, 32, 32],
    'exclusion_list' : [
        ['永日小品'],
        [],
        ['〔青びかる天弧のはてに〕', '青柳教諭を送る', '〔あくたうかべる朝の水〕']
    ]
}
author_df = pd.DataFrame(author_dict)


def download_zip(author_df):
    for index, row in author_df.iterrows():
        zip_dict = {}

        url = row['url']
        resp_root = requests.get(url)
        html_root = lxml.html.fromstring(resp_root.content)
        html_root.make_links_absolute(resp_root.url)

        print('author : {}'.format(row['author']))
        print('url : {}'.format(url))

        download_count = 0
        for a in html_root.cssselect('a'):
            if -1 < row['download_num'] and row['download_num'] <= download_count:
                break

            link = a.get('href')
            if link is not None and 'cards' in link:
                resp_card = requests.get(link)
                html_card = lxml.html.fromstring(resp_card.content)
                html_card.make_links_absolute(resp_card.url)
                link_zip_list = html_card.xpath('//a[contains(@href, ".zip")]')
                if 0 < len(link_zip_list):
                    link_zip = link_zip_list[0]
                    zip_filename = link_zip.text.split('.')[-2]
                    zip_link = link_zip.get('href')
                    print('[{}],{},{}'. format(a.text, zip_filename, zip_link))

                    # 使用しないファイルをスキップ
                    if a.text in row['exclusion_list']:
                        print('    {} is excluded'.format(a.text))
                        continue

                    zip_dict[zip_filename] = zip_link
                    download_count += 1

        download_path = ZIP_DIR + row['author'] + os.path.sep
        if not os.path.exists(download_path):
            os.mkdir(download_path)
        for i, key in enumerate(zip_dict):
            print('download[{}/{}]...    {} : {}'.format(i+1, len(zip_dict), key, zip_dict[key]))
            urllib.request.urlretrieve(zip_dict[key], download_path + os.path.basename(zip_dict[key]))


def extract_zip(author_df):
    for index, row in author_df.iterrows():
        author_zip_dir = ZIP_DIR + row['author'] + os.path.sep
        author_text_sjis_dir = TEXT_SJIS_DIR + row['author'] + os.path.sep
        if not os.path.exists(author_text_sjis_dir):
            os.mkdir(author_text_sjis_dir)

        files = os.listdir(author_zip_dir)
        for file in files:
            root, ext = os.path.splitext(file)
            if ext == '.zip':
                with zipfile.ZipFile(author_zip_dir + file, 'r') as zf:
                    zf.extractall(path=author_text_sjis_dir)
                    print('extract {} to {}'.format(author_zip_dir + file, author_text_sjis_dir))


def prepare_data(author_df):
    re_ruby = re.compile('\《.+?\》')
    re_note = re.compile('\[#.+?\]')
    for index, row in author_df.iterrows():
        author_text_sjis_dir = TEXT_SJIS_DIR + row['author'] + os.path.sep

        files = os.listdir(author_text_sjis_dir)
        for file in files:
            root, ext = os.path.splitext(file)
            if ext == '.txt':

                sjis_filename = author_text_sjis_dir + file
                fsjis = codecs.open(sjis_filename, 'r', 'shift_jis')
                blank_line_count = 0
                paragraph_index = 0
                is_header = False
                for i, line in enumerate(fsjis):
                    line = line.strip()

                    # 空行はスキップ
                    if len(line) == 0:
                        blank_line_count += 1
                        continue

                    # ヘッダはスキップ
                    if line.startswith('--------------------'):
                        is_header = not is_header
                        continue
                    if is_header:
                        continue

                    # フッタはスキップ
                    if 3 <= blank_line_count:
                        if line.startswith('底本:'):
                            break
                    blank_line_count = 0

                    # ルビ、注釈は削除
                    edited_line = re_note.sub('', re_ruby.sub('', line.replace('\r', '')))
                    if len(edited_line) == 0:
                        continue

                    # 段落ごとに utf8 のテキストファイルを作成
                    paragraph_index += 1
                    utf8_filename = '{}{}_{}_{}.txt'.format(TEXT_UTF8_DIR, row['author'], root, str(paragraph_index).zfill(5))
                    futf8 = codecs.open(utf8_filename, 'w', 'utf-8')
                    futf8.write(''.join(edited_line))
                    futf8.close()

                print('{} : {}'.format(sjis_filename, paragraph_index))
                fsjis.close()


def create_data_list(filename):
    file_list = []
    label_list = []

    text_files = os.listdir(TEXT_UTF8_DIR)
    for file in text_files:
        root, ext = os.path.splitext(file)
        file_list.append(GS_DIR + 'text/' + file)
        label_list.append(root.split('_')[0])

    data_dict = {'file_url': file_list, 'label': label_list}
    data_df = pd.DataFrame(data_dict)
    print('data_df : {}'.format(data_df))
    data_df.to_csv(ROOT_DIR + filename, index=False, header=False)

作成したプログラムで前処理を実行します。

# 必要なディレクトリを作成
os.makedirs(ZIP_DIR)
os.makedirs(TEXT_SJIS_DIR)
os.makedirs(TEXT_UTF8_DIR)

print()
print('# 青空文庫からテキストデータの zip ファイルをダウンロード ####################')
print()
download_zip(author_df)

print()
print('# ダウンロードした zip ファイルを解凍 ##################################')
print()
extract_zip(author_df)

print()
print('# 解凍したテキストデータを前処理 ####################################')
print()
prepare_data(author_df)

print()
print('# 学習で使用するデータリストファイルを作成 #############################')
print()
create_data_list(DATA_LIST_FILENAME)

作成したファイルを GCS へアップロードするため、認証を行います。

# auth.authenticate_user() で GCP への認証を行う
auth.authenticate_user()

auth.authenticate_user() を実行すると、認証画面へのリンクが表示されるので、そのリンク先にてアクセス権のあるユーザで認証を行ってください。その後、表示される認証用コードを入力欄に貼り付けて Enter キーを押すと認証完了です。

最後に、以下のように gsutil コマンドでファイルをアップロードします。このとき、<プロジェクト名> の箇所は使用するプロジェクト名に書き換えてください。

# テキストデータファイルを GCS へアップロード
!gsutil -m cp aozora/text_utf8/* gs://<プロジェクト名>-lcm/aozora_dataset/text/

# データリストファイルを GCS へアップロード
!gsutil -m cp aozora/data_list.csv gs://<プロジェクト名>-lcm/aozora_dataset/

4. AutoML NL のトレーニングは簡単

それでは AutoML NL でトレーニングを実行します。2章で使うための準備はできていると思いますので、GCP コンソールの AutoML NL の画面へ移動してみましょう。

ここで、[NEW DATASET] クリックしてデータセット作成を始めます。

alt_text

[Dataset name] には “aozora_dataset” を指定し、1つの文章には1人の作者しか紐づかないため、[Objective] では [Single-label classification] を選択します。

alt_text

そして、”Import text items” では、[Select a CSV file on Cloud Storage] をチェックして先程アップロードしたデータリストファイルの url を指定し、[CREATE DATASET] をクリックして開始します。

alt_text

データセットの作成処理が始まるので、しばし待ちます。

alt_text

今回は8分ほどでした。

ちなみに “dazaiosamu”、”miyazawakenji”、”natsumesoseki” とあるのはアップロードしたトレーニングデータのラベルで、それぞれのデータ数も表示されています。前述の Python を使った前処理では、各作品から読み込んだ段落を1つのデータとして区切っていました。なので、この画面では区切った段落がそれぞれ1つのデータとして表示されています。

alt_text

そして、[TRAIN] タブをクリックすると、トレーニングデータ数は十分だ、と教えてくれるので、そのまま [START TRAINING] をクリックします。

alt_text

次の画面で [Model name] を入力し(ここではデフォルトのままにしています)、さらに [START TRAINING] をクリックしてトレーニングを開始します。

alt_text

alt_text

今回はトレーニング終了まで3時間ほどかかりました。

5. トレーニングしたモデルの評価も確認できる

トレーニングで作成されたモデルの評価も画面上から簡単に確認することができます。

トレーニング終了後の画面で [EVALUATE] をクリックすると以下のようにモデルの評価が表示されます。その中で [Avg presision] のスコアが確認できますが、これは各ラベルでの平均での Precision-Recall Curve(適合率/再現率曲線) の下の面積を表しています(グラフは下の方に表示されています)。

alt_text

要は、この値が 1.0 に近いほど精度が高いことを示します。ランダムに推定した場合はこの値が 0.5 になりますが、今回の結果は 0.873 とあるので、それほど低い精度ではないとみることもできます。ただし一般的に、この値での評価はそのモデルを使って推定したい事象(解決したい課題) によるので注意する必要があります。

2つめの指標として [Precision] が確認できます。これは以下のような計算で求められ、

precision(適合率)
  = (真陽性) / (真陽性 + 偽陽性)
  = 正しく作者を推定した数
    / (正しく作者を推定した数 + 間違って作者を推定した数)

例えば、以下のように考えます。

precision(適合率)
  = 漱石と推定して正解した数 
    / (漱石と推定して正解した数 + 漱石と推定して実際には太宰、宮沢だった数)
  = 漱石と推定したサンプルデータの中で、実際に正解した割合

3つめの指標として [Recall] が確認できます。これは以下のような計算で求められ、

recall(再現率)
  = (真陽性) / (真陽性 + 偽陰性) 
  = 正しく作者を推定した数
    / (正しく作者を推定した数 + 間違って作者ではないと推定した数)

例えば、以下のように考えます。

recall(再現率)
  = 漱石と推定して正解した数
    / (漱石と推定して正解した数 + 漱石以外と推定して実際には漱石だった数)
  = 実際には漱石だったサンプルデータの中で、漱石だと推定して正解した割合

Precision と Recall がバランスよく高い値となる(Precision-Recall Curve の面積が 1.0 に近づく)ほど、トレーニングデータに偏りがなく、かつモデルが高い精度を示していることになります。

また、[EVALUATE] の結果として Confusion matrix も確認できます。この表では、各行が正解ラベルで、それに対する各列が推定したラベルとなっています。従って、対角線上の値が正解した割合となります。この表を見ることで、例えば、宮沢賢治の作品を太宰治と間違えた割合が比較的高い、ということなども確認できます。

alt_text

6. トレーニングしたモデルで文豪っぽさを推定してみる

ここからが本来やりたかったことになりますが、トレーニング後のモデルを使用した推定は簡単です。GCPコンソールの [PREDICT] タブをクリックして表示される画面で、推定したいテキストを入力し、その下の [PREDICT] をクリックするだけです(ちなみに、REST API、Python の API を使用した推定のサンプルコードが [PREDICT] の画面の下の方に表示されます)。

alt_text

それでは早速実行してみます。トレーニングデータに含まれていない宮沢賢治の「銀河鉄道の夜」の「ジョバンニが学校の門を出るとき、同じ組の七、八人は家へ帰らずカムパネルラをまん中にして校庭の隅の桜の木のところに集まっていました。」という一文を入力して [PREDICT] をクリックします。すると推定結果はすぐに表示され、”miyazawakenji” のラベルの推定が最も高く 1.000 となりました。

alt_text

また、太宰治の「走れメロス」の「メロスは激怒した。必ず、かの邪智暴虐の王を除かなければならぬと決意した。」という文を推定すると、0.988 という結果となりました。

alt_text

ちなみに「メロスは激怒した。」だけの場合は “miyazawakenji” の推定値が 0.977 と、最も高い結果でした。さすがに文が短いと推定の精度は低いようです。

さらに、夏目漱石の「こころ」の「私はその人を常に先生と呼んでいた。」では、”natsumesoseki” が 0.994 となりました。

alt_text

トレーニングがうまくいっているようなので、最後に弊社会長吉積のメッセージがどの文豪っぽいかを推定してみます。その結果は以下のようになりました。

alt_text

7. トレーニング費用は多少かかるが推定費用は安い

AutoML NL の費用は以下のようになっています。今回のトレーニングは3時間ほどかかっていたので、1000円ほどになります。個人的には、気軽に何度も試せる金額ではないかな、という印象です。推定については、3万回も実行していないので今回は無料になります。もし、3万回以上推定することがあったとしても、500万回までは月に550円ほどですみます。

トレーニング 1時間あたり $3.00
分類 0~30,000/月 無料
30,001~5,000,000/月 $5.00
5,000,000/月 お問い合わせ

8. まとめ

いかがでしたか? (機械学習ではよくあることですが)トレーニングデータの準備には手間がかかったとしても、データさえ揃えれば、あとのトレーニングの実行、そして作成したモデルでの推定はとても簡単だったのではないでしょうか? これだけ簡単に高度な機械学習モデルが構築できるのは魅力的だと思います。TensorFlow などのフレームワークを使用した独自の機械学習モデルの構築が必要なケースもあると思いますが、その前に、AutoML の性能を確認していただくことをお勧めします。AutoML で要件を満たせるのであれば、これほど楽なことはないので!

次の記事を読み込んでいます
次の記事を読み込んでいます
次の記事を読み込んでいます