はじめに
前回(「LIPS Schemeを使ってみました5(自然言語処理)」に引き続きLips使ってみましたの記事です。

今回は、日本語の係り受け関係をmermaidでダイアグラム表示するためのデータ作成に挑戦しました。
係り受けについてはいろいろなパターンがありますが、ここでは単純な例文を扱います。
格助詞をキーとした主語、述語、目的語だけのパターンです。
今回の投稿は係り受けデータを元にした作図データ(mermaidスクリプト)の生成処理に絞りました。
元になった係り受け解析プログラムは別の機会に投稿したいと思います。
環境
Lips:
これまでLipsの動作確認はREPLサイトをつかっていましたが、Lipsをインストールしてローカルでも実行できるようにしました。
手順はNode.jsをインストールした後、下記のコマンドを実行することでインストールできました。
npm install -g lips@beta
手順サイト : https://lips.js.org/docs/intro#nodejs
エディターはVSCodeを使っていますが、ターミナルで"lips -quiet"または"lips xxxx.scm"のようにコマンドをたたけば 動作確認できるのでお手軽です。
[!NOTE]
OSはWindowsを使っています。Windowsのcmdおよびpowrshellでは問題ありませんが
WSL(Ubuntu)でLipsを起動したとき入力プロンプトが出てきませんでした。原因は分かりません。
本稿のゴール
「花子は太郎に本を渡した」という文章の係り受け解析結果のデータを入力データとして
mermaidで下図のような係り受け関係図を表示するスクリプトを作成します。
処理毎に出力されるデータ構造(データ内容)
[形態素データ]->[係り受け構造データ]->[スクリプト]というデータフローで最終的なmermaidのグラフデータを生成します。
形態素データ
下のデータは「花子は太郎に本を渡した」という文例をkuromoji.jsに食べさせた結果の形態素データです。
ここでは必要な要素データだけに絞って掲載しています。
javascriptのobject配列になっています。
[
{ surface_form: '太郎', pos: '名詞' },
{ surface_form: 'は', pos: '助詞' },
{ surface_form: '花子', pos: '名詞' },
{ surface_form: 'に', pos: '助詞' },
{ surface_form: '本', pos: '名詞' },
{ surface_form: 'を', pos: '助詞' },
{ surface_form: '渡し', pos: '動詞' },
{ surface_form: 'た', pos: '助動詞' }
]
係り受け構造データ
下のデータはkuromojiの出力をベースに生成した係り受け構造データです。
lispのリスト形式になっています。
(渡し ( ;動詞の表記(surface)
(た ( ;助動詞の表記(surface)
(太郎 ((は ()))) ;名詞の表記(surface) 助詞の表記(pos)
(花子 ((に ()))) ;名詞の表記(surface) 助詞の表記(pos)
(本 ((を ())))) ;名詞の表記(surface) 助詞の表記(pos)
)
)
)
データ構造を品詞で表現すると下のようになります。
(動詞 (
(助動詞 (
(名詞 ((助詞 ()))) (名詞 ((助詞 ()))) (名詞 ((助詞 ())))
))
)
)
データ構造をkuromoji.jsの形態素解析データの表記で表現すると下のようになります。
(surface (
(surface (
(surface ((pos ()))) (surface ((pos ()))) (surface ((pos ())))
))
)
)
スクリプト
下のデータは係り受け構造データをベースに生成したmermaidスクリプトです。
mermaidのREPLサイト(Playground)で表示できます。
graph TD
node0[渡した]
node1[太郎]
node1 --> |は| node0
node2[花子]
node2 --> |に| node0
node3[本]
node3 --> |を| node0
処理概要
今回投稿するプログラムは下記フローの出力処理の部分です。
テストプログラムのHTML構造
下のHTMLは大枠的なプログラムの全体フローを示します。
そのままブラウザーに読み込むとdivタグにテスト文字列を表示します。
まずは全体フローを把握しておきましょう。スタート関数はgenerate-mermaid-testです。
generate-mermaid-testはshowDiagramのパラメータを出力します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/lips@beta/dist/lips.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
</head>
<body>
<div class="kakariUkeDiagram" id="kakariUkeDiagram"><!-- ここにmermaidスクリプトを入れます --></div>
<script>
/** Lispとの比較用mermaid出力関数 */
async function showDiagram_js(graphScript) {
console.log("showDiagram - " + graphScript);
const element = document.querySelector("#kakariUkeDiagram");
element.innerHTML = graphScript;
}
</script>
<script type="text/x-scheme" bootstrap>
;; mermaidスクリプト生成関数
;; data:係り受け構造データ
(define (generate-mermaid data)
(console.log "generate-mermaidt : " (car data))
(let (
(root (car data))
(lines "graph TD\nNode[何か]")
)
;;リスト要素を抽出、文字列化、結合してlinesを更新して返す
(set! lines (string-append lines " --> " (symbol->string root)))
lines
)
)
;; テストスタート関数
(define (generate-mermaid-test)
(console.log "generate-mermaid-test")
(let ((tree '(係り受け構造)))
;; 係り受け構造データをパラメータとしてmermaidスクリプト生成関数を呼ぶ
(generate-mermaid tree)
)
)
;; mermaid表示関数
;; graphScript: mermaidスクリプト
(define (showDiagram graphScript)
(console.log "showDiagram - " graphScript)
(let ((element (document.querySelector "#kakariUkeDiagram")))
;;kakariUkeDiagramタグのテキストを更新する
(set! element.innerHTML graphScript)
)
)
;; mermaid表示関数呼び出し
;(showDiagram_js (generate-mermaid-test)) ;javascriptの同等関数
(showDiagram (generate-mermaid-test))
</script>
</body>
mermaidの処理 (showDiagram)
mermaidの日本語ドキュメントサイトのサンプルを参考にしました。
https://docs.min87.com/ja/mermaid/config/usage.html
同サイトのJavaScriptのサンプルプログラムをLISP化しやすい形にしてLISPに翻訳しましたのでいつものように説明のかわりにJavaScriptとLipsコードの対比を示します。DOM制御なのでほぼ直訳ですね。
JavaScript
async function showDiagram_js(graphScript) {
mermaid.initialize({ startOnLoad: false });
const element = document.querySelector('#kakariUkeDiagram');
const { svg } = await mermaid.render('graphDiv', graphScript);
element.innerHTML = svg;
}
Lips
(define (showDiagram graphScript)
(mermaid.initialize `&(:startOnLoad #f))
(let* (
(element (document.querySelector "#kakariUkeDiagram"))
(render_svg (mermaid.render "graphDiv" graphScript))
)
(set! element.innerHTML render_svg.svg)
)
)
スクリプト生成処理 (generate-mermaid)
係り受け解析結果の構造データを受けてmermaidスクリプトを生成する処理です。
generate-mermaidは3つのサブ関数で構成しています。
- 各ノードラベル作成 : make-node
係り受け解析構造データから各係り受け元の語句を抽出して表示用ラベル文字列を作ります。 - トップノードラベル作成 : top-root
係り受け先語句(述語)の表示用ラベル文字列を作ります。 - ノードカウンター : node-counter
ラベルに付加するシーケンス番号を管理します。("node0", "node1"...)
では、以下で各関数の内容を見てみましょう。
top-root
この関数はmermaidスクリプトのトップに来るラインを作成します。
LipsのREPLサイトまたはコマンド(lips -q)でREPLを開いて下のコードを入力してください。
適当なファイル名(ex. test999.scm)で"lips test999.scm"のように実行してもよいです。その場合、関数の戻り値を表示するにはprint、display、またはconsole.log等の出力関数を明示的に使う必要があります。
(define (top-root root verb-node)
(let* (
(verb-root (car root)) ;係り受け先(トップ)の語句または述語
(verb-aux (caaar (cdr root))) ;格助詞
;; verb 動詞 + 助動詞
(verb (string-append (symbol->string verb-root) (symbol->string verb-aux)))
)
(string-append "graph TD\n" verb-node "[" verb "]")
)
)
次に下の例文を入力して試してみましょう。
(define s
'(渡し ((た ((太郎 ((は ()))) (花子 ((に ()))) (本 ((を ())))) )))
)
(top-root s "test")
次のような文字列が出力されます。
"graph TD
test[渡した]"
node-counter
ノードのラベルに番号をつけるためのカウンター制御関数です。
この関数カウント制御のクロージャを返して呼び出し毎にnode-counterをカウントアップします。
REPLを開いて下のコードを入力してください。
(define (node-counter)
(define node-counter 0) ; ラベルにつける番号
;;カウントアップ処理関数を返す
(lambda ()
(let ([id node-counter])
(set! node-counter (+ node-counter 1)) ; 番号をカウントアップする
(string-append "node" (number->string id)) ; idを文字列変換してnode0..nを返す
)
)
)
下記のコードを実行すると"node0"、"node1"がコンソール出力することを確認できます。
(let ((node-next (node-counter)))
(display (node-next)) (newline)
(display (node-next)) (newline)
)
make-node
述語に係る語句(係り受け元)の各ノードラベルを作る関数です。コア処理になります。
係り受け元の対象データは'((太郎 ((は ()))) (花子 ((に ()))) (本 ((を ()))))になります。
太郎、花子、本が含まれるリスト要素を抽出してループで回してmake-nodeに対象データを渡します。
まずはREPLで下のコードを入力してウォーミングアップしましょう。
係り受けデータのネストが深いので処理の対象データの渡される状況から見てください。
(define s
'(渡し ((た ((太郎 ((は ()))) (花子 ((に ()))) (本 ((を ())))) )))
)
(let ((children (cadaar (cdr s))))
(print children)
(for-each (lambda (child) (print child)) children)
)
まずは((太郎..) (..) (..))までの要素リストをchildrenに取り出して
for-eachで各子要素リストを取り出して処理すればよいですね。
(太郎 ((は ())))
(花子 ((に ())))
(本 ((を ())))
では、上述のmake-nodeの関数定義と下のコードをREPLに入力して動作を確認してみてください。
;; 述語に係る各ノードラベルを作る
(define (make-node arg verb-node next-node)
(let* (
(noun (car arg)) ;=> 太郎, 花子, 本
(str_noun (symbol->string noun))
;;(caar (cdr arg)) = (car (car (car (cdr arg))))
(particle (caaar (cdr arg))) ;=> は, に, を
(str_particle (symbol->string particle))
(noun-node (next-node))
)
;; (console.log "make-node" noun-node)
(list
(string-append noun-node "[" str_noun "]")
(string-append noun-node " --> |" str_particle "| " verb-node)
)
)
)
(define root '(渡し ((た ((太郎 ((は ()))) (花子 ((に ()))) (本 ((を ())))) ))) )
(let ((children (cadaar (cdr root))) (counter (node-counter)))
(for-each (lambda (child)
(print (make-node child "品詞" counter))) children
)
)
下記のラベルリストが出力されます。
(node0[太郎] node0 --> |は| 品詞)
(node1[花子] node1 --> |に| 品詞)
(node2[本] node2 --> |を| 品詞)
generate-mermaid
generate-mermaidは上で説明した関数を内部関数を埋めれば完成です。内部関数は外に出してグローバル関数として定義してもOKです。
(define (generate-mermaid root)
;; トップのノードラベルを作る
(define (top-root root verb-node)
; fill lines
)
;; ノードラベルに付ける番号を管理する
(define (node-counter)
; fill lines
)
;; 述語に係る各ノードラベルを作る
(define (make-node arg verb-node next-node)
; fill lines
)
;;main処理
(let* (
(next-node (node-counter)) ;ラベル名に不可する番号
(verb-node (next-node)) ;係り受け先の動詞ノード
(lines (list (top-root root verb-node))) ;mermaidに出力するリスト
(children (cadaar (cdr root)))
)
;(display "generate-mermaid ") (display verb-node) (newline)
(for-each
(lambda (child)
(set! lines (append lines (make-node child verb-node next-node)))
) children
)
(string-join "\n" lines) ; 改行をセパレーターにして各要素を結合する
)
)
おわりに
mermaid生成処理に渡す入力パラメータのデータ形式は構造体型にすることも考えましたが、個人的にLispという言語に馴染む必要があると思いLispのリスト形式にしました。
またLisp系のプログラム学習はカー・カダー系関数の利用に親しむことが基本かと思った次第です。
ただ、カッコのネストはまだ慣れません。閉じカッコは親しんできたJavaやC風に配置しています。
letでの変数定義についても複数の変数のときは見辛さを感じるので1行1変数にしています。
係り受け解析という処理ですが、扱う文章パターン(文型)によって処理が複雑になります。
係り受け等の構文解析を含む自然言語解析はcabocha等のライブラリ活用もよい学習材料になりそうです。
参考