(この記事は Elixir (その2)とPhoenix Advent Calendar 2016 10日目の記事です)
前回までは、文章解析を中心に作ってきましたが、少し方向性を変え、AIに感情のような「状態」を持たせ、その状態次第で返事を変えるようなロジックを作ってみましょう
なお、本コラム中の「Elixirの書き方」については、あまり細かく説明をしていないので、「ここの書き方が分からない」とか「この処理が何をしているのかよく分からない」等あればコメントください
人間の感情について
Wikipediaには感情に関するこんな一覧がありますが、人間の感情は、複雑な切り口や分類を持っています
中でも、会話に特化した分類というものがあり、感じたことから、どのようなアクションが行われるか、といった整理もあります
こういった情報をヒントに、AIの感情というものを設計してみるとしましょう
ちなみにこのシリーズは、「ライトな感じで弱々しいAIを作る」がテーマなので扱いませんが、脳の各部位(脳幹、辺縁系、前頭前皮質、扁桃体、海馬、視床下部、大脳新皮質など)の働きを「最低限の生存のためのデフォルト値」や「学習のためのメカニズム」とし、生まれたばかりの赤ちゃんが外部環境にどのように働きかけ欲求を満たしていきながら大人になっていくか、という観点から、感情やアクションをモデリングすると、より人間臭いAIへの手がかりになるでしょう
また、マズローの5段階欲求のどの領域をターゲットにするか、や、生存のための欲求=死に対する先天的な回避(脳で言うと扁桃体の働き)、体感覚からフィードバックされる学習経験や慣れといったものも、構築のための良いヒントになると思います
AIの感情を設計する
以下10個の会話に特化した感情分類をベースに、ライトな実装に収まる最低限というものを検討します
これらを以下のように集約・除外します
- 「喜」と「哀」は、同軸線上のプラス方向とマイナス方向なのでつ1に集約
- 「怒」および「怖」は、「哀」の別表現のようなものなので「哀」に集約
- 「恥」と「安」は、未実装な能動的発言・アクションに伴う感情なので除外
- 「好」と「厭」は、同軸線上のプラス方向とマイナス方向なのでつ1に集約
- 「昂」、つまり悪い面での焦りのようなものは、時間の概念が入ってきて面倒そうなので除外
- 「驚」は、知識や常識といったものとの照らし合わせが必要で本稿に収まらなさそうなので除外
残った以下について、定義します
- 「喜」←→「哀」・・・単発で発生する感情(その場の会話へのリアクションを決める)
- 「好」←→「厭」・・・永続的に残る感情・印象(リアクションの大枠が好意的か否かを決める)
ザックリまとめると、発言の内容によって、「喜」か「哀」に分類され、発言以前の「好」←→「厭」の状態でリアクション時の言葉の丁寧さ・親密さが変わる、という感じです
また、発言が「喜」か「哀」は、以降の「好」←→「厭」の状態にも影響を及ぼします
感情状態の可視化
変動する「好」←→「厭」の現在値は、以下のように表示します
-2~+2の5段階とし、真ん中の0がフラットな状態とします
単発感情の設計
単発感情である「喜」←→「哀」は、どのような発言があったとき、それを喜んだり、哀しんだりするかを定義することで実現します
ここは、単純過ぎるとは思いますが、「AIの名前」+「褒め言葉」であれば「喜」となり、「AIの名前」+「けなし言葉」であれば「哀」となる、としましょう
まず「AIの名前」ですが、「Elixir」にちなんで、「えりこ」でいきます
次に、「褒め言葉」「けなし言葉」ですが、本当はここで、WordNetのような「概念辞書」を使って、様々な言葉を解釈できるようにするとカッコいいんですが、それはまたの機会に預け、今回は、以下に反応するようにします
■褒め言葉
- えりこは速い・・・+2
- えりこは美しい・・・+1
- えりこは素晴らしい・・・+1
■けなし言葉
- えりこは遅い・・・-2
- えりこは醜い・・・-1
- えりこは難しい・・・-1
べっ、別に、Elixirのことを言ってるんじゃ無いんだからねっ!
では、これらと、「喜」「哀」の反応をEmotionモジュールとして追加します
defmodule Emotion do
def praise do
[
%{ "word" => "速い", "class" => "形容詞", "score" => 2 },
%{ "word" => "美しい", "class" => "形容詞", "score" => 1 },
%{ "word" => "素晴らしい", "class" => "形容詞", "score" => 1 },
]
end
def abuse do
[
%{ "word" => "遅い", "class" => "形容詞", "score" => -2 },
%{ "word" => "醜い", "class" => "形容詞", "score" => -1 },
%{ "word" => "難しい", "class" => "形容詞", "score" => -1 },
]
end
def thanks do
[
"ありがとう",
"ほめすぎだよー",
"照れちゃうよぅ",
]
end
def sad do
[
"えー...",
"そんなこと言わないでよ",
"そう言われてもなー",
]
end
end
単発感情の実装
仕様はこんな感じです
- 述語が「褒め言葉」「けなし言葉」のいずれかで、かつ全名詞句のどこかに”えりこ”があれば、感情反応する
- 「褒め言葉」ならスコアはプラスとなり、反応も「喜」になる(どの反応かはランダム)
- 「けなし言葉」ならスコアはマイナスとなり、反応も「哀」になる(どの反応かはランダム)
- これに該当しない場合は、前回と同じオウム返しをする
まずは、「褒め言葉」「けなし言葉」や、自分の名前が、句の中で出現したら、その文字列を返す関数を作ります
defmodule Cabocha do
def any_tok( [ %{ "chunk" => %{ "id" => id }, "toks" => toks } | tail ], target_id, match, word ) do
is_match = case id == target_id do
true -> match_word_class( toks, match, false )
false -> false
end
case is_match do
true -> match[ "word" ]
false -> any_tok( tail, target_id, match, word )
end
end
def any_tok( [], _target_id, _match, word ), do: word
…
この関数を使って、前述した仕様の感情反応を実装します
defmodule MiniAi do
def me(), do: %{ "word" => "えりこ", "class" => "名詞" }
def listen( message \\ "えりこは圧倒的に美しい" ) do
case message do
"" -> ""
nil -> ""
_ ->
relation = Relation.get( message )
verb_id = List.first( relation )
noun_ids = Relation.list_level( relation, 1, 0, [] )
subject_ids = Relation.list_follow( relation, noun_ids |> List.last, false, [] )
syntax = Cabocha.parse( message )
verb = get_multi_words( [ verb_id ], syntax, "" )
subjects = get_multi_words( subject_ids, syntax, "" )
abuse = any( Emotion.abuse, syntax, verb_id, "" )
praise = any( Emotion.praise, syntax, verb_id, "" )
score = case abuse == "" do
true ->
case praise == "" do
true -> 0
false ->
item = Enum.find( Emotion.praise, &( &1[ "word" ] == praise ) )
item[ "score" ]
end
false ->
item = Enum.find( Emotion.abuse, &( &1[ "word" ] == abuse ) )
item[ "score" ]
end
is_me = case score do
0 -> ""
_ -> any_ids( noun_ids, syntax, me(), "" )
end
case is_me == "" do
true -> "#{subjects}#{verb}んですね?"
false -> impressions( score )
end
end
end
def impressions( score ) do
case score > 0 do
true -> Enum.random( Emotion.thanks )
false -> Enum.random( Emotion.sad )
end
end
def any_ids( [ head_id | tail_ids ], syntax, match, word ) do
new_word = any( [ match ], syntax, head_id, "" )
case new_word == "" do
true -> any_ids( tail_ids, syntax, match, "" )
false -> new_word
end
end
def any_ids( [], _syntax, _match, word ), do: word
def any( [ head_match | tail_matchs ], syntax, target_id, word ) do
new_word = Cabocha.any_tok( syntax, target_id, head_match, word )
case new_word == "" do
true -> any( tail_matchs, syntax, target_id, new_word )
false -> new_word
end
end
def any( [], _syntax, _target_id, word ), do: word
…
試してみましょう
別の褒め言葉もいってみましょう
なんか人間臭くなりましたね
けなし言葉もいってみよー
なんか急に現実に引き戻された感じが...
半永続感情の作り込み①:感情状態の保持
半永続的な感情・印象である「好」←→「厭」は、以下3つのパートで構成します
- 現在の感情状態(「好」←→「厭」)を保持する
- 現在の感情状態に応じて、リアクションのパターンを変える
- 発言の「喜」←→「哀」の結果を感情状態に反映する
まずElixirは、オブジェクト指向言語と異なり、オブジェクトやオブジェクト内の状態を保持するという概念がありません(マクロを使って、見た目だけそれっぽくすることは可能ですけど)
そのため、Elixirでは、「プロセス」に状態を保持させ、プロセスとの通信で状態の取得/更新を行うことにより解決します
今回は、幾つか種類があるプロセスの中でも、「Agent」というものを使って、「好」←→「厭」の状態を保持するFeelingモジュールを作ってみます
defmodule Feeling do
def id(), do: :feeling
def init( like \\ 0 ) do
search_pid = :global.whereis_name( id() )
{ :ok, pid } = case search_pid do
:undefined -> Agent.start_link( fn -> like end )
_ ->
Agent.stop( search_pid )
:global.unregister_name( id() )
Agent.start_link( fn -> like end )
end
:global.register_name( id(), pid )
pid
end
def attach() do
search_pid = :global.whereis_name( id() )
pid = case search_pid do
:undefined -> init()
_ -> search_pid
end
pid
end
def current(), do: Agent.get( attach(), fn( score ) -> score end )
def affected( score ) do
raw = current() + score
new = case raw > 2 do
true -> 2
false -> case raw < -2 do
true -> -2
false -> raw
end
end
Agent.update( attach(), fn( _dummy ) -> new end )
end
end
案外、カンタンに作れることを実感していただけるのでは無いでしょうか?
実際、オブジェクトを作るのとそれほど変わらない感覚で、プロセスは気軽に使えます
これは、「開発の手間」という点だけで無く、「プロセス生成にかかる負荷が非常に低い」という点でも気軽です
1つのマシン上に、数千万のプロセスを起動し、マシンが逼迫せず動作する、なんて芸当も可能です
半永続感情の作り込み②:リアクションのパターンを変える
次に、「好」←→「厭」の状態に応じて、リアクションのパターンを変えるところですが、ここもシンプルに、文章の前後に、気分を表す言葉を追加するだけで対応します
defmodule Emotion do
def feel_pre do
[
"(言葉には出さず:",
"ふーん",
"",
"うわー、",
"アァーン、",
]
end
def feel_post do
[
")",
"...",
"",
"(๑´ڡ`๑)",
"(TOT)",
]
end
…
defmodule MiniAi do
…
def impressions( score ) do
Enum.at( Emotion.feel_pre, Feeling.current() + 2 )
<> case score > 0 do
true -> Enum.random( Emotion.thanks )
false -> Enum.random( Emotion.sad )
end
<> Enum.at( Emotion.feel_post, Feeling.current() + 2 )
end
…
半永続感情の作り込み③:感情状態に反映
「喜」←→「哀」のスコアを「好」←→「厭」に反映します
defmodule MiniAi do
def me(), do: %{ "word" => "えりこ", "class" => "名詞" }
def listen( message \\ "えりこは圧倒的に美しい" ) do
case message do
…(本関数の最終行付近まで飛ぶ)…
case is_me == "" do
true -> "#{subjects}#{verb}んですね?"
false ->
Feeling.affected( score )
impressions( score )
end
end
end
…
最後に、感情状態を可視化します
<p>あなた「<%= @params[ "message" ] %>」</p>
<p>えりこ「<%= MiniAi.listen( @params[ "message" ] ) %>」</p>
<form method="GET" action="/">
<input type="text" name="message" size="60" value="">
<input type="submit" value="話しかける">
</form>
<hr>
<h3>感情状態</h3>
<p>好意度(好5~厭1):<%= Feeling.current() %></p>
<hr>
<h3>CaboCha解析結果</h3>
<p><pre><%= Cabocha.tree( @params[ "message" ] ) %></pre></p>
<p><pre><%= Cabocha.view( @params[ "message" ] ) %></pre></p>
<p><pre><%= inspect( Cabocha.parse( @params[ "message" ] ) ) %></pre></p>
<p><pre><%= inspect( Relation.get( @params[ "message" ] ) ) %></pre></p>
さぁ、できました!
それでは、えりことの会話をお楽しみください~
パターンが少ないので単純ではありますが、雰囲気は出てきたんじゃ無いでしょうか?
ここに、人間が飽きない位の語彙や反応のパターンを加えると、そこそこ楽しめるようになるんですが、たとえば、SlackやTwitterの会話から自動的にスクレイピングして辞書追加させたりすると、かなり充実してきて、予想しない反応も含まれていき、かなり楽しくなってきます
一方、感情状態の方も、序盤で除外した分を、あと2~3種類くらい実装すると、とても複雑な感情の動きをエミュレートするようになってきますので、ご興味あればチャレンジしてみてください
さて、このシリーズ(1stシーズン?)の最終回である次回は、くだけた会話もそこそこ理解できるようにするための工夫とか、えりこが知らない言葉を言われたときにWikipediaからパクって引用するようなロジックを考えてみたいと思います
余談
今回のコーディングが楽しめた方は、「her/世界でひとつの彼女」とかオススメかも知れません
サマンサ、という女性型AIと、人間男性であるセオドアの恋愛ストーリーなのですが、恋愛要素だけで無く、中盤以降のセオドアの理想をサマンサが協力して実現していく過程が、地味にいい感じです(その後、悲劇的な別れに繋がるプロローグではあるんですけどね)