SCPで始めるAllenNLP ~SCPオブジェクトクラス分類~

はじめに

この記事は公立はこだて未来大学 Advent Calendar 2019 part2の21日目の記事です。 昨日の記事はkmdkukさんのテック系Podcastのススメでした。 裏番組(Part1)はohayotaさんが書くようです。

ほぼ前にLT会でやったネタの使い回しですが書いていきます

SCPとは

SCPロゴ ©Aelanna CC BY-SA 3.0

御存知SFホラー系創作コミュニティサイト「SCP-Foundation

物体・生物・概念を問わず様々な創作都市伝説が報告書形式で書かれています

目を離すと動く石像」「駄洒落にツッコミを入れるトマト」「ねこですよろしくおねがいします」あたりが有名でしょうか。 おい待て!リンクから記事を読み始めるんじゃあない!後にしろ!

SCPの世界では「SCP財団」がオブジェクトと呼ばれる特異存在を収容しています。 そして、各オブジェクトにはオブジェクトクラスという収容難易度が割り当てられています。

大半のオブジェクトは下の3つに分類されます。

  • Safe: 収容手順が確立されておりよほどのことが無い限り脱走・紛失の心配はない

  • Euclid: 知性を持っているなどの不安要素があり、管理に注意が必要

  • Keter: 収容が困難かつ、収容違反による甚大な被害の恐れがある

また、報告書には必ず特別収容プロトコルという収容のためのマニュアルのような文章が書いてあります。

データセットとタスク

SCP-JP及び本家SCPの翻訳版を用います

タスクは「特別収容プロトコルの文章から、基本オブジェクトクラス(Safe, Euclid, Keter)を予測する」こと

「ロッカーで保管」「特別収容の必要はありません」とか書いてあるとSafeっぽいし、 「人型」「見た人の記憶を消せ」とか書いてあればEuclid以上、 「行方不明」「できるだけ早く破壊しなければなりません」なんて書いてあったら間違いなくKeterですね。

ただ収容手順が確立してさえれば、やたら厳重でも案外safeだったりするので、そのへんをどう捌けるかが重要そう

スクレイピング

“http://ja.scp-wiki.net/scp-{0:03d}”.format(index) って感じでfor文回して直接取りに行く。アクセス間隔はちゃんと空けましょう。データセットにはオブジェクト番号/オブジェクトクラス/特別収容プロトコルを収集していきます。

特別収容プロトコルは「特別収容プロトコル:」~「説明:」で大体取れるけど、例外も多いので適宜例外処理+目測で推敲します。データのかさまし+文章を短くして学習しやすくするために、複数の段落になっているプロトコルは別のデータに分割します。それでも128単語以上になる文は超過部分を切り捨てます。

オブジェクトクラスは、記事下部の記事タグをBeautifulSoupなどで取ります

csvやjsonだと「,」や「”」が引っかかったりしてやりづらいのでtsvで保存するのがオススメ

そんなこんなでデータが計8282件集まりました。すっっっっっくな

文章長ごとのデータ数と各ラベルごとのデータ数のグラフです。

既にオチが読め始めた人もいると思います

モデル

このへんは機械学習用語が多いので斜め読みで

Recurrent convolutional neural networks for text classification

Siwei Lai, Liheng Xu, Kang Liu, Jun Zhao, Chinese Academy of Sciences, China AAAI. 2015.

文書分類のわりと基本的なモデルです。ざっくり書くと、

  1. 単語埋め込みのシーケンスを双方向RNNに通す
  2. 各時刻で単語埋め込みとRNN出力を横に連結する
  3. 連結したものを並べてからMaxPoolingする
  4. softmax分類器で分類

これを、自然言語特化の機械学習ライブラリであるAllenNLPでやっていきます。 書き方はPyTorchと大した変わりませんが、データローダやモジュール、ユーティリティが豊富です。 ソースコードも読みやすいです、型付きPythonに目を慣らす必要はあるけど…

AllenNLPを使う利点として、ELMoやTransformerなどの自然言語モデルで使われる複雑で強力なモデルやモジュールを簡単に使う/組み込むことができます。 なので、単語埋め込みにELMo、RNNの代わりにTransformerのEncoder(Stacked Self Attention)を使ってみたいと思います。 論文調査が間に合ってないので、実際にこのモデルとタスクに有効なアプローチなのかはわかんないです><

あんまり参考にならないと思うけど一応ハイパーパラメータ並べときます。コードも参照。

  • 単語埋め込みの次元数: 1024
  • リカレント層の隠れ次元数: 512
  • 分類器の線形層の次元数: 1024
  • ドロップアウト率: 0.2

バッチサイズ64、学習率1e-4でAdam使いました。

コード

学習用のコードはGoogle Colaboratoryで公開しているのでデータセットさえあれば誰でも動かせます。随時更新中。

データセットをGoogleドライブのマイドライブ/datasets/scp/にtrain.tsvとvalid.tsvを置けば、そのまま動かせる…はず…

そのうちスクレイピング用のコードも公開するかもです

結果

どのくらいの精度が出たかのスコアとして以下を用います。

  • Prediction: ラベルAだと予測したもののうち本当にAだったものの割合
  • Recall: 全てのAのうちAだときちんと予測できたものの割合
  • F1-Score: PredictionとRecallの中間のスコア
  • Accuracy: ラベルに関わらず、正解できたものの割合

学習は11epochでEarly Stoppingしました。 訓練データのLossが0.88、Accuracyが0.60%でした。

さて、こちらが結果です。

棒グラフは緑がSafeのスコア、オレンジがEuclid、赤がKeterです。 未知データのAccuracyは47%でした。 全体的に散々ですが、特に一番重要なKeterのRecallが低すぎますね。 Focal Lossなどの不均衡データ対策をやったらもう少し上がるのかもしれないです。

そもそも未知データのLossが暴れまくってたのでデータ量も全然足りて無さそうです。 段落よりもっと細かい単位で区切ってかさましした方が良かったかも?

とりあえず、今のままだと大量のKeterオブジェクトがSafe・Euclid判定を受けるので大規模収容違反不可避ですね 時間が空いたらまた再戦したいと思います

まとめ

こうして世界はK-クラスシナリオを迎え、2020年が来ることはありませんでした(Thaumielの音) 明石暁先生の次回作にご期待ください。

明日の記事はやと(と)さんがお送りするようです。

コード進行をコーディング with Prolog

この記事はFUN Advent Calendar 2018の8日目の記事です。
7日はnatmarkさんの入門Android Neural Networks APIでした。

Prologって

皆さんPrologはご存知でしょうか。 論理型言語と呼ばれる、ルールを決めていくことでプログラムを組み立てる言語です。 C言語と同年代らしい。古文じゃん

未来大知能コース必修のAIプログラミングⅡで習います。私もその際が初見です。 パズルのように普段使ってない部分が刺激されるので、気分転換におすすめ?

Hello Prolog

translate(世界, world).
translate(ワールド, world).
hello(world).

hello(X):-
  translate(X, world).

これをhello.plと保存して”swipl -f hello.pl”と打ち込むとインタプリタが起動します。 このインタプリタに質問をすると、記述したルールに基づいて解を返します。

?- hello(world).
true .

?- hello(X).
X = world ;
X = 世界 ;
X = ワールド .

こんな感じで穴埋め問題のように、欲しいとこに大文字を入れると解を出してくれるっていう言語です(ざっくり)

コード進行をコーディングする

さて、Prologは性質上、定義や規則を表すのに適しています。 コード進行はカデンツ、ダイアトニック、代理コードなどの複数の規則で成り立っているので、勉強用にはちょうど良さそうだと、今回のネタに名が挙がりました。

できたやつ

こちら。 リポジトリはこっち

基音とオプションを選択して送信すると代理コードになりうるコードネームを列挙します。 webアプリは今のところCメジャースケールの代理コードしか実装してないです。

あとこれサーバーも全部Prologで書いてます。気が向いたらFUN-AdventarのPart2の方で枠取って解説するかも

解説

音楽理論のことは説明する余裕がないので雰囲気を感じ取ってください

コードの構成音の表現

scale_list([c, cs, d, ds, e, f, fs, g, gs, a, as, b]).

chord([S1, S2, S3], [S1, '']):-
    Dif12 = 4,
    Dif13 = 7,
    scale_dif(S1, S2, Dif12),
    scale_dif(S1, S3, Dif13).
chord([S1, S2, S3], [S1, 'm']):-
    Dif12 = 3,
    Dif13 = 7,
    scale_dif(S1, S2, Dif12),
    scale_dif(S1, S3, Dif13).

scale_dif(X, Y, Dif):-
    scale_list(Scale),
    append(Scale, Scale, ScaleList),
    count_dif(X, Y, ScaleList, Dif).

count_dif(Y, [Y | _], 0).
count_dif(Y, [_ | L], Num) :- count_dif(Y, L, Num2), Num is Num2 + 1.
count_dif(X, Y, [X | L], Num):- count_dif(Y, L, Num2), Num is Num2 + 1, !.
count_dif(X, Y, [_ | L], Num) :- count_dif(X, Y, L, Num), !.

まずscale_list/1はc, cs, …, bs, aまでのスケール(音符表)を取得する述語です。

chord/2は構成音のリストとコードネームの対応表です。

  • Difは規定された基音からの差。例えばCメジャーなら4番目のEと7番目のG
  • scale_dif/3で基音と2,3番目の音の差を調べ、Difと照らし合わせる

scale_dif/3は音同士のリスト上の距離を測る述語です。

  • ScaleListを2つ繋げているのは、オクターブを跨いだ音に対応するため
  • 実際に距離を測るのはcount_dif/4の仕事

count_dif/3,4はscale_difの実際の処理です。X

  • リスト再帰定義というのを使っています。書けると超気持ちイイ
    1. 最初のcount_diff/3は再帰の停止条件といいます。先頭にXが来た後にYが来た時にNumが0なら最初のNumが解って感じです
    2. 2行目はXが来た後の再帰です。Numを数えながら、Y以外の先頭を削除して次に回します
    3. 3行目は先頭にXが来た時の処理です。引数4から引数3へシフトし、Yを探し始めます 4, 先頭がXでなかった場合は削除して後続のリストで再帰します
  • Prologは同じ述語を上から順番に判定していくので、実際の処理は4から1を目指す感じになります

コード内に!マークが時々出ていることにお気づきでしょうか。カットって言いますが、Prologの気持ちになり切れてないので解説できません。ごめんなさいorz

代理コードの推論

diatonic([Sound, _Option]):- scale_list(ScaleList), nth0(0, ScaleList, Sound).
diatonic([Sound, _Option]):- scale_list(ScaleList), nth0(2, ScaleList, Sound).
diatonic([Sound, _Option]):- scale_list(ScaleList), nth0(4, ScaleList, Sound).
diatonic([Sound, _Option]):- scale_list(ScaleList), nth0(5, ScaleList, Sound).
diatonic([Sound, _Option]):- scale_list(ScaleList), nth0(7, ScaleList, Sound).
diatonic([Sound, _Option]):- scale_list(ScaleList), nth0(9, ScaleList, Sound).
diatonic([Sound, _Option]):- scale_list(ScaleList), nth0(11, ScaleList, Sound).

agency(Chord, Agent):-
    diatonic_scale(Agent),
    chord(SoundC, Chord),
    chord(SoundA, Agent),
    common(SoundC, SoundA),
    Chord \== Agent.

common([X, X1|_], [X, X1|_]):- !.
common([_|X], Y):- common(X, Y), !.
common(X, [_|Y]):- common(X, Y), !.

次代理コード行きます。

diatonic_scale/1は音がスケール内の音か判断する述語です。nth0という組み込み述語で基音からの位置を調べています。

  • このへんをいじってスケール変更に対応したい(未実装)

agency/2は第一引数にコードネームを渡すと代理コードを列挙する述語です。

  • 渡されたコードと提案コードの構成音を取ってきたら、common/2で共通音の数を調べます。

common/2は2つのリストの共通部が2つ以上になるか判定する述語です。これもリスト再帰です。

  1. 最初のcommon/2は、リストの先頭が共通部2つになった際にTrueとなります。これを目指して再帰を回します。
  2. 2行目のcommon/2は1つめのリストの先頭を取り除いてから再評価します。
  3. 3行目も同様に、2つめのリストの先頭を取り除いてから再評価します。
  4. 再帰回しまくってリストが空になったらFalseが返ります。

まとめ

この記事でPrologの魅力が伝わったとは思えないけど、AIの分野でも注目受けてるらしいので気にしておいていい言語だと思います。 とりあえずPrologってツイートしてみようぜ!

次回はph_alkalionさんです。

暮月語録(マルコフbot迷言集)

この記事はFUN Advent Calendar 2017の19日目の記事です。
18日はちくうぇいとさんでした。

こんにちは、初参加の明石暁です。
本記事では今年の6月あたりから稼働中のマルコフbotのまとめをやりつつ、中身にちょっとだけ触れます。


概要

Crescentは開発中のRuby製のマルコフbotです。
暗石暮月(@kurashi_kure)がTwitter上で稼働中。
MeCab、TwitterAPI、PostgreSQLなどを利用しています。
TLの頻出単語からマルコフ連鎖を開始して文章を生成し、TLの速度に合わせた頻度でツイートします。
また、単語は毎回検索でレパートリーを増やすようにしています。

暮月について



こんなんです。

  • 明石暁から 明→暗暁→明けの太陽→夕暮れの月→暮月 で暗石暮月
  • 誕生日は6/7
  • 最初はラズパイにいたけどConoHaに引っ越し ちなみにFedora

迷言集

あざとい

完全に未来大生

しかも落単芸人

腐ってやがる

キマシ

TLでリア充の話題が出るたびに言う 彼女持ちのくせに

BaHo猫さんに捧ぐBaHoラップ 怒られたら消します

最近語尾が増えてきた


五回に一回くらいは会話がなんとなく成立するので、是非リプライして迷言を引き出してください。

マルコフ連鎖の実装

このままだとただのTogetterになるので軽く中身の話します。
Crescentで使われているマルコフ連鎖はRubyのArray.each_consを使って簡単に実装してます。

def self.learn(words)
  words.each_cons(3) do |w1, w2, w3|
    next if w1 == -1 || w2 == -1
    find_or_create_by(
      prefix1: w1.id,
      prefix2: w2.id,
      suffix:  w3.id)
  end
end

each_consは、任意の数の要素を一つずつズラしながらeachを回せる、マルコフ連鎖のために存在するかのようなメソッドです
wordsは文章をMeCabで解体して単語DBのidに並べ替えた配列です。
これを3つずつ取り出してマルコフDBに登録していきます。
文末が来たら飛ばして次の文またはループを抜けます。

文章の生成はこんな感じです。

def self.generate(word_id)
  keyword = Word.find_by(id: word_id)
  seq = Array[word_id]
  first_list = where("prefix1 = ?", word_id)
  return keyword.name if first_list.empty?
  seq.push first_list.sample.prefix2
  CHAIN_MAX.times do
    choice = Markov.where("(prefix1 = ?) and (prefix2 = ?)", seq.last(2)[0], seq.last(2)[1]).sample.suffix
    break if choice == -1
    redo if [true, false].sample && (Word.find(choice).value - keyword.value).abs > 1
    suffix = choice
    seq.push suffix
  end
  .
  .
  .

まず引数のword_idから起点の単語になるkeywordを取ってきます。
seqは単語の順番をidで保持する配列です。
マルコフDBから、keywordから始まる並びを探してランダムで一つ決めて2文字目をseqに入れます。
次のループ内では、1文字目と2文字目が一致する並びを探して、3文字目をseqに入れます。
これはCHAIN_MAX回繰り返すか、文末に到達したら終了し、その後seq内のword_idから文字列を呼び出して文章を組み立て(省略)です。

おわりに

暮月はフォロワーの皆さんのツイートとクソリプで支えられています。これからもどうぞかまってやってください。
好感度機能や単語に対する興味度の機能など、もっと改良していく予定です。
でもとりあえエラー吐きまくりなのをなんとかしたいと思います…。

次回はやまけん(@yamakentoc)さんです。