二分木をどう作るか

基本的にKNPの構文解析に依存した形で作る。前は、KNPだけではなく接続詞や助詞などを考慮して二分木の構造を作っていたが、基本、KNPをベースにしその上で、他を顧慮することにする。

KNPは、係り受け解析なので、それをどう使うかである。

KNPは、文節、タグ、そして形態素解析の個々の要素という構造を持っている。文節とタグはそれぞれ独立した係の番号を持っている。ただし、相互に矛盾しているわけではない。その上で基本、文節の係り受けをもとにする。

ある文節mから開始するとしよう。再帰構造を前提にする。mのかかり先がm+1だった場合、リストにm+1を加え、次を調べる。次ではなくて、mの係先がm+kだった場合、それまでの文節リストとかかり先をセットにして保存する。次にm+1を調べる。

このm+1から順に調べて行って、その、最終的なかかり先がm+kよりも大きくなることはあり得る。しかし、最終的なかかり先が二つになることはない、

ということて、まず、mがm+kにかかっている場合。mまでを部分知識として登録する。m+1以降もそれを繰り返して、m+kまでの部分知識が、すべて確定したら、m+kからの分とそれにかかっているすべての部分知識を含めて、一つの部分知識とする。

このアルゴリズムでなんとかなりそうだが。

ただ、どこまで、KNPが、文法構造を正しく捉えているかがポイントだ。

Wikipedia Prologの再構成とデータベース登録

5月ごろに、それまでのwikipedia prologのフォーマット上の問題が見つかって、その訂正を一旦保留していたが、先週から数日かけて、全て作り直した。

prolog宣言文の数は、17,843,495個あり544個のファイルに分割されている。データサイズは合計11.3Gバイトだ。

前にも書いたが、たとえば、いくつかのキーワードを持った宣言文をprologで拾い出そうとすると、一つのファイルの1秒かかる。使えない。そこで、一つ一つの宣言文が含むキーワード(名詞や動詞句など、二分木の葉になっているもの)を宣言文のIDとともにデータベース(mariadb)に登録した。そうすると、intersectコマンドを使って、キーワードを同時に持つ宣言文を拾い出せる。

マルチスレッドを利用することを前提に、mariadbのテーブルを25個に分けた。25個のスレッド(java)で、たとえば人間とロボットというキーワードを持つ宣言文を検索させると1秒以下で、すべてのwikipedia本文から、700余の文章(宣言文のID)を拾い出す。凄まじい速さだ。データベースの凄さだ。(indexを張っておかないといけない)

同時に持った宣言文のIDを拾いだしたら、その宣言文自体が欲しい。そこで、宣言文IDと宣言文そのものを、これまた25個のテーブルにデータベース化した。

そこで、人間とロボットというキーワードを持つ宣言文IDを検索し、それを元に宣言文そのものを取り出すためにかかった時間は、1.7秒だ。宣言文IDをつかって、ベタのファイルからその宣言文を取り出そうとすると、これほど高速にはならない。1.7秒は使える範囲だ。

だいぶ環境は整ってきた。

 

Prolog化したWikipediaファイルのjavaによる検索時間

prologのfact文にしたwikipediaファイルを検索するためのjavaプログラムを作成した。prologによる開発効率が悪いので、あっさりjavaで、という狙いだった。プログラム自体は簡単にできた。ただ、じっさいそれで、「ロボット」と「模型」といった、2語を同時に含む文章を検索させると、1ファイルあたり7.8秒かかる。wikipedia本文の全部を544ファイルにしてあるうちの1ファイルにこれくらいの時間がかかるということだ。

prologにしてあるからで、元々のテキストから検索すれば、もっと早いのではないかと思われるかもしれない。単純な検索ならば、もっと早くなる可能性がないとは言えないが、prologのfact化してあるものは、そこにjuman++によるカテゴリ、品詞データ、ドメインなどの形態素解析やknpによる構文解析結果がはめ込まれているので、それらを含めれば比べ物にならない。つまり、元々のテキストに、こうした自然言語解析をやりながら、データの検索をかければ、もっと時間がかかってしまうだろうということだ。

もちろん、googleなどは、もっと上手いことをやっているんだろうが、それほどの環境やシステム、能力を持っていない。

となると、prolog化したwikipediaは、キホ的なところは、prologで検索しなければならないと思われる。

swiprologは、プログラム/データをバイナリ化して保持するので、自分の思う通りの検索をやらせれば、もっともっと速かった。

prologというもののすごさがわかる。

Prologはサーバーに徹する

2ヶ月、空白があった。理由は、戦略的な行き詰まりがあったからだ。煮詰まったと言ってもいい。原因は、Prologで全てをやろうとしたからだった。Prologは、開発効率がひどく悪い言語だと言うことを思い知った。その理由は、頭に描いたアルゴリズムのうち、JavaとかC++では簡単にやれることが、Prologは、超難しくなることだ。もちろん、一方で、Prologで非常にうまいこと処理できるものがある。個々を区別せず、やたらPrologでやろうとした。時間の浪費に消耗してしまった。

空白を置くことによって、冷静になれたと思う。空白期間中は、かつて熱心にやっていた電子書籍の作成システムを、汎用に作り直していた。(興味があれば、ネット書房のサイトをみてください)

話を元に戻すと、そういうわけで、今後はPrologは、言語的知識を表現することに徹する。それを取り出す上では、Prologでサーバーまでは組み立てる。与えられた要素を持った知識を取り出すサーバーをPrologで組み立てる。クライアントは、JavaでもC++でもなんでもいいだろう。プロトコルさえ決めておけば。

絶対アドレスと節の位置との相互変換

二分木の中のノードやリーフの位置は、lとrの系列で示している。が、語と語の距離をそのアドレスから計算するのがとてもややこしいプログラムになっていた。そこで、アドレスと語の位置を相互に交換できるようにした。

ベースにしたのは、次のような、二分木を平板に出力するプログラムだ。記録のために書いておく

print_top :-
    data(Tree),
    set_flag(location,0),
    print(Tree,[]).

print(Tree,Address):-
    atom(Tree),
    get_flag(location,No),
    format('~w(~w:~w), ',[Tree,No,Address]),
    asserta(address(Address,No)),
    No1 is No + 1,
    set_flag(location,No1).

print(Tree,Address):-
    node(N,L,R)=Tree,
    append(Address,[l],Address_l),
    append(Address,[r],Address_r),
    print(L,Address_l),print(N,Address),print(R,Address_r).

data(
node(h,
    node(d,
        node(b,
            a,
            c
        ),
        node(f,
            e,
            g
        )
    ),
    node(l,
        node(j,
            i,
            k
        ),
        node(n,
            m,
            o
        )
    )
)
).

実行すると次のようになる。

?- print_top.
a(0:[l,l,l]), b(1:[l,l]), c(2:[l,l,r]), d(3:[l]), e(4:[l,r,l]), f(5:[l,r]), g(6:[l,r,r]), h(7:[]), i(8:[r,l,l]), j(9:[r,l]), k(10:[r,l,r]), l(11:[r]), m(12:[r,r,l]), n(13:[r,r]), o(14:[r,r,r]), 
true .

assertaでprologの説も構成している。

prolog二分木知識によるシステム相互の応答

没頭していたというよりも、どうするか苦悶していたので前回書いた時から三週間近く経過してしまった。苦闘の理由は、システムを発展性のあるものにすることだった。

ようやく、基礎的なところで納得できるものになったので、何か書いておくことにした。自然言語解釈のところとprologによるsocket相互通信のシステム構築に手間かけた。

自然言語処理のところでは、大きなところは、質問文の解釈の基礎的なところをどのようにするか、設計上の問題に直面した。色々な知識はprolog二分木で表現されていて、その二分木がどのような構造になっているかを踏まえなければならない。二分木は、ノード値と二つのリーフ値の重層的連結になっている。ここに構文情報がはめ込まれてもいる。

ノード値は助詞を中心に付随する修飾語から成り立っていて、動詞や名詞はそこには全く入ってこない。したがって、色々な意味で有限である。限られている。質問文かどうかは、このノード値を解析すればわかる。主語的なものは、格助詞の「は」などがノード値にあれば、その左のリーフか、ないしは、左側のツリーのいずれかのリーフに主語があるはず。

ノード値からの距離が重要で、例えば、格助詞の場合は左側で一番近いところにある名詞が、その主要候補となるだろう。また、距離に、状況によって左の葉を優先するか、右の葉を優先するかの設定もする。この距離は、ノードやリーフの絶対アドレスから算出する。

一つの文章の二分木ツリーから、ノード値、名詞、動詞、修飾語、およびその絶対アドレスの全てを、それぞれリストにして、最初に一挙に取得してしまうという方法も取り入れた。これは別な形で二分木を分解してしまう方法でもある。こうすると、文章のほとんどがそのリスト軍の中に写像される。

こうすると文章の意味取得が容易になる。質問に対して、このリストを使って返信が可能になる。

通信については、javaを経由すればソケット通信は楽になるが、あくまで、prolog動詞て対話の通信ができるようにした。c++やjavaの時と同様に、ソケットとそのストリームを作成して、あとは自由に相互にやり取りをするというソケット通信の本来の形を実現するのに苦労した。ソケットを作って、一個の文章のやり取りをすることはすぐにできるが、ソケットを二つのシステムの対話の間じゅう維持するのが難しかったのだ。しかし、それもできた。

これから、自由な問いかけに、自らの知識(prolog二分木)に基づいて、自由に返答できるシステムにしていこう。形としては、あの巨大なwikipediaの知識を利用できる形にしてあるのだから。

アドレスという考え方: prolog二分木

自然言語のprolog二分木の中をもっと自由に移動する方法がないかと考えてきたが、アドレスを入れることにした。

二分木はルートを持っている。このルートが原点となる。linuxなどのディレクトリ構造のルートと同じである。ただ、二分木であるから、分岐は全てバイナリである。

そこで、ルートから出発して、左の葉をたどる時には'l'(エル)を加え、右をたどる時には'r'を加えたprologリストを作成することによって、唯一のアドレスを与えることができる。

仮想的な二分木を考える。

node(a,
    node(b,
        node(c,
            d,
            e
        ),
        node(f,
            g,
            h
        )
    ),
    node(i,
        node(j,
            k,
            l
        ),
        node(m,
            n,
            o
        )
    )
)

ルートの値は、aである。左右対称のツリーになっている。これに対し、アドレス[l,r,l]は、値gとなる。あるいは、[l,r]にあるのはサブツリーで、node(f, g, h)である。同じく、[r,l]にあるのは、node(j, k, l)となる。

このように、そのアドレスの部分ツリーを取ってくるプログラムは次のようになる。

%% 現在位置(ツリーアドレス)からの部分ツリーを返す
getsubtree(Tree,[],Tree).  %% 位置が空だったら、そのものを返す
getsubtree([],_,[]).
getsubtree(node(_,L,R),[H|T],Out) :- 
        (T=[] -> 
            (H=l -> Out=L; Out=R)
        ;   (H=l -> getsubtree(L,T,Out)
            ;getsubtree(R,T,Out),
            true)
        ).

名詞の主語としての重要性ウェイト:prolog二分木

二分木には、juman++による形態素解析の結果が組み込まれている。主語を捉える上で、可能性として色々ある場合、扱う順位、ウェイトのようなものが欲しい。たとえば、「アトムと言われているものはなんですか」というのを、二分木にすると、

%% line = アトムと言われているものはなんですか
%% phrases: [ 0 1 2 r3 ] 
testdoc(testline_0_0,
    node(ですか,
        node(は,
            node([],
                node(と,
                    [アトム, 'S:普/C:自然物/D:科学・技術'],
                    [[[言わ, 'V:言う'], れて], いる]
                ),
                [もの, 'S:形']
            ),
            [なん, 'S:数/C:数量']
        ),
        [ ]
    )
).

となる。図で描くと、

となる。「は」という格助詞の前に、名詞は三つある。「なん」が数詞になっているのが少しおかしい(juman++の仕様)だが、他に、「アトム」と「もの」もある。「なんですか」というのも、「ものはなんですか」も日本語としては単独で成立する。しかし、ここでは主語は「アトム」であるべきだ。

となると、名詞の主語になる優先順位というのが必要になるだろう。名詞の種類は、juman++では、次のようになっている(http://www.unixuser.org/~euske/doc/postag/)。

普通名詞 (例)「つくね焼」「鞭打ち症」「パイ中間子」サ変名詞以外のもの。
副詞的名詞 (例)「ところ」「ため」「ぐらい」「~したところ」「~するため」
形式名詞 (例)「の」「こと」「もの」「つもり」「わけ」	
固有名詞 (例)「エスキモー」「広辞苑」「平成」以下の 3カテゴリにあてはまらない固有名詞。
組織名 (例)「NATO」「そごう」「運輸省」	
地名 (例)「東京」
人名 (例)「田中」
サ変名詞 (例)「説明」「あんよ」「埋め合わせ」「発想」「~する」の形をとれるもの。
数詞 (例)「ゼロ」「億」 数値。
時相名詞 (例)「あした」「ほどんど」「それぞれ」

順位をつけると、(1)人名(2)組織名(3)地名(4)固有名詞(5)普通名詞(6)サ変名詞(7)形式名詞(8)数詞(9)時相名詞(10)副詞的名詞、となるのではないか。この優先順位で、文章の主語をとらえることにしよう。

自然言語二分木を平文に変換するprologプログラム

書き忘れていたが、この間、以前も使っていた二分木を平文にするプログラムを少し改良している。AIサーバー、AIクライアントで使っているので掲載しておく。

%%
%% prologツリーをベタな文章に変換して表示する
%% 全て、変数に出力されるように改訂
%%

wsprint(Sent,Out) :- 
    nb_setval(console,''),
    printnode(Sent),
    nb_getval(console,Out).
    %% write(Out),nl.

%% グローバル変数 console に出力をつなげていく
wsappend(S) :-
    nb_getval(console,Tmp),
    atom_concat(Tmp,S,Out),
    nb_setval(console,Out).

%% -----------------------
%%% 出力のclauses
%% -----------------------
% 対象がatomならば、そのまま表示
printnode(N) :- atom(N),wsappend(N).
%% 対象が空でないリストならば、最初の項の表示
printnode(N) :- [_|_] = N,showlist(N). 
%% 対象が空リストならば'/'(半角スラッシュの表示)
%% printnode(N) :- [] = N,wsappend('/'). %% 空リストでもtrueにする
%% スラッシュやめる
printnode([]).
%% 対象が項ならば、元の言葉の順序で表示(語がnodeならば再帰的に表示する
%% ただし、node語がnodeは含まない)
printnode(N) :- node(X,Y,Z) = N,
        printnode(Y),
        printnode(X),
        printnode(Z).

%% -----------------------
%% getlistは、リストが[語, カテゴリ]から構成されているのから、語だけのリストを作る
%% 一つのフレーズに複数の語があると
%% [[[語, カテゴリ],語],[語, カテゴリ]] などのように繋がってリスト化される
%% knpがカテゴリを出力しない場合は、語が単独になることもある
%% HeadとTailをから、それぞれの語を取り出して、結合したのを出力
%% -----------------------
getlist([H|[T]],[X1, X2]) :- getlist(H,X1),
        getlist(T,X2),!.
%% 構造的に、Tailには、単位リストしか入っていない
getlist([H|[T]],[H,H1]) :- atom(H),[H1|_] = T,!.
%% tailがリストでない場合は、atomであるHeadのチェック
getlist([H|[_]],H) :- atom(H).
%% tailが構造化されたリストの場合にはここで処理する
getlist([H|[T]],[Z,T]) :- atom(T),
        getlist(H,Z).

%% -----------------------
%% ベタなリストに変換するのがprintlist
%% swi-prologにflattenという組み込み関数がある
%% -----------------------
printlist(L) :- atom(L),
        wsappend(L).
%% ベタなリスト化は、以下のclauseで単純に作れる
printlist(L) :- [H|[T]] = L,
        printlist(H),
        printlist(T),!.

%% -----------------------
%%  getlistとprintlistを繋げるのがshowlist
%% -----------------------
showlist(L) :- getlist(L,X),
        printlist(X).

実行例を示しておく。prolog二分木化したwikipediaからとってきているかなり複雑な二分木がだ、綺麗に日本語の平文に直している。

?- ['../lib/wsprint.swi'].                                                                              true.

?- wsprint(node(は,[['デビュー', 'S:サ/C:抽象物/D:メディア'], 前],node(で,[[紫苑, 'S:普/C:植物'], [名義, 'S:普/C:抽象物']],node(と,[[杏樹, 'S:人'], [紫苑, 'S:普/C:植物']],node([],[いう, 'V:いう'],node(で,[コ ンビ, 'S:普/C:組織・団体'],node(の,[[耽美, 'S:普/C:抽象物'], [系, 'S:普/C:抽象物']],node([],[漫画, 'S:普/C:人工物-その他;抽象物/D:文化・芸術'],node(を,node(の,[],[パロディ, 'S:普']),node([],[[描いて, 'V:描く'], おり],node([],[[要, 'S:普/C:人工物-その他;抽象物'], [出典, 'S:普/C:抽象物/D:文化・芸術']],node(の,[[ 江口, 'S:人'], [寿史, 'S:人']],node([],[単行本, 'S:普/C:人工物-その他;抽象物/D:文化・芸術'],node(の,[[江口, 'S:地'], [寿史, 'S:人']],node(で,なんとかなる,node([],[ショ, 'S:サ'],node(の,[[江口, 'S:地'], [寿史, 'S:人']],node(に,[[[爆発, 'S:サ/C:抽象物'], ['ディナー', 'S:普/C:人工物-食べ物/D:料理・食事']], ['ショ ー', 'S:普/C:抽象物/D:文化・芸術']],node([],[[[[収録, 'S:サ/C:抽象物/D:文化・芸術;メディア'], [さ, 'V:する']], れて], いる],[ ])))))))))))))))))),Out).

Out = デビュー前は紫苑名義で杏樹紫苑というコンビで耽美系の漫画のパロディを描いており要出典江口寿史の単行本江口寿史のなんとかなるでショ江口寿史の爆発ディナーショーに収録されている .


AIサーバーとAIクライアント:prolog二分木

先の記事では、クライアント側がjavaだったが、クライアント側もprologにした。クライアントプログラムは、先の平文を二分木にするクライアントを少し変えたものだ。

%
% prologのテレパシーサーバーに質問し、回答を得るクライアント
%

% 文字列とutf8のバイトコードを相互に変換する
% http://www.ibot.co.jp/wpibot/?p=2681 など参照
:- ['../lib/utf8string.swi'].

% swi-prologモジュールの組み込み
:- use_module(library(streampool)).

% クライアントをスタートさせて、ストリームを取得、グローバル変数に保存する
telepathy_client(Host, Port) :-
        setup_call_catcher_cleanup(tcp_socket(Socket),
            tcp_connect(Socket, Host:Port),
            exception(_),
            tcp_close_socket(Socket)),
        setup_call_cleanup(tcp_open_socket(Socket, In, Out),
            nb_setval(socketIn,In),
            nb_setval(socketOut,Out)).

telepathy_to_server(Term,Reply) :-
        % 送信文字列をコードに変換する
        utf8tring(Bytes,Term),
        nb_getval(socketIn,In),
        nb_getval(socketOut,Out),
        % コマンをつけて、サーバーに送信
        % format(Out, '~w:~s~n', [Com,Bytes]),
        % 当面コマンドをつけない
        format(Out, '~s~n', [Bytes]),
        flush_output(Out),
        %read(In, ReplyCode), % これではうまくいかない
        % サーバーからコードを受信する
        read_line_to_codes(In, ReplyCode),
        % write(ReplyCode),nl,
        % コードを文字列に変換
        utf8tring(ReplyCode,Reply).
        % 表示する
        %format('Reply: ~s~n', [Reply]).

% ストリームを閉じる
telepathy_close :-
        nb_getval(socketIn,In),
        nb_getval(socketOut,Out),
        close(In, [force(true)]),
        close(Out, [force(true)]).

サーバー側は、前の記事と同じで変更ない。実行結果は以下のようである。

まず、クライアント側。

?- ['telepathy_client.swi'].
true.

?- telepathy_client(localhost,30000).
true.

?- telepathy_to_server(アトムとはなんですか,Reply).
Reply = アトムはロボットです.

続いて、サーバー側。

?- ['telepathy_server.swi'].
true.

?- server(30000).
Receive = > アトムとはなんですか 
Send = > アトムはロボットです

これからは、知識が多い場合の処理はもう少し後にして、単純な知識が様々に利用される状況を想定してみる。