はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

spaCyでの係り受けなど可視化(注:タイトル負けしています)

前の記事でspaCyをカジりました。

itdepends.hateblo.jp

spaCyなどのNLTKでは語の依存関係などを解析できますが、その関係を可視化して文とは文章とはなんたるかを俯瞰したいということが頻出します。

spaCyについては標準で、係り受けなどの関係を可視化するライブラリが同梱されています。

displacy というやつです。

https://spacy.io/usage/visualizers

ですが、個人的にはどうもいろいろいろありまして、(多少ブサイクでも)無印のターミナルのPythonのREPLで完結できたら嬉しいなということで、自分にちょうど良いspaCyの語の依存関係のCLIで可視表示にチャレンジしてみたというソースコードスニペットの経過状況です。

特別なテクニックなどをご披露できるものではないのですが、自分の中では少し書き出して残しておきたかったので、毎度の雑文ですがブログ投稿したというところなのでその点ご了承ください。

試作品その1

ソースコード

import sys
import unicodedata
import spacy
from itertools import accumulate
import operator

nlp = spacy.load('ja_core_news_md')

text = '昨日と違い今日はよく晴れた日だ'


# 前準備
deplist = []
tokentext = []
lenlist = []
rdep = []
postionlist = None


def _getwidth(c): return 2 if unicodedata.east_asian_width(c) in 'FWA' else 1
def getwidth(text): return sum([_getwidth(i) for i in text])


for i in nlp(text):
    deplist.append(i.dep_)
    _tmp = i.text + '/'
    tokentext.append(_tmp)
    lenlist.append(getwidth(_tmp))
    rdep.append(i.head.i)

positionlist = list(accumulate(lenlist, func=operator.add))


def bar(l, r, l2r):
    """
    依存関係をテキストアートの矢印で結ぶための素材を整える関数
    """
    psrc = positionlist[l-1] if l-1 >= 0 else 0
    barlen = positionlist[r-1] - psrc - 1
    if l2r:
        barstr = ' ' * psrc + '|' + '-' * barlen + '^'
    else:
        barstr = ' ' * psrc + '^' + '-' * barlen + '|'
    return barstr


# こっちはデバッグ用の出力
for i in nlp(text):
    print(i.text, i.head.text, file=sys.stdout)
print(positionlist, file=sys.stdout)

# 可視化
for i, v in enumerate(rdep):
    print(''.join(tokentext))
    if i < v:
        print(bar(i, v, True), deplist[i], sep='')
    elif i > v:
        print(bar(v, i, False), deplist[i], sep='')

実行結果イメージ

f:id:azotar:20200827234236p:plain

依存関係やそのトークンが何桁目に該当するか等をリストの変数などに入れて、最後に矢印相当をprintしてどことどこに繋がりがあるかを表現しています。 また、全角と半角を多少よろしく扱うために、unicodedata.east_asian_width(string) を使っています。

そんなに悪くないですが、そんなに見やすくもないというところと、ある程度等幅フォントに依存するのは当たり前とは言え、依存関係の始点・終点の繋がりが気になります。

また、私の腕前だと仕方ないのですが、なんとなくハードコーディングで-1したり+1したりしている感覚があって、(今回そこを追い求めるものではないので割り切りでも良いのですが)つまらないExceptionをスローしそうな雰囲気があります。

試作品その2

その1のもやもやした心残りの解消ともう少し繋がりがキレイに見えるようにということで試作品その2。

その1が横方向だったので、等幅フォントでも横幅が気になるケースがあるののでしょうか。

では、トークンの文字幅や矢印を縦方向で表記すれば(フォントによらず文字の縦の高さは一緒じゃろうから)いいんじゃないだろうかとして試してみたのがこれ。

ソースコード

省略

実行結果イメージ

f:id:azotar:20200827234445p:plain

...

やる前に気づけよ、というところですが、トークンごとのスペースを気にしなくて良くなったのは良いが、むしろスペースで印字位置を微調整しているところでさらに裏目に出る模様。

あと、私の手元で起きていることがこれに当てはまるかはわかっていませんが、どうも全角罫線は例えば次のようなこともあるという話で、どうもこのラインを攻めるのはあまりおトクではないのかと思い始めた次第。

https://diarywind.com/blog/e/box-chasr-width-difference-among-fonts.html

試作品その3

詳しいことは分からないし、何か別の理由を誤解している可能性はありますが、等幅フォントを使っていてもスペースや横幅のマージンを取りすぎない方が間延びせず美しいと皆が感じそうな文字については、最近のおしゃれなターミナルでは実質等幅にならないのではという傾向がみて取れることがわかりました。

よって、矢印風の表現にこだわりをやめるとともに、多少ごちゃごちゃしても、矢印にあたるところの表記はおそらく目一杯文字幅が確保される文字で代用することにするという方向性です。

また、半角(アルファベットや数字)と全角でいいうと、前者を考慮してコードが難しくなるのは、もともとの用途からいうと本末転倒な気持ちになったため、インプットのテキストに半角文字は入ってこない前提でコード量を減らしたという断捨離にシフトしつつあります。

... という発想のバージョンがこちら。

ソースコード

import pandas as pd
import sys
import spacy
nlp = spacy.load('ja_core_news_md')


def dispdep(text):
    FSPC = '□'
    HSPC = '.'
    TAB = '\t'

    def bar(l, r, l2r):
        def myjust(s): return list(s.ljust(len(rdep), FSPC))
        if r == l:
            if r == 0:
                return myjust('★')
            else:
                return myjust(FSPC * r + '★')
        barlen = r - l
        if l2r:
            return myjust(FSPC * l + '■' + '■' * (barlen - 1) + '▼')
        else:
            return myjust(FSPC * l + '▲' + '■' * (barlen - 1) + '■')

    tokentext = []
    rdep = []
    for i in nlp(text):
        tokentext.append(HSPC * 2 + i.text + HSPC * 2 + i.dep_)
        rdep.append(i.head.i)

    bars = []
    for i, v in enumerate(rdep):
        if i <= v:
            bars.append(bar(i, v, True))
        elif i > v:
            bars.append(bar(v, i, False))
    bars.append(tokentext)
    pd.DataFrame(bars).T.to_csv(sys.stdout, sep=TAB, quoting=3, header=None)


text = '昨日と違い今日はよく晴れた日だ'

for i in nlp(text):
    print(i.text, i.head.text)

dispdep(text)

実行結果イメージ

f:id:azotar:20200827234626p:plain

やりたいこととコード量のバランスが取れて整理されてきた感じがする。

あと、pandasのDataFrameを転置するアイディアは気に入っています。

一方、実行結果はそれはそれで大事な何かが失われた気もします。

試作品その4

結局、横方向に回帰。フォントの課題をクリアしているわけではないが、むしろ等幅フォントでなくても影響が出にくい文字を積極活用する方向。

ソースコード

from itertools import accumulate
import operator
import spacy
nlp = spacy.load('ja_core_news_md')


def dispdep(text):
    deplist = []
    tokentext = []
    lenlist = []
    rdep = []
    postionlist = None

    for i in nlp(text):
        deplist.append(i.dep_)
        _tmp = i.text + '/'
        tokentext.append(_tmp)
        lenlist.append(len(_tmp))
        rdep.append(i.head.i)

    positionlist = [0] + list(accumulate(lenlist, func=operator.add))

    def bar(l, r, l2r):
        psrc = positionlist[l]
        barlen = positionlist[r] - psrc
        if l == r:
            if l == 0:
                 return '山'
            else:
                return 'ー' * positionlist[l-1] + '山'
        if l2r:
                return  'ー' * psrc + '口' * barlen + '山'

        else:
                return 'ー' * psrc + '山' + '口' * barlen

    print(''.join(tokentext))
    for i, v in enumerate(rdep):
        if i <= v:
            print(bar(i, v, True), ' ', deplist[i], sep='')
        elif i > v:
            print(bar(v, i, False), ' ', deplist[i], sep='')


text = '昨日と違い今日はよく晴れた日だ'
for i in nlp(text):
    print(i.text, i.head.text)

dispdep(text)

実行結果イメージ

f:id:azotar:20200827234912p:plain

ダサいですが、これで良い気がしてきました。

ソースコードは省略しますが、最後はこんな感じに。(フォント種別の影響が出にくい→絵文字かつ横幅がデカそうなものを活用するという発想)

f:id:azotar:20200827235023p:plain

以上、この記事終わり。

[追伸] 参考リンク

もっとこの路線で深掘り勉強する際にはこちらの例で勉強させてもらおうと思っています。

spaCy + CLI + 可視化 といったキーワードでググって私のこの記事にたどり着いた方は、よりお望みのものはこちらのリンク先のものかも。

srad.jp