見出し画像

NLP!FastTextでテレビ番組のジャンル推測モデルをトレーニングする

自然言語処理でテレビ番組表のジャンルを推測してみたい。

『番組情報を与えたら、ジャンルを推測してくれる。』
そんな感じの推測モデルを作れないものだろうか。

技術が進歩した今なら、自然言語処理&Python素人の私にもできるはず。
エンジニアは手を動かしてなんぼのはずだ。
そう思って、実際にやってみることにした。

ライター紹介

森川 知雄(もりかわともお)
中堅SIerでテスト管理と業務ツール、テスト自動化ツール開発を10数年経験。
SHIFTでは、GUIテストの自動化ツールRacine(ラシーヌ)の開発を担当。
GUIテストに限らず、なんでも自動化することを好むが、ルンバが掃除しているところを眺めるのは好まないタイプ。
さまざま案件で自動化、効率化によるお客様への価値創出を日々模索している。
2021年からは技術イベントSHIFT EVOLVEの運営を主担当。好きな食べ物はナン。


あらまし

自然言語処理には、単語のベクトル処理が可能な機械学習ライブラリFastTextを使う。

fastText

そしてFastTextでは教師あり学習が可能である。

fastTextは、 Facebookの AI Research (FAIR) ラボによって作成された、単語の埋め込みとテキストの分類を学習するためのライブラリです。このモデルにより、単語のベクトル表現を取得するための教師なし学習アルゴリズムまたは教師あり学習アルゴリズムを作成できます。Facebook は、294 言語の事前トレーニング済みモデルを提供しています。fastText で使用される手法については、いくつかの論文で説明されています

fastText - WikiPediaより

教師データとしてNHK謹製のサービスであるNHK番組表APIを使わせていただいた。

NHK番組表API | portal

検証環境はWSL + Dockerで立てる。

先に結論を言うと、ある程度の推論がうかがえるまでの精度には達したと感じている。
結果だけを知りたい方は後述の「ジャンルを推測してみよう」の項をご覧ください、タイム・イズ・モネー。

データの準備

公開されているAPIから"Program List API"を選んだ。
サービスとエリアを指定するとJSONを返してくれるAPIである。
このAPIでは番組情報のgenres要素にジャンルIDを持っている。

  {
        "id": "2022080522386",
        "event_id": "22386",
        "start_time": "2022-08-05T04:10:00+09:00",
..."title": "時論公論「イランをめぐる国際関係 核合意の行方は」",
        "subtitle": "中東のイランをめぐり情勢が動いている。イランの核開発を制限する「核合意」が機能しない中、アメリカ、イランの思惑と今後の協議の行方を解説する。",
        "content": "【出演】NHK解説委員…出川展恒",
        "act": "【出演】NHK解説委員…出川展恒",
        "genres": [ 👈 これ
          "0006",
          "0000",
          "0007"
        ]
      },

ジャンルIDは、"ARIB STD-B10 デジタル放送に使用する番組配列情報標準規格 5.1版" に準じているらしく、ネットで調べるとリストを取得することができたので日本語マスターとして使用する。

0000,定時・総合
0001,天気
0002,特集・ドキュメント
0003,政治・国会
...

詳細は下記を参照していただきたい。

ドキュメント リクエスト | portal

尚、このAPIは合計15サービスを7日先まで取得できるので
全サービス分をダウンロードする。
今回は十分な学習データを確保するため数週間分のデータを用意した。

※ 注意:APIは利用規約をよく読んで正しく使いましょう

環境構築

WSL上にディレクトリを切って各ファイルを置く

├── Dockerfile
├── docker-compose.yml
├── json 
│   ├── e1_yyyy-mm-dd.json
...
│   └── e4_yyyy-mm-dd.json
└── sub
    ├── genre.txt ジャンルリスト
     └── train.py

"train.py"はモデル生成用スクリプト、今回の主プログラムである。

Dockefile

FROM python:3.9

RUN pip install \
  gensim \
  numpy \
  mecab-python3 \
  requests \
  unidic \
  unidic-lite
RUN git clone https://github.com/facebookresearch/fastText.git && \
    cd fastText && \
    pip install .
RUN python -m unidic download

コンテナイメージをビルドする。

docker build -t fasttext-study .

DockerfileではPythonで必要なライブラリとFasttextをインストールしており
後述のMeCabのためにunidic辞書をダウンロードしている。

コンテナの起動はdocker-composeから行う。
jsonファイルや教師データなどのリソースはすべてボリュームからアクセス可能にしておく。

# docker-compose.yml
version: '3.3'
services:        
  fasttext:
    container_name: fasttext-study 
    image: fasttext-study
    tty: true        
    volumes:
      - ${PWD}/train:/train
      - ${PWD}/json:/json
      - ${PWD}/model:/model
      - ${PWD}/sub:/sub

モデル生成スクリプト

APIから得たJsonから教師データのモデルを生成するには幾つかのステップが必要となる。
これを一気にやってしまいたいと思い一本のスクリプトにしてみた。

スクリプトのおおよその流れ

  • Jsonから番組情報とジャンルを抜き出す

  • クレジング:記号などのデータを除去

  • 分かち書き:単語区切りに分解

  • 教師データを生成

  • モデルをトレーニングして生成

※ 詳細は後述の付録にてスクリプトを載せているのでここでは省略する。

#
# script: sub/train.py
#
# ジャンルマスターをメモリ上に生成する処理

# クレンジング処理
...
# テキストを分かち書きに変換する処理
...
# Jsonを展開して教師データを生成する処理
...
# 教師データの生成処理
...
# モデルの生成
def create_model():
    model = fasttext.train_supervised(input=ROOT_PATH + TRAIN_FILE)
    model.save_model(ROOT_PATH + MODEL)
    print(model.test(ROOT_PATH + VALID_FILE))
    return model

#
# main
#
tagger = MeCab.Tagger('-Owakati')
...
create_model()

Jsonから抜き出したデータから教師データを生成し
トレーニング用(TRAIN_FILE)と、検証用ファイル(VALID_FILE)に分割する。

"create_model"ではこれらを使ってモデルを生成しファイルとして保存している。

model = fasttext.train_supervised(input=ROOT_PATH + TRAIN_FILE)
model.save_model(ROOT_PATH + MODEL)

、検証用ファイル(VALID_FILE)でテストを行い、モデルの精度を確認している。

print(model.test(ROOT_PATH + VALID_FILE))

スクリプト実行~モデル生成

docker-composeで起動したコンテナ内でスクリプトを実行する。

docker-compose up -d
docker exec -it fasttext-study /bin/bash

python /sub/train.py

実行結果

Read 0M words
Number of words:  19743
Number of labels: 83
Progress: 100.0% words/sec/thread: 1743388 lr:  0.000000 avg.loss:  4.039466 ETA:   0h 0m 0s
(655, 0.18778625954198475, 0.09304084720121028)

モデルの精度は0.18、再現率は0.09となった。

"精度"はFastTextによって推測されたラベルのうち、正しいラベルの数で
"再現率"は、すべての実際のラベルの中で、正常に推測されたラベルの数とのこと。

これは高い精度とは言えない。チューニングが必要だ。

チューニングをする

機械学習はチューニングが肝心と聞く。
モデルのパラメータとデフォルト値([]の値)は以下の通り

  • bucket ngram のハッシュに使用されるバケット数 [2000000]

  • dim ベクトルの次元数 [100]

  • epoch エポック [5]

  • loss 損失関数 {ns, hs, softmax} [softmax]

  • lr 学習率 [0.1]

  • wordNgrams 単語Nグラム [1]

  • verbose ログ詳細化[2]

嬉しいことにFastTextのチュートリアルでは
パラメータについて詳しく解説してくれている。

Text classification · fastText

これに習って"train.py"のパラメータを変えながら試行錯誤をしてみた。
その結果、以下のようになった。

Read 0M words
Number of words:  19743
Number of labels: 83
Progress: 100.0% words/sec/thread:  437696 lr:  0.000000 avg.loss:  1.554914 ETA:   0h 0m 0s
(655, 0.8076335877862595, 0.4001512859304085)

精度、再現率のいずれもデフォルトのパラメータから飛躍的に向上した。
使用したパラメータはこんな感じである。

# 変更前
model = fasttext.train_supervised(input=ROOT_PATH + TRAIN_FILE)
# 変更後(パラメータ例)
model = fasttext.train_supervised(input=ROOT_PATH + TRAIN_FILE, epoch=25, lr=1.0, wordNgrams=1, bucket=200000, dim=500)

自動チューニングができると後で知る

FastTextでは、パラメータの最適化を自動的にできることを
さんざん試行錯誤した後に知った。
公式ドキュメントにしっかり書いてあるのに、うっかりものである。

パラメータは以下の通り

  • autotune-validation 検証ファイルの指定

  • autotune-metric 優先ラベルの指定

  • autotune-predictions 評価に使用される推測数

  • autotune-duration 実行に書ける時間

  • autotune-modelsize モデルのファイルサイズ

早速自動チューニングを使ってみた。

model = fasttext.train_supervised(
    input=ROOT_PATH + TRAIN_FILE, 
    autotuneValidationFile= ROOT_PATH + VALID_FILE, 
    autotuneDuration=180,
    autotunePredictions =5
)
※ PythonではCamel Caseでオプションを記述する

結果はこのとおり

Progress: 100.0% words/sec/thread:  292939 lr:  0.000000 avg.loss:  0.998500 ETA:   0h 0m 0s
(683, 0.8199121522693997, 0.40965618141916604)

自前でチューニングした場合より精度が良くなっている。
すごい。

最終的なパラメータはvarboseオプションを指定すればログで確認できる。

List of options · fastText

ジャンルを推測してみよう

さぁ準備はできた。(長かった)
番組を説明するテキストを与えて、ジャンルの推定を試してみる。

テストコードはこちら。

# コンテナ内でpythonと叩く
import fasttext
model = fasttext.load_model("生成したモデルのパス")
model.predict("ジャンルを推測するテキスト", k = 5, threshold = 0.1)

ルール

  • 番組のタイトルや内容を説明するテキストを評価して ラベル(今回の場合はジャンル)を推測するAPI"predict"を用いる

  • テキストはMecabでわかち書きにしておく

  • 内容の一致するラベルが推測できたら及第点、さらに精度が0.5以上であれば合格とする。

結果の見方

テキスト:推測するテキスト 
ジャンル:推測されたジャンル(精度) 
※ 以下精度の降順に推測候補が並ぶ

いざ実行

食欲の秋ということでみなさんが大好きな「キノコ」を選んでみた。

最初のお題は簡単だ。

テキスト:キノコレシピの紹介 
ジャンル:グルメ・料理(0.99953783)

グルメ番組に違いない、合格である。
少しひねってみよう。

テキスト:キノコを求めて里山を歩く
ジャンル:ローカル・地域(0.85830045)

カロリー低めのローカルぶらぶら番組を想像させるタイトル。
上出来、合格だ。

テキスト:新種キノコ発見 お手柄中学生の名前を採用か
ジャンル:自然・動物・環境(0.98154885)

なんと、これも合格。自然・環境系のコンテンツとして認識されている。
"中学"というキーワードに惑わされていないところが良い。

テキスト:私とキノコの80年人生
ジャンル:海外ロック・ポップス(0.30290237)
    :国内ロック・ポップス(0.29674232)
    :社会福祉(0.19283171)
    :トークバラエティ( 0.12923248)

残念ながら不合格。
候補が多いところに迷いを感じる。
キノコと人生でなぜ音楽になるのだろうか
何か秘密があるのかもしれない。

思い切って食材から離れてみよう。

テキスト:終戦から77年 キノコ雲の下で何が起こっていたのか
ジャンル:ドキュメンタリー全般(0.45281234)
    :カルチャー・伝統文化(0.22417668)
    :ローカル・地域 (0.11437157)

及第点だが、良いと思う。
2位以降の候補もそれほどは遠くはない。
結果が分散するとモデルに迷わせてしまったみたいで、なんだか申し訳ない気分になる。

テキスト:食の安全 今キノコが危ない 外食産業で何が起こっているのか
ジャンル:カルチャー・伝統文化(0.22362447)
    :ドキュメンタリー全般(0.2095945)
    :暮らし・住まい(0.11046771) 

これは不合格。"ドキュメンタリー全般"がトップに来てほしかった。

ドラマっぽいものを推測する

架空のドラマの情報を与えて、ジャンルをドラマ(またはアニメ)と推測できたら
それは凄いことではないか。
なぜなら"ドラマっぽさ"をモデルが推論できるということだからだ。

さっそくやってみよう。

テキスト:最近裏山で採れるキノコが美味すぎる件
ジャンル:旅・釣り・アウトドア(0.52443504)
ジャンル:旅バラエティ(0.26292202)

ライトノベルっぽいタイトルにしてみたが、ドラマはかすりもしない。
外に出てキノコを食べる番組になってしまった。

タイトルだけでは難しいのかもしれない。

それっぽい説明をつけてみる。

テキスト:最近裏山で採れるキノコが美味すぎる件 主人公の太郎はキノコ好きの一人暮らしの26歳サラリーマン。ある日突然、父の那須男がやってきて...
ジャンル:国内ドラマ(0.35908607)
    :幼児・小学生(0.14749332)

ドラマとして認識された。
すばらしい。

テキスト:キノコのナスがママ!
ジャンル:旅・釣り・アウトドア(0.53586715)
    :カルチャー・伝統文化(0.23944585)

説明が少なめの昭和なファミリーコメディっぽいタイトルにしてみたが
やはりアウトドア色が強くなってしまう。
令和ではフィットしないのか。

テキスト:キノコのナスがママ! 主人公の太郎はキノコ好きのわけあり一人暮らしの高校生。ある日突然、幼なじみの那須子と住むことに...
ジャンル:国内ドラマ(0.42843699)
    :国内アニメ(0.26975736)
    :幼児・小学生(0.15353644)

それっぽい説明をつけてみると、ドラマとアニメで認識された。
アニメっぽい設定が含まれているということらしい。
架空の番組説明が創作っぽい、と認識された瞬間である。

最後に弊社社長の著書タイトルのジャンルを推測してみた。

テキスト:できないとは言わない できると言った後にどうやるかを考える
ジャンル:中学生・高校生(0.52443504)
    :幼児・小学生(0.26292202)

不思議な結果になってしまった(社長、申し訳ありません)
この書籍は書かれていることは、成人したサラリーマンだけでなく
中高生いやさ幼児でも理解できると、示唆しているのかもしれない。

なんだか、結果が正しい様に思えてきた。
モデルではなく私が考え過ぎてしまっている感じがする
このへんで終わりにしたい。

できないとは言わない。できると言った後にどうやるかを考える | 丹下 大 |本 | Amazon

まとめ

  • NHK番組表データをAPIから取得

  • WSL + DockerでFastTextの環境を構築した

  • スクリプトで教師データ生成からのモデル生成

  • モデルのチューニング

  • 番組カテゴリを推測した

番組名や説明テキストからある程度のジャンルを推測できることがわかった。

課題としては教師データのボリュームが少なく、十分な学習をできているのか、わからなかったこと。MecabをNeologdなどの辞書に最新化していなかったことが挙げられる。

アップデートされた辞書を用いれば、著名人ならわかち書きは" 山田 太郎 "ではなく" 山田太郎 "とすることで番組と出演者の紐づけは可能になるだろう。


尚、本記事でやっている教師ありデータのチュートリアルは公式サイトにすべて書かれている。チューニングパラメータの効果が非常にわかりやすいく説明されており「Fasttextを触ってみたい!」という方にはおすすめします。

Text classification · fastText

チュートリアルではSeasoned Adviceという料理サイトのデータを使っている。今回は日本語で試してみたかったし、教師データの収集についても知りたかったので、TV番組という題材を選んでみた。

私のようなML素人、Python素人でもなんとか体験できたのは、FastTextやMecabといったOSSプロダクトのおかげ。深く感謝である。

次回はMecab辞書の改良や、FastTextの教師なし学習についてもチャレンジしてみたいと思う。

駄文にお付き合いいただき、ありがとうございました。

付録

おまけとして、今回の処理を一括実行するスクリプトを載せておく。

```python
#
# fasttext 
# model training to detect genre of tv program  
#
import os
import json
import MeCab
import re
import fasttext

LABEL_PREFIX = '__label__'
ROOT_PATH = '/'
JSON_PATH = "json"
GENRE_MASTER_FILE = 'sub/genre.txt' # ジャンルマスター
TRAIN_FILE = 'sub/wakati.train'     # トレーニング用教師データ
VALID_FILE = 'sub/wakati.valid'     # 教師データ
MODEL = 'model/model.bin'
TRAIN_RATIO = 0.8

# ジャンルマスターをメモリ上に生成
def generate_genre_master(file):
    genre_master = {}
    g = open(file, 'r', encoding='utf-8')
    for l in g.readlines():
        tmp = l.split(',')
        genre_master.update({tmp[0]: tmp[1]})
    g.close()
    return genre_master

# クレンジング
def cleanText(txt):
    return re.sub('[“▼…▽!-/:-@[-`{-~、-〜”’・]+', ' ', txt)

# テキストを分かち書きに変換
def wakati_by_mecab(tagger, txt):
    terms = []
    node = tagger.parseToNode(cleanText(txt))
    while node:
        terms.append(node.surface)
        node = node.next
    return ' '.join(terms)

# Jsonを展開して教師データを生成
def extract_from_json(tagger, json_path, genre_master):
    result = []
    os.chdir(json_path)
    for f in os.listdir('.'):
        with open(filename, 'r', encoding='utf-8') as f:
            service = filename[0:2]
            jsonDict = json.load(f)
            for obj in jsonDict['list'][service]:
                line = '{0} {1} {2}'.format(obj['title'], obj['subtitle'], obj['content'])
                wakati = wakati_by_mecab(tagger, line)
                genres = []
                for genre in obj['genres']:
                    genres.append(LABEL_PREFIX + genre_master[genre])
                result.append('{0} {1}'.format(' '.join(genres), wakati))
    return result

# 教師データの生成
def create_train_txt(tagger):
    genre_master = generate_genre_master(ROOT_PATH + GENRE_MASTER_FILE)
    tmp = extract_from_json(tagger, ROOT_PATH + JSON_PATH, genre_master)
    wakati = list(set(tmp))
    train_record = int(len(wakati) * TRAIN_RATIO) - 1

    train = open(ROOT_PATH + TRAIN_FILE, 'w', encoding='utf-8')
    train.write('\n'.join(wakati[0:train_record]))
    train.close()

    valid = open(ROOT_PATH + VALID_FILE, 'w', encoding='utf-8')
    valid.write('\n'.join(wakati[train_record:]))
    valid.close()

# モデルの生成
def create_model():
    # 自動チューニングの場合
    model = fasttext.train_supervised(input=ROOT_PATH + TRAIN_FILE, autotuneValidationFile= ROOT_PATH + VALID_FILE, verbose=1, autotuneDuration=180)
    # 手動チューニング(パラメータは参考程度)
    # model = fasttext.train_supervised(input=ROOT_PATH + TRAIN_FILE, epoch=50, lr=1.0, wordNgrams=1, bucket=200000, dim=200) 
    model.save_model(ROOT_PATH + MODEL)
    print(model.test(ROOT_PATH + VALID_FILE))
    return model

#
# main
#
tagger = MeCab.Tagger('-Owakati')
create_train_txt(tagger)
create_model()

解説

"create_model"以外の、主な処理を補足しておく。

wakati_by_mecab

単語をベクトル分析するにはわかち書きへの変換が必須である。

単語を分解するわかち書きはこんな感じだよ -> 単語 を 分解 する わかち 書き は こんな 感じ だ よ

これを形態素解析と言うのだが、今回はMeCab(mecab-python3)を使用した。日本語の場合、単語できっちりわかれている英語と違い重要かつ困難な処理なのでMeCabさんに感謝である。

mecab-python3 · PyPI

extract_from_json

Jsonから教師データを生成する。
番組情報からタイトル、サブタイトル、詳細を取得してわかち書きにする。

ジャンルはマスターを参照して日本語名に変換する。FastTextではプレフィクス"label"を宣言することで、教師データとして認識される。
今回のlabelはもちろん番組ジャンルである。

フォーマットはこんな感じ。

__label__トークバラエティ __label__お笑い 晩婚 さん いらっ しゃい 日曜 3 時 の 定番。転び やすい 椅子 は ベスト セラー の 趣

create_train_txt

extract_from_jsonで生成されるわかち書きテキストを、教師データと検証用データに分ける。
チュートリアルを見るとだいたい8:2でやっているので
そのとおりに教師用データと検証用データを分割出力する。

train.write('\n'.join(wakati[0:train_record - 1]))
valid.write('\n'.join(wakati[train_record:]))

データ数は以下のとおり。

$ wc -l /train/*.*
   2620 /train/wakati.train
    655 /train/wakati.valid

参考:

__________________________________

執筆者プロフィール:森川 知雄
中堅SIerでテスト管理と業務ツール、テスト自動化ツール開発を10数年経験。
SHIFTでは、GUIテストの自動化ツールRacine(ラシーヌ)の開発を担当。
GUIテストに限らず、なんでも自動化することを好むが、ルンバが掃除しているところを眺めるのは好まないタイプ。
さまざま案件で自動化、効率化によるお客様への価値創出を日々模索している。
2021年からは技術イベントSHIFT EVOLVEの運営を主担当。好きな食べ物はナン。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/