LoginSignup
22
8

More than 5 years have passed since last update.

Elixirで弱々しいAI#4「文脈から意味を読み取る」

Last updated at Posted at 2017-05-07

5/8追記→【業務連絡w】altさん、コードの色付け、ありがとうございますm(__)m

(この記事は Elixir (その2)とPhoenix Advent Calendar 2016 9日目の記事です)

前回は、CaboChaで文章構成を解析するための準備を行いました

今回は、いよいよAIらしいロジックである、意味解析...つまり、文脈から意味を読み取ってアクションする(といってもカンタンなものですが)を作り込んでいきます

なお、本コラム中の「Elixirの書き方」については、あまり細かく説明をしていないので、「ここの書き方が分からない」とか「この処理が何をしているのかよく分からない」等あれば、コメントいただければ、回答します :headphones:

特に今回は、データ変換の書き方が、けっこう難しい領域に入っていきます(&安易にcaseで書いてしまった(-_-u...)ので、遠慮無くご質問いただければと思います(空いているAdvent Calenderのコマでお答えしたり、を考えています) :peace:

CaboChaの解析結果をもう1段、加工する

前回の最後に、「xml_parser」モジュールで、CaboChaの解析結果をElixirで処理しやすいよう変換しましたが、もう1段、処理しやすくなるよう加工します

CaboCha解析結果は、「chunk」1つで、ひと塊のフレーズとなっており、「chunk」配下には、単語である「toks」とその単語の品詞が並びますが、品詞がカンマ区切りの文字列のため、扱いにくいので、マップ化します(ついでに使わない構造や値も削除して、データをスッキリさせます)

lib/cabocha.ex
defmodule Cabocha do
    def parse( body \\ "10年後、ブラックホールの謎を応用した、重力のプログラミングが可能となる" ) do
        case body == "" || body == nil do
            true  -> ""
            false ->
                body
                |> xml
                |> XmlParser.parse
                |> chunks
                |> chunk_map( [] )
        end
    end

    def chunks( { :sentence, nil, chunks } ), do: chunks

    def chunk_map( [ { :chunk, %{ id: id, link: link, score: score }, toks } | tail ], chunk_list ) do
        chunk_map( tail, 
            chunk_list ++ [ %{ "chunk" => %{ "id" => id, "link" => link, "score" => score }, 
            "toks" => toks_map( toks, [] ) } ] )
    end
    def chunk_map( [], chunk_list ), do: chunk_list

    def toks_map( [ { :tok, %{ feature: feature, id: id }, word } | tail ], tok_list ) do
        toks_map( tail, 
            tok_list ++ [ %{ "id" => id, "word" => word, "feature" => feature_map( feature ) } ] )
    end
    def toks_map( [], tok_list ), do: tok_list

    def feature_map( feature ) do
        Regex.named_captures( ~r/
            ^
            (?<part_of_speech>[^,]*),
            \*?(?<part_of_speech_subcategory1>[^,]*),
            \*?(?<part_of_speech_subcategory2>[^,]*),
            \*?(?<part_of_speech_subcategory3>[^,]*),
            \*?(?<conjugation_form>[^,]*),
            \*?(?<conjugation>[^,]*),
            (?<lexical_form>[^,]*),
            (?<yomi>[^,]*),
            (?<pronunciation>.*)
            $
            /x, feature )
    end
 …

この変換で、chunk配下の品詞も「feature」配下にマップ化し、更にパターンマッチしやすくしました

iex> recompile()
iex> Cabocha.parse
[%{"chunk" => %{"id" => "0", "link" => "6", "score" => "-1.514907"},
   "toks" => [
    %{"feature" => %{"conjugation" => "", "conjugation_form" => "",
        "lexical_form" => "1", "part_of_speech" => "名詞",
        "part_of_speech_subcategory1" => "数",
        "part_of_speech_subcategory2" => "",
        "part_of_speech_subcategory3" => "", "pronunciation" => "イチ",
        "yomi" => "イチ"}, "id" => "0", "word" => "1"},
    %{"feature" => %{"conjugation" => "", "conjugation_form" => "",
        "lexical_form" => "0", "part_of_speech" => "名詞",
        "part_of_speech_subcategory1" => "数",
        "part_of_speech_subcategory2" => "",
        "part_of_speech_subcategory3" => "", "pronunciation" => "ゼロ",
        "yomi" => "ゼロ"}, "id" => "1", "word" => "0"},
    %{"feature" => %{"conjugation" => "", "conjugation_form" => "",
        "lexical_form" => "年", "part_of_speech" => "名詞",
        "part_of_speech_subcategory1" => "接尾",
        "part_of_speech_subcategory2" => "助数詞",
        "part_of_speech_subcategory3" => "", "pronunciation" => "ネン",
        "yomi" => "ネン"}, "id" => "2", "word" => "年"},
 …

文章のツリー構造を解析する

CaboCha解析結果は、「chunk」間の上下関係が、文章の構造となっており、各Chunkの「link」が別のchunkの「id」を指すようなツリー構造をしています

これを抽出するため、Relationというモジュールを追加します

lib/relation.ex
defmodule Relation do
    def get( body \\ "10年後、ブラックホールの謎を応用して、重力のプログラミングが可能となる" ) do
        case body == "" || body == nil do
            true  -> []
            false ->
                parsed = Cabocha.parse( body )
                [ terminate_id ] = terminate( parsed, [] )
                ids = nodes( [ terminate_id ], parsed, parsed, [] )
                [ terminate_id, ids ]
        end
    end

    def nodes( terminate_id, [ %{ "chunk" => %{ "id" => id, "link" => link } } | tail ], body, node_ids ) do
        concat_node_ids = case terminate_id == [ link ] do
            true  -> 
                inner_ids = case link == "-1" do
                    true   -> []
                    false  -> 
                        [ ids ] = Enum.map( [ id ], &( nodes( [ &1 ], body, body, [] ) ) )
                        ids
                end
                case inner_ids == [] do
                    true  -> node_ids ++ [ id ]
                    false -> node_ids ++ [ id, inner_ids ]
                end
            false -> node_ids
        end
        nodes( terminate_id, tail, body, concat_node_ids )
    end
    def nodes( _terminate_id, [], _body, node_ids ), do: node_ids

    def terminate( [ %{ "chunk" => %{ "id" => id, "link" => "-1" } } | tail ], _terminate_id ) do
        terminate( tail, [ id ] )
    end
    def terminate( [ _ | tail ], terminate_id ) do
        terminate( tail, terminate_id )
    end
    def terminate( [], terminate_id ), do: terminate_id
end

ビルドして動かすと、各chunkのツリー構造が、入れ子リストで表示されます

iex> recompile()
iex> Relation.get
["6", ["0", "3", ["2", ["1"]], "5", ["4"]]]

ちなみに、inspect()を使うと、Phoenixでも表示できるみたいなので、CaboCha解析結果の「id」と「link」の関係が表現されていることを確認してください

lib/web_mini_ai_web/templates/page/index.html.eex
<p>あなた「<%= @params[ "message" ] %></p>
<p>貧弱AI「<%= MiniAi.listen( @params[ "message" ] ) %></p>

<form method="GET" action="/">
<input type="text" name="message" size="60" value="">
<input type="submit" value="話しかける">
</form>

<hr>
<h3>CaboCha解析結果</h3>
<p><pre><%= Cabocha.view( @params[ "message" ] ) %></pre></p>
<p><pre><%= inspect( Relation.get( @params[ "message" ] ) ) %></pre></p>

image.png

ここまでで、文章の構造と、各フレーズ内の単語とその品詞が分解できたので、文章の意味を解析できるようになりました :laughing:

意味を解釈する(概論および要件定義編)

たとえば、iPhoneのSiriや、AndroidのGoogleNowのようなライトな人工無能は、スマホに「命令」口調で指示することで、天気を調べたり、Web検索したりができますが、これは言われた文章の意味の解釈として、「述語のアクション+名詞が述語の対象」という感じで、意味解釈し、設定済のアクションを実行します

「OK、Google。昨日の天気を教えて。」と言うと、Androidは、「天気を調べる」という設定済みアクションを、「昨日」という対象で行います(文章の間にある「~の~」や「~を~」、もしくは「~教えて」は、無視してたりします :tongue:

今回、開発しているAIは、より対話を意識した設計として、「命令」口調による意味解釈としてのアクションよりも、「ユーザがただボヤいた」ことに対し、リアクションするような設計を目指してみましょう

その1つの実装として、これは「コーチング」ないしは「カウンセリング」のテクニックになりますが、「相手がやろうとしていること(≒述語)を理解し、相手自身により深く考えてもらう」ために、「相手のやろうとしていることをそのまま質問する」、をやってみます

意味を解釈する(基本設計編)

「10年後、ブラックホールの謎を応用して、重力のプログラミングが可能となる」という文章は、述語である「可能となる」(・・・CaboCha解析結果のid="6")に対し、以下3つが係るツリー構造となっています

 ①「10年後」・・・CaboCha解析結果のid="0"
 ②「ブラックホールの謎を応用して」・・・CaboCha解析結果のid="3"←"2"←"1"
 ③「重力のプログラミングが」・・・CaboCha解析結果のid="5"←"4"

これを質問形式にすると、こんな感じです

 ①「10年後、可能になるんですね?」
 ②「ブラックホールの謎を応用して、可能になるんですね?」
 ③「重力のプログラミングが、可能になるんですね?」

なんかどれも、それっぽい返事に聞こえますね? :relaxed:

どれか1つを選ぶのは、悩ましいところですが、独断と偏見で③でいきます

実はこのテクニック、明石家さんまがトークするときのテクニックであり、異性にモテたり、セールスで相手から本音を引き出すためのテクニックでもあったりしますので、あまり異性ウケしないなぁとか相手にうまく売り込めないなぁと悩んでいる方は、意識されると良いかも知れません :thumbsup:

意味を解釈する(詳細設計編)

この文章のツリー構造は、終端の動詞句である"6"をトップとし、その直下の名詞句の先頭である"0"、"3"、"5"が並ぶ構成なので、最後の名詞句である"5"を選び、"5"配下のフレーズと、"6"のフレーズ、質問である「んですね?」を接げば完成です

image.png

本来、動詞句や名詞句は、各フレーズ内の単語の品詞を解析し、マッチしなければ次の候補をチェックしますが、CaboChaの解析結果は、既にこれを意識した解析結果となっているため、「常にリスト先頭にある動詞句」="6"と、「その直下の最後の名詞句」="5"配下の各フレーズを抽出すればOKです

意味を解釈する(実装編①:ユーティリティの実装)

まずは、idを指定すると、そのフレーズを返す関数が必要です

lib/cabocha.ex
defmodule Cabocha do
    def get_words( [ %{ "chunk" => %{ "id" => id }, "toks" => toks } | tail ], target_id, words ) do
        new_words = case id == target_id do
            true  -> concat_toks( toks, "" )
            false -> words
        end
        get_words( tail, target_id, new_words )
    end
    def get_words( [], _target_id, words ), do: words

    def concat_toks( [ %{ "word" => word } | tail ], words ) do
        concat_toks( tail, words <> word )
    end
    def concat_toks( [], words ), do: words
 …

次に、直下の名詞句の先頭をリストアップするために、ツリーの任意の階層をリストアップする関数を作ります

また、名詞句の階層を全て取得してくるために、任意のid配下をリストアップする関数も作ります

lib/relation.ex
defmodule Relation do
    def list_level( [ head | tail ], target_level, current_level, items ) do
        new_items = case is_list( head ) do
            true  -> 
                list_level( head, target_level, current_level + 1, items )
            false -> 
                case target_level == current_level do
                    true  -> items ++ [ head ]
                    false -> items
                end
        end
        list_level( tail, target_level, current_level, new_items )
    end
    def list_level( [], _target_level, _current_level, items ), do: items

    def list_follow( [ head | tail ], target_id, is_following, items ) do
        new_items = case is_list( head ) do
            true  -> 
                list_follow( head, target_id, is_following, items )
            false -> 
                case head == target_id || is_following do
                    true  -> 
                        inner_items = items ++ [ head ]
                        case is_list( List.first( tail ) ) do
                            true -> 
                                next_target_id = List.first( tail ) |> List.first
                                list_follow( tail, next_target_id, true, inner_items )
                            false -> 
                                inner_items
                        end
                    false -> items
                end
        end
        case is_following do
            false -> list_follow( tail, target_id, is_following, new_items )
            true  -> new_items
        end
    end
    def list_follow( [], _target_id, _is_following, items ), do: items
 …

意味を解釈する(実装編②:メイン処理の実装)

ここまで作った関数を組み合わせて、「常にリスト先頭にある動詞句」="6"と、「その直下の最後の名詞句」="5"配下の各フレーズを抽出するには、以下のようなコードを書けばOKです

lib/mini_ai.ex
defmodule MiniAi do
    def listen( message \\ "進撃の巨人はとても面白いです" ) do
        case message do
            ""  -> ""
            nil -> ""
            _   ->
                relation = Relation.get( message )
                verb_id = List.first( relation )
                subject_top_id = Relation.list_level( relation, 1, 0, [] ) |> List.last
                subject_ids = Relation.list_follow( relation, subject_top_id, false, [] )
                syntax = Cabocha.parse( message )
                verb  = get_multi_words( [ verb_id ],  syntax, "" )
                subjects = get_multi_words( subject_ids, syntax, "" )
                "#{subjects}#{verb}んですね?"
        end
    end
    def get_multi_words( [ head | tail ], syntax, words ) do
        word = Cabocha.get_words( syntax,  head, "" )
        get_multi_words( tail, syntax, word <> words )
    end
    def get_multi_words( [], _syntax, words ), do: words
 …

では、試してみましょう

image.png

うまく、いきました!! :kissing_closed_eyes:

他の文章だと、どうでしょう?

image.png

うん、いい感じですね

しかし、文章末尾が口語体とかになると...

image.png

まぁ、おかしな感じになりますが、ここの補正は、また今度の機会に

基本、品詞のパターンで削っていくことになりますが、if文の嵐になる上、精度がイマイチなので、機械学習やディープラーニングを使って除去できれば、と考えています

次回は、これまでの文章解析から少し方向性を変え、AIに感情のような「状態」を持たせ、その状態次第で返事を変えるようなロジックを作りましょう :sunrise:

22
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
8