はてだBlog(仮称)

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

Pythonのネストされたdictに「a.b.c.d」のようなJavaScript風のアクセスを行う小品のスニペット例

Pythonのdict型のようなデータの配下の特定のプロパティに、「a.b.c.d」のような表記でアクセスできると、試験ツールやチェックツールで確認したいパターンを外部から入力として与えやすくなにかと便利だと感じています。

こういった使い捨てツールなどのために、言語をかえたりしながら、アクセスDSLの関数が必要になることが一年のうち2、3回あります。

基本は再帰を使うことになり、毎度その場その場の要件や気分を表していておもしろいなと思ったのでメモってみました。

今回はこんな感じ↓

hoge.py(Pythonです)

d = {
    "a": {"b": {"c": "あああ", "d": ["いいい"]}},
    "x": "ううう"
}

# 指定のフィールドを取り出す
def findfield(obj, path):
    k = path.pop(0)
    if isinstance(obj, dict):
        if len(path) == 0:
            if k in obj.keys():
                return obj[k]
            return None
        return findfield(obj[k], path)
    return None


# 指定のフィールドを削除する
def delfield(obj, path):
    k = path.pop(0)
    if isinstance(obj, dict):
        if len(path) == 0:
            obj.pop(k, None)
            return
        delfield(obj[k], path)


print('f1', findfield(d, 'a.b'.split('.')))

print('f2', findfield(d, 'a.b.c'.split('.')))

print('f3', findfield(d, 'a.b.d'.split('.')))

print('f4', findfield(d, 'a.err'.split('.')))

print('f5', findfield(d, 'a.b.d.err'.split('.')))

delfield(d, 'a.b.d.err'.split('.'))
print('d1', d)

delfield(d, 'a.err'.split('.'))
print('d2', d)

delfield(d, 'a.b.d'.split('.'))
print('d3', d)

delfield(d, 'a.b.c'.split('.'))
print('d4', d)

delfield(d, 'a.b'.split('.'))
print('d5', d)


なお、「a.b.c.d」のようなアクセスに向かないプロパティにぶち当たった場合、例えば、a.b.cがリストの場合は強制的にNoneを戻すこととしています。

hoge.pyの実行結果

f1 {'c': 'あああ', 'd': ['いいい']}
f2 あああ
f3 ['いいい']
f4 None
f5 None
d1 {'a': {'b': {'c': 'あああ', 'd': ['いいい']}}, 'x': 'ううう'}
d2 {'a': {'b': {'c': 'あああ', 'd': ['いいい']}}, 'x': 'ううう'}
d3 {'a': {'b': {'c': 'あああ'}}, 'x': 'ううう'}
d4 {'a': {'b': {}}, 'x': 'ううう'}
d5 {'a': {}, 'x': 'ううう'}

大事なことを見逃しているかもしれないが、今回の用途ぐらいには対応できた気がする。

spaCyのMatcherについて(spaCyで日本語ホットワード抽出の真似事再び)

はじめに

以前の記事でspaCyで手探りで遊んでみました。

itdepends.hateblo.jp

また、これを受けて、PhraseMatcherを少し試してみましたというのが次の記事です。

itdepends.hateblo.jp

上記2つの上の方の記事では、PhraseMatcherとともに、spaCyのMatcherの名前にふれていましたが、今回、実際にMatcherを試してみたというメモです。

spaCyのMatcher

いろいろできますが、正規表現でのパターンマッチングが文字通り文字の並びに関するものとすれば、spaCyのMatcherは品詞や語彙の関係などのルールをもとに、文中の該当フレーズを検索/抜き出しできます。

例えば、[名詞,助詞,動詞]というルールをMatcherのパターンとして登録して、インプットの文章にマッチングを行うと、(「最近眠い」は抽出対象外となる一方)「桜が咲く」とか「太郎が走る」「太郎が走っ」といったフレーズが抽出できます。

より本格的には、モデルを賢くするために使ったりもするようですが(違うかな?)、少なくとも私のようなちょっと変わり種のgrepとして使いたい人にもお手軽に使える便利な仕組みです。

公式リファレンスなど

チュートリアルAPI仕様書を行き来するのが良いでしょう。

spacy.io

spacy.io

ルールについて

上記で、「ルール」の設定のイメージを示しましたが、実際は以下の条件を組み合わせたルールが設定できます。

Rule-based matching · spaCy Usage Documentation

また、次のサイトで英語の文章についてですが、ルールの試し打ちが可能です。

explosion.ai

話が前後しますが、ルールの具体的な設定は、以下のような、dictのリストで、品詞が「ADJ」で....というトークンの並びのパターンを指定し、これに該当するものが抽出されるというやり方になります。

pattern = [{'POS': 'ADJ', 'OP': '?'},
           {'LEMMA': 'match', 'POS': 'NOUN'},
           {'LEMMA': 'be'}]

品詞がADJのトークンが1つ以上 の後に
見出し語が「match」で品詞(用法)が名詞のもの が続いて
見出し語が 「be」に該当する
フレーズ(Span)を抽出せよ.... の意味

↓

文中の
match is
modern matches are
Wooden matches are
matches are

などが抽出される。

ルールの簡単な事例

ルールは、1つのトークンに複数の条件を当てはめることもできます。

また、「OP」により、繰り返しを表現できます。

この他、次の例ではなく、もっと後のサンプルで例示していますが、そのトークンの品詞情報が「【名詞】または【動詞】で始まるもの」のように、トークンの種別に関する正規表現でのルール設定も可能です。

spaCyのMatcherを利用したPythonプログラムサンプル

import spacy
from spacy.matcher import Matcher
import sys
nlp = spacy.load('ja_core_news_md')

_text = """
鳥が飛ぶ。
桜咲く。
海港から関西国際空港へ移動する。
"""

doc = nlp(_text.replace(" ", "。").replace(" ", "、"))  # replaceしているのは個人的な都合


def event(matcher, doc, i, matches):
    """
    本当はいろいろできる...がここではとりあえずマッチした直後にデバッグ文を出力
    """
    match_id, s, e = matches[i]
    print("DEBUG: ", i,
          nlp.vocab.strings[match_id], doc[s:e:], file=sys.stderr)


# Matcherを初期化して、「ルール」のパターンを登録(add)
mc = Matcher(nlp.vocab)
ptn1 = [{"TAG": {"REGEX": '^名詞'}}, {"DEP": 'case', "OP": "*"}, {"POS": "VERB"}]
mc.add("RULE1", event, ptn1)
ptn2 = [{"POS": "PROPN", "ENT_TYPE": "FAC"}]
mc.add("RULE2", event, ptn2)

# 登録したパターンでドキュメントに対してマッチング
for match_id, s, e in mc(doc):
    print(nlp.vocab.strings[match_id], doc[s:e])

# 答え合わせ
for i in doc:
    print(i.text, i.pos_, i.dep_, i.tag_, i.ent_type_)

↓ 出力例

DEBUG:  0 RULE1 鳥が飛ぶ
DEBUG:  1 RULE1 桜咲く
DEBUG:  2 RULE2 関西国際空港
DEBUG:  3 RULE1 関西国際空港へ移動
RULE1 鳥が飛ぶ
RULE1 桜咲く
RULE2 関西国際空港
RULE1 関西国際空港へ移動

 SPACE  _SP 
鳥 NOUN nsubj 名詞-普通名詞-一般 
が ADP case 助詞-格助詞 
飛ぶ VERB ROOT 動詞-一般 
。 PUNCT punct 補助記号-句点 
桜 NOUN compound 名詞-普通名詞-一般 
咲く VERB ROOT 動詞-一般 
。 PUNCT punct 補助記号-句点 
海港 NOUN obl 名詞-普通名詞-一般 
から ADP case 助詞-格助詞 
関西国際空港 PROPN obl 名詞-固有名詞-一般 FAC
へ ADP case 助詞-格助詞 
移動 VERB ROOT 名詞-普通名詞-サ変可能 
する AUX aux 動詞-非自立可能 
。 PUNCT punct 補助記号-句点 

Matcherでホットワード候補抽出(spaCyを使ってホットワード抽出再び)

なんとなくMatcherが分かった気になったところで応用です。

冒頭に引用した次の記事で、Matcherを使わずに、文書から品詞などを判定しながら、「ホットワード」抽出を試みました。

itdepends.hateblo.jp

ということで、上記で試みたオレオレ名人芸(素人芸?)で得られるものとおおよそ同等の条件に近づくように、Matcherを使ったらどうなるかということで試してみたのがこちら↓です。

spaCyのMatchのお試し

上記の実行結果

f:id:azotar:20200918173434p:plain

(少なくとも自分の中では)以前のものより「多少すっきり実現できた」気がしています。

試してみる前は、自分の腕前の限界もあって、それほど変わらないのではと思いましたが、MatcherでDSL的に大きく処理できる効果があったように思います。

前回は逐次的に処理していた部分を、Matcherで今回「フレーズとみなす」ものの最低限の条件を満たしたSpanの切り取りで最初の課題を飛び越えられるのはなかなか良いですね。

Matcherはあくまで個々のトークンの種別や並びでの抽出なので、依存関係が繋がっているかといったところは、対象外です。

よって、主語と述語として意味をなすように繋がっているか、といったところは、一旦取り出したspanについて追加で行うことになりますが、むしろMatcherができることがはっきりして役割分担もすっきりしたかもしれない。

それにひきかえ、以前のものはそれほど日が経っていないのに、今はもう何をしているかさっぱり分からない....

やってみてよかったです。

以上です。

spaCyのPhraseMatcherを変わり種のあいまい検索やgrepとして使ってみる(ための試し打ちをした)

はじめに

以前の記事でspaCyで手探りで遊んでみました。

itdepends.hateblo.jp

記事の中で、spaCyのPhraseMatcherの名前にふれていましたが、今回、実際にPhraseMatcherを試してみたというメモです。

spaCyのPhraseMatcher

さて、そのspaCyのPhraseMatcherですが、うまい例えかは自信がありませんが「当社版のポ○モンを作りたいので是非企画〜開発をお願いしたい」的なノリで、ある文書からあるフレーズをgrep(検索)できる! というものです。

しかも、ポ○モンへの似せ具合をいくつか加減できるというスグレモノです。

あとで、実際の例をお見せしますが、「桜が咲く」を検索条件に指定することで、「桜が咲いた」「桜が咲かない」といった活用も含めてマッチさせることも、さらには「向日葵が咲いた」までマッチさせることもできます。

一方、本来のピュアなgrepでは正規表現の範囲で本当に指示通りのものが検索結果として得られますが、PhraseMatcherでは、得られた検索結果を見て確かにそれも対象になるべきだねという驚きやもう少し割り引いてほしかったなーという感想が得られるところがあります。

これは、「当社版のポ○モン」のようなオーダーをした人もされた人も、実際に得られた仕上がりを見た時に感じる感想と似たところがあるかもしれません。

ですが、あくまでコンピューターへの手続き的な命令で実現されるので、大半は、「こんなお願いの仕方でよく気づいてくれたね」という成功事例のような気がします。

姉妹APIとして、Matcherがありますが、これはまた改めて。

公式ドキュメント

PhraseMatcherのAPI仕様書です。 spacy.io

ただ、私見ですが、実際どうやって使ったら良いんだろうというところが、API仕様書からは読み取りにくく感じました。

もちろん、公式には(ちょっと離れたセクションですが)チュートリアルもあって、言語ガチ勢ではない私でも、次のページも併用することで、こうやれば良いのねというところが分かるようになりましたので、両方のページをいったりきたりすることをおすすめします。

spacy.io

試してみる

では実際にやってみます。

'桜が咲く', '桜が美しい', '美しい桜' を検索条件として与えた実験です。

先述の「ポ○モンの似せ加減」に相当するものを、PhraseMatcherのコンストラクターのattrパラメータで指定できます。

ここでは記事の主旨に従って、語弊を恐れずに説明するとおおよそ次のように言えると考えています。

  1. LEMMA: 見出し語に標準化した上で上記の「桜が...」のいずれかにマッチするものを検索します。
  2. POS: 例で指定されているフレーズを、おおまかな品詞分類し、その品詞の並びで表現されているフレーズを文書全体から検索。
  3. TAG: より細分した品詞での、POSと同様のもの。
  4. DEP: 例で指定されているフレーズのトークンの間の依存関係と似た依存関係で表現されているフレーズを文書全体から検索。

1は表記揺れをカバーした検索に近い挙動となりますし、他は「例えばこんな感じと似た言い回しのフレーズ」というものに対して、何を軸に似たと考えるかの加減で多少違いが出るというところでしょうか。

4は簡単な例だと2、3と大きな違いが出にくそうですが、少し複雑なもの(というか長めの検索例のフレーズ)の場合は、主語に修飾語をいっぱいくっつけて主部を大きくしがちな人の文書から、実際にそのような言い回しになっている箇所を見つけるといったちょっと変わった例の応用が考えられます*1

他にもあります。また上記のようなおおよその説明ではなく、厳密な定義をお求めの方は下記をご覧ください。 Rule-based matching · spaCy Usage Documentation

サンプルプログラム


import sys
import spacy
from spacy.matcher import PhraseMatcher

nlp = spacy.load('ja_core_news_md')

#検索対象の文書
_text = """
桜が咲く。
きれいな桜が咲かない。
桜が早く咲く。
桜よ咲け。
桜が咲いた。
大きな向日葵。
夏にはひまわりが咲く。
緑が映える。
緑が綺麗だ。
草木が生える。
草木が煌めく。

"""

doc = nlp(_text.replace(" ", "。").replace(" ", "、"))

#検索条件(に相当するフレーズ例)
terms = ['桜が咲く', '桜が美しい', '美しい桜']

patterns = [nlp(term) for term in terms]

# https://spacy.io/usage/rule-based-matching  # adding-patterns-attributes
ATTRS = ['LEMMA', 'POS', 'TAG', 'DEP']
# 'ENT_TYPE','SHAPE'

for ATTR in ATTRS:
    pm = PhraseMatcher(nlp.vocab, attr=ATTR)
    pm.add('RULE__' + ATTR, None, *patterns)
    for i, j, k in pm(doc):
        print(nlp.vocab.strings[i], doc[j:k])

実行結果

RULE__LEMMA 桜が咲く
RULE__LEMMA 桜が咲か
RULE__LEMMA 桜が咲い
RULE__POS 桜が咲く
RULE__POS 桜が咲か
RULE__POS 桜が早く
RULE__POS 桜が咲い
RULE__POS ひまわりが咲く
RULE__POS 緑が映える
RULE__POS 緑が綺麗
RULE__POS 草木が生える
RULE__POS 草木が煌めく
RULE__TAG 桜が咲く
RULE__TAG 桜が咲か
RULE__TAG 桜が早く
RULE__TAG 桜が咲い
RULE__TAG ひまわりが咲く
RULE__TAG 緑が映える
RULE__TAG 草木が生える
RULE__TAG 草木が煌めく
RULE__DEP 桜が咲く
RULE__DEP 桜が咲か
RULE__DEP 早く咲く
RULE__DEP 桜が咲い
RULE__DEP ひまわりが咲く
RULE__DEP 緑が映える
RULE__DEP 緑が綺麗
RULE__DEP 草木が生える
RULE__DEP 草木が煌めく


*1:もっとspaCyを活かした例はあると思いますが、私の身近な例の範囲で例えたため、そんなに響かない例になっていたらご容赦ください。

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

spaCy2.3の日本語標準対応にのっかってホットワード抽出の真似事(ただし候補ワード・フレーズ抜き出しまで)

spaCyがver 2.3になって、デフォルトで日本語に対応したようです。

spacy.io

ということで、何日か前に、GiNZAスゲーとなった感謝の気持ちは忘れないながらも、フリーライダーな私としては、裸のspaCy2.3に乗り換えて、まずは雰囲気を掴んでみようということで使ってみることにしました。

なお、この記事を書き始めた時点では、以前、次の記事で試してみたGiNZAは、spaCyのver2.2までの対応でした。

itdepends.hateblo.jp

よって、spaCyをver2.3にアップグレードすると、(なんとなく動作している雰囲気はありますが、)それまで動作していた古いバージョンのGiNZAは公式には動作対象外となるのでご注意ください。

(ちなみに、この記事を公開した時点ですでに、GiNZAはver4となり、ベースとなるspaCyは2.3に対応したようです。また、上記では、spaCy2.3のデフォルトの日本語まわりはGiNZAと無関係かのような言い方をしていますが、公式でダウンロードできるモデルはGiNZA同梱のモデルと同様のものに見えなくないですので、なんらか繋がりはあるのかもしれません。単に私の探索力の問題で正式な情報源は未確認ですが...)

https://github.com/megagonlabs/ginza/blob/develop/docs/index.md#breaking-changes-in-v40 https://www.recruit.co.jp/newsroom/2020/0817_18783.html

■目次

spaCyを使ってみるモチベーション/ユースケースのようなもの

言い忘れましたが(またこの後の文章を見れば見る人は分かると思いますが)、筆者は特に自然言語処理のエキスパートでもなんでもありません。謙遜でもありません。

しかし、ちょっとこの分野の応用のビジネスの場で、形を問わずブサイクでもなんらか成果を出したい世界に所属しているので、(真理の探究というよりは)手間をかけずになんちゃってでも成果が出ると嬉しいという方向でのある意味真面目なモチベーションを持ち合わせています。

具体的には、(実際の案件からは多少デフォルメ&ぼやかしているものの)次のようなものです。

モチベーション

あるドキュメントやドキュメント群に頻出する、あるいはこれらのドキュメント群のある程度の塊全体の特徴をよく表したフレーズを効率的に抜き出したいです。

つぶやきのホットワードがイメージに近いでしょうか。

なお、実際のホットワードのように、ここでいうフレーズは、TwitterInstagramハッシュタグやトレンドでみられるような、単語以上、2、3程度の文節からなる複合ワードをイメージしています。

spaCyでやりたいこと

キーセンテンスや要約とはちょっと違いますので、文書要約のための有名ライブラリは応用はできなくないかもしれませんが、守備範囲ど真ん中ではなさそうです。

ディープラーニングで(なんらか仕組みを借用するならまだしも、自分でモデル開発してまで)というのは少し目的に対してヘビーそうです。

一方、対象とするドキュメント群が、構造化こそされていないもののある程度の基準・秩序でメンテされていることがあてにできる場合があります。

例えば、ブログは難しいですが、ニュースだとライティングや用語、センテンスなどに一定の暗黙のルールが潜んでいると思います。

このようにある程度ドキュメントの性質があてにできる場合、ひとつの仮説として、次のようなややアドホックな手法でもある程度良い結果が得られるのではないかと考えることにしました。

  1. ストップワードを取り除く
  2. また、(文学的味わいのようなものは薄れてしまいますが)過剰な修飾に該当する単語を取り除く → ここの加減が難しいのではと思う一方、深層学習などで頑張るよりも、単語の依存関係や品詞が分かるなら今回の目的の範囲でもぼちぼち都合が良い結果が得られるのではないか。
  3. 残った単語、単語と単語を意味の繋がりをある程度担保したフレーズを形成、これらの長すぎず短すぎずの集合にした、元のドキュメントの純度・濃度*1を上げたデータに変換。

ある程度純度が高いデータであれば、出現数ランキングやなんらか相対的に出現数が多い((ややハンマー釘脳かもしれませんが、筆者はElasticsearchのSignificant_termsを気に入っており、これの利用が念頭にあります。)といった、比較的平易な集計でいい感じのホットワード一覧が得られたら良いな。

ということで、うまくいくかどうかはともかく、spaCyで可能になる、日本語の品詞分類や文中の単語間の依存関係をよろしく参照しつつ、まずはドキュメント全体の性質をなんらか反映しているような単語やフレーズをヒューリスティックに抜き出すスクリプトを試して勉強してみようというのがモチベーションです。

なお、一瞬だけGiNZAの話に戻るのですが、この記事のモチベーションとなった上記の試行例相当は、GiNZAのver4で導入された、文節を意識したAPIでよりスマートに実装できるのでは?という気がしております。

もし、たまたまこの拙記事を検索で訪れた方で、てめー(私)のオレオレ方式はいいから、もっと由緒正しいアプローチをしりたいというかたは、GiNZAのver4をご検討されるのが良いかもしれない...ということを素直に申しておきます。

github.com

※ 注目: bunsetu_XXXXやphrase他のAPIs

spaCy2.3インストール

公式を見てください... というところですが、手元のmacですとこんな感じでした。 今回は、サイズが37MBの大中小でいうところの「中(_md)」のモデルにしています。

pip3 install spacy

python3 -m spacy download ja_core_news_md

https://spacy.io/usage#quickstart https://spacy.io/models/ja#ja_core_news_md

インポート方法の例

import spacy
nlp = spacy.load('ja_core_news_md')

spaCy 前提知識・予備知識

いきなりオレオレスクリプトをベタ張りするのも気が引けたので、私の感じている、spaCyの特徴的な使い方の例などを少し述べます。

体系的、網羅的ではありませんが、spaCyをつまみ食いしたい人向けのちょっとしたチュートリアルになっています。

公式ドキュメント

spacy.io

APIのトップページと、左メニューのDoc、Tokenのセクションを最初に見るのが良いかと思われます。

spaCyがわかった気になるスクリプト

import spacy
nlp = spacy.load('ja_core_news_md')

doc = nlp('昔々、あるところにおじいさんとおばあさんが住んでいました。ある日おじいさんは山に芝刈りに、おばあさんは川に選択に行きました。')

sentences = [s for s in doc.sents] #センテンス(1文ずつ)のリストが得られる。
ents = [e for e in doc.ents] # 固有表現のリストが得られる。 https://spacy.io/api/annotation#named-entities
tokens = [t for t in doc] # トークンの一覧 (私自身は、一度この形式にして取り捌くのが体に馴染む気がしています。)

for i in tokens:
   print(
       '',
       i.text,
       i._.reading,
       i.i,
       #Universal Part-of-speech  https://spacy.io/api/annotation#pos-tagging    https://spacy.io/usage/adding-languages#tag-map
       i.pos_, 
       #Sudachi/SudachiPyが利用されているとのことで、どこかに品詞の分類の公式一覧ドキュメントがあると思われるのだがよく分からず。→ 歴史的なところから想像すると、「IPA品詞体系、UniDic品詞体系、
       i.tag_, 
       i.lemma_,
       i.orth_,
       i._.inf,
       # Universal Dependencies:  日本語を対象にした例だと、ググって出てくる、こちらのサーベイを参考にさせていただいています→ https://www.jstage.jst.go.jp/article/jnlp/26/1/26_3/_pdf
       # spaCy公式での説明→ https://spacy.io/api/annotation#dependency-parsing  ※Universal Dependency Labelsというところのアコーディオンをクリックすると一覧が表示される。
       i.dep_, 
       i.head.text,
       '',
       sep='|'
   )

出力例

i.text i._.reading i.i i.pos_ i.tag_ i.lemma_ i.orth_ i._.inf i.dep_ i.head.text
昔々 ムカシムカシ 0 NOUN 名詞-普通名詞-副詞可能 昔々 昔々 , obl 住ん
1 PUNCT 補助記号-読点 , punct 昔々
ある アル 2 DET 連体詞 或る ある , nmod ところ
ところ トコロ 3 NOUN 名詞-普通名詞-副詞可能 ところ , obl 住ん
4 ADP 助詞-格助詞 , case ところ
5 NOUN 接頭辞 , compound さん
じい ジイ 6 NOUN 名詞-普通名詞-一般 じい , compound さん
さん サン 7 NOUN 接尾辞-名詞的-一般 さん さん , nmod さん
8 ADP 助詞-格助詞 , case さん
9 NOUN 接頭辞 , compound さん
ばあ バア 10 NOUN 名詞-普通名詞-一般 ばあ , compound さん
さん サン 11 NOUN 接尾辞-名詞的-一般 さん さん , nsubj 住ん
12 ADP 助詞-格助詞 , case さん
住ん スン 13 VERB 動詞-一般 住む 住ん 五段-マ行,連用形-撥音便 ROOT 住ん
14 CCONJ 助詞-接続助詞 , mark 住ん
15 VERB 動詞-非自立可能 居る 上一段-ア行,連用形-一般 aux 住ん
まし マシ 16 AUX 助動詞 ます まし 助動詞-マス,連用形-一般 aux 住ん
17 AUX 助動詞 助動詞-タ,終止形-一般 aux 住ん
18 PUNCT 補助記号-句点 , punct 住ん
ある アル 19 DET 連体詞 或る ある , nmod
20 NOUN 名詞-普通名詞-副詞可能 , nmod さん
21 NOUN 接頭辞 , compound さん
じい ジイ 22 NOUN 名詞-普通名詞-一般 じい , compound さん
さん サン 23 NOUN 接尾辞-名詞的-一般 さん さん , nsubj 行き
24 ADP 助詞-係助詞 , case さん
ヤマ 25 NOUN 名詞-普通名詞-一般 , obl 芝刈り
26 ADP 助詞-格助詞 , case
芝刈り シバカリ 27 NOUN 名詞-普通名詞-一般 芝刈り 芝刈り , advcl 行き
28 ADP 助詞-格助詞 , case 芝刈り
29 PUNCT 補助記号-読点 , punct 芝刈り
30 NOUN 接頭辞 , compound さん
ばあ バア 31 NOUN 名詞-普通名詞-一般 ばあ , compound さん
さん サン 32 NOUN 接尾辞-名詞的-一般 さん さん , nsubj 行き
33 ADP 助詞-係助詞 , case さん
カワ 34 NOUN 名詞-普通名詞-一般 , obl 行き
35 ADP 助詞-格助詞 , case
選択 センタク 36 NOUN 名詞-普通名詞-サ変可能 選択 選択 , obl 行き
37 ADP 助詞-格助詞 , case 選択
行き イキ 38 VERB 動詞-非自立可能 行く 行き 五段-カ行,連用形-一般 ROOT 行き
まし マシ 39 AUX 助動詞 ます まし 助動詞-マス,連用形-一般 aux 行き
40 AUX 助動詞 助動詞-タ,終止形-一般 aux 行き
41 PUNCT 補助記号-句点 , punct 行き

Universal Dependenciesについて

pos(Part Of Speech)で示される品詞や、tagで示される日本語の慣例を加味した品詞のタグ付けはなんとなくみてみると分かるところがあります。

一方、上記でいうところの「dep(Token.dep_)」はなんなんでしょうか。

これは単語と単語の関係、ある単語に対してどういう役割を担っているかといったものを表すもののようで、「係り受け」や「Universal Dependencies」と言われるもの(もしくはそれらに関するもの)のようです。

Universal Dependenciesについての、世界の御本尊、日本語に関するものの本丸がどちらに御座すのかは不勉強で分かっていませんが、

例えば、「Token.dep_」が

acl であれば → clausal modifier of noun (adjectival clause) ということで

美しい花」でいうと、「美しい」は形容詞であると同時に、Token.dep_については「acl」で、名詞を形容する(修飾する)役割であると分析されるとともに、「Token.head」は、修飾先の「花」のTokenを指し示すということになります。

Universal Dependencies(日本語関連)

日本語を対象にした例だと、ググって出てくる、こちらのサーベイを参考にさせていただいています。

https://www.jstage.jst.go.jp/article/jnlp/26/1/26_3/_pdf

spaCy公式でのUniversal Dependencies 説明

spacy.io

※Universal Dependency Labelsというところのアコーディオンをクリックすると一覧が表示される。

類似度

後述の例では特に用いていませんが、単語ベクトルなどの数値も保持されており、2つの単語の類似判定もできるようです。

(私は使いこなせていませんが)spaCyはもっといろいろできるよという可能性をお伝えするのに少しだけふれておきます。

gf = None
for i in tokens:
   if  i.text == 'じい':
       gf = i
       break

from operator import itemgetter
sorted([[i.text,i.similarity(gf)] for i in tokens],key=itemgetter(1),reverse=True)

↓ 出力例 (文例の中の単語では、「じい」に似ている(と評価された)のは「ばあ」ということが分かる)

[['じい', 1.0],
 ['じい', 1.0],
 ['ばあ', 0.4911714],
 ['ばあ', 0.4911714],
 ['と', 0.30002618],
 ['、', 0.28635746],
 ['、', 0.28635746],
 ['は', 0.25570428],
 ['は', 0.25570428],
 ['い', 0.23832183],
 ['に', 0.21961989],
 ['に', 0.21961989],
 ['に', 0.21961989],
 ['に', 0.21961989],
 ['に', 0.21961989],
 ['で', 0.21929],
 ['た', 0.20998874],
 ['た', 0.20998874],
 ['が', 0.19864015],
 ['。', 0.17501514],
 ['。', 0.17501514],
...

nlp.pipeで複数ドキュメントを一括解析

上の方の例でしめしたように、nlp('テキスト')で、解析後のDocオブジェクトが得られるので、ググると、これを使ったサンプルスクリプト例が多く見られます。

しかし、複数ドキュメントを扱う場合は、nlp.pipeで並列化するのがベストプラクティスのようです。

docs = nlp.pipe([
    '昔々、あるところにおじいさんとおばあさんが住んでいました。ある日おじいさんは山に芝刈りに、おばあさんは川に洗濯に行きました。',
    'むかしむかし、足柄山の山奥に、金太郎という名前の男の子が住んでいました。',
    '海の側の村に、浦島太郎という男の若者が暮らしていました。'
])


doc1 = nlp('昔々、あるところにおじいさんとおばあさんが住んでいました。ある日おじいさんは山に芝刈りに、おばあさんは川に洗濯に行きました。')
doc2 = nlp('むかしむかし、足柄山の山奥に、金太郎という名前の男の子が住んでいました。')
とするより高速
(実際にやってみた例としては、2倍ほど高速でした。)

nlp.pipelineについて(今回は使ってませんが...)

公式のチュートリアルがわかりやすい。

course.spacy.io

独自のパイプ処理を追加できるし、ある要件の場合は不要かな〜というパイプを外すことができます。

例えば、nlp.remove_pipe でdoc.entsで取得できる固有値表現を取り除くと、手元の環境だと30%ぐらい高速化されました。

また、今回の例では、今思えば、後述の内容をpipelineの一つとして実現するのが、spaCyのイケてる利用方法だったかもしれないと今気づきました。

Matcher、PhraseMatcherについて(今回は使ってませんが...)

実は、spaCyでは、(ループやイテレータで回すような逐次的な記述ではなく)宣言的な記述により、品詞の組み合わせや特定の意味合いの語の並びといったルールベースの検索/テキスト抽出が可能です。

後述の例では、対象とするドキュメント例はアドホックな分析の方がしっくりくると、(個人的な)経験則に従うことにしたので、用いていませんが、spaCyではこのようなことも可能ということで触れておきます。

spacy.io

ここでは、ルールベース(という言い方が正確かは自身がありませんが)、通常のgrep分かち書き考慮の全文検索型との対比でいうと、次のようなものです。

全文検索型:

「東京都」でその文書における出現位置を(「京都」はヒットさせずに)検索

vs

ルールベースの例:

  • 「地名」を含むようなセンテンスを検索
  • 「東京都の...」のように、「地名」を所有格としたセンテンスを検索
  • 文書中の「地名」「人」「動詞」という構文をとるセンテンスを検索
  • 「地名」が主にになっているセンテンスを検索

(もちろん、「東京都」という単語を含むという検索も可能です。)

今回は(私の理解不足を差し引いても)考えがあってMatcherを使わなかったのですが、やっぱり使っておけばよかったかなとも、この記事を仕上げるにあたって思うところもあります。

ホットワード集計の元ネタ抽出(やってみた!の例)

随分、前置きが長くなりましたが、ホットワード集計の元ネタ抽出の「やってみた!」例です。

具体例を出せませんがある程度秩序のあるドキュメント群を念頭においてあるのと、どちらにせよ後で統計的集計を行うので、できるだけ好みのワードやフレーズが残るようにするものの、「不自然なものが絶対混在しないようにする」というところは目指していません。

(1) 以下の4カテゴリごとに抽出

  1. 名詞: 桜, 花
  2. 動詞/形容詞: 美しい
  3. 名詞フレーズ(装飾語あり): 美しい桜,美しい桜の花
  4. 動詞・形容詞フレーズ(前方の装飾あり、語尾の自然な活用): 桜が美しい, 美しい桜が咲く

品詞の違いや単語か複合語を意識せず素直にカウントする案もありますが、ある文書の特徴を表す表現が名詞や固有名詞など特定の品詞に強く出る場合がある一方で、逆に全体から見ると数は少ないものの特定の品詞やフレーズの中では件数が多く、それがドキュメント全体の傾向を示すということもままあると感じています。

感覚的には、名詞はその文書が対象としているドメインを象徴することが多く、動詞や形容詞はその文書の中で発生しているなんらかの動きやストーリーを浮き彫りにする傾向があるといえるのではないでしょうか。

というのが、この4つのカテゴリごとに単語・フレーズ抽出を行うこととした理由です。

(2)名詞フレーズと動詞・形容詞フレーズの抽出

それぞれ、下図のような考え方としました。(ちょっと図中の文例があまりリアルでないかも...頑張って行間を読んでください。)

f:id:azotar:20200819232803p:plain

ポイントは次のとおり。

  1. 名詞フレーズ:名詞を見つけたらその前方の修飾の語句のもっともらしい部分から続けてフレーズを作る。
  2. 動詞・形容詞フレーズ:動詞あるいは形容詞を見つけたらその前方にいる目的語などと繋げて、何をしようとしているか、何の様子かをシンプルなワンフレーズとして抜き出す。

名詞フレーズは名詞の近くほどその名詞の意味を深める語が配置される傾向があるのでそれを意識したフレーズを抜き出す一方、動詞や形容詞は近くに副詞が配置されることが多く、必ずしも近くに配置される単語がその動詞や形容詞の主題を表さな い、加えてその分、該当の動詞や形容詞との「依存関係(dep)」が強い単語に絞ってやるいった味付けをしています。

また、実際のソースコードではもう少し味付け((いくつかベンチマークとなる文書を読み込ませてみて、紛らわしいと感じた例の例外処理をしている部分もあります。

なお、名詞フレーズと動詞・形容詞フレーズに分けた理由は、前述の名詞と動詞・形容詞に大きく分けたのと同じ理由です。

どちらかと言えば、おおきな方針のもと、おおよそ「フレーズらしい形になっているかな」というチャンクを抜き出しておき、最後にもういちどだけ、目についたパターンを具体的に指定して除外する。それでも残るような微妙なフレーズ例はいずれにせよ、のちの統計処理で自ずとふるいにかけられるだろうというスタンスです。

spaCyの品詞分類やUDを活用したオレオレホットワード集計用クレンジングデータ出力サンプル

spaCyの練習

実行結果

入力テキスト

f:id:azotar:20200821223504p:plain

抽出結果

f:id:azotar:20200821223521p:plain

アドホックな方法の割に、意外にいいところまで来たような気もします。

一方、簡単に済まそうとして、逆にステップ数が多くなったのと、後で見た時に自分でも分からなそう...というところはありますが、自分の中ではなかなか勉強になったので満足しています。

spaCy参考URL

spaCyに慣れるのに参考にさせていただいたページ

qiita.com

blog.imind.jp

*1:自然言語関係の情報処理でより適切な用語があるかもしれませんが、よく知らないのでこのような表現としました。

pandas.read_htmlでぶち抜きtableタグに関する今更の気づき

pandas.read_htmlについて、rowspanやcolspanで実現されているぶち抜き表についても、よろしく整然データとしてのDataFrameに変換できるということを知ったので、その感想です。

pandas.read_htmlについて

pandas(Python)には、pandas.read_htmlというメソッドがあります。

これは、

pandas.read_html(io='https://example.com/',encoding='utf-8')

などとやるとそのロケーションのhtmlファイルを読み込み、ファイル中のtableタグの記述をスクレイピングしてくれます。

tableタグの内容がDataFrameに読み込まれ、それが、read_htmlの戻り値となるので、あとは、pandas/DataFrameの世界でいろいろやれて便利です。

f:id:azotar:20200802111649p:plain

pandas.pydata.org

pandas.read_htmlはhtml中のtableに特化したスクレイピングDSL(かも)

昨今の複雑なhtmlでは、beautifulsoup4 を使っている(らしい)read_htmlとは言え、ブラウザ表示時の外面が表形式のものであっても、うまく読み込めないということもあるにはあるようです。

この場合は、read_htmlの細かいパラメータを細かく指定してやる必要がある場合もあります。

pandas.pydata.org

ということで時に注意が必要ではありますが、実際のところ大半の表(tableタグ)は深く考えずに読み込みできているように見えるというのが私の実感です。非常に便利!

また、read_htmlは冒頭の例のような

table_df = pandas.read_html(io='https://example.com/',encoding='utf-8')

という記述だけで、html中の各テーブルをDataFrameにしたデータに「table_df[2]」などでアクセスできます。

read_htmlは、pandasでのデータ処理のための「スクレイピング」前処理をサポートする関数と言えますが、tableタグをスクレイピングする(おそらく最少タイプ量の)DSLとも言えそうです。

この他、pandas.to_htmlやpandas.to_markdownで、read_htmlで DataFrameに読み込んだtableタグのコンテンツを(結果的にその他のタグなどの)余計な情報を削ぎ落として、出力してやることもできます。

つまり、html中のtableタグのスリム化&変換のおまじないとして(例えばpandasに興味がない人でも*1)ツールとして使って一過性のデータ操作などにも活用できるという生活の知恵なのでは?

ぶち抜きtableを整然データ(DataFrame)に変換できる(この記事の本題)

テクニカルな話を書いているようで特に何も書いておらず、やはり非テクニカルな話の続きなのですが、最近 read_htmlをさわっていて、 生活の知恵としてひとつうれしい気づきがありました(そしてこれがこの記事の本題だったりします)。

pandas.read_html は、rowspan="2"やcolspan="3"でぶち抜きになっている表(table)のデータを、DataFrameで取り扱えるように/とりあつかいやすいように、 整然データの形式に変換してくれる

これは、かなり嬉しいです。

f:id:azotar:20200802111711p:plain

read_htmlでデータを読み込むユースケースは、通常(周辺のhtmlのクセによっては多少読み込みに工夫が必要になることもあるが)株価の時系列データのようなある程度はそのままで「整然データ」に近い形式のものを対象にすることが多いと思います。

この考えからからすると、rowspanなどでぶち抜き形式の表になっているものは、pandasで扱うモチベーションからは遠いところにいるように思われ、そのようなデータはエラーになったりスキップされるだろうと思い込んでいたのでした。

が、そこはさすがのpandas、ぶち抜き部分を、整然データに割りもどしてDataFrameに格納してくれているようです。

いや〜。これは本当に嬉しい。

というのも、私のような(データサイエンスというよりは)業務データ整備系の界隈をうろついている人間は、こういう整然データ一歩手前の一手間かければ使えるかもしれない半構造化データにでくわすことが多いです。

例えば、社内にRDBなどに格納された一次情報が存在するもののそこにはアクセスできず(あるいはこちらの事情としてもアクセスしたくない、あるいはアクセスできるが正規化されておりマスタファイルとの結合が必要といった別の手間が想定される予感がある)、代わりに加工された社内ポータルの「(ぶちぬき表記ありが玉にキズだが)それ以外はまあまあ悪くない表形式のデータ群」に出くわした際にある種のジレンマに陥ります。

このようなデータは最終的には一手間かけることにはなるのですが、そのような一手間をかける価値があるかということ自体をPoCしたいということがままあります。

そのような見込みがありそうかということをひとまず確かめるのに、なんらかの形でデータのエッセンスを抜き出してみて俯瞰したい、そんな時に、ぶち抜き表がしばしば障壁になるので、あん時、これを知っていればなー...という気持ちです。

と最初から最後まで雑談でしたが、read_htmlの宣伝になったかもということで、感謝をこめて 以上

*1:インストールの手間などがあるので、実際のところ、pandasやpythonに関わっていない人向けにはかえって手間がふえてしまいますが、あくまで例えとして。

Elasticsearch のMore Like This Queryを使ってみた(グルメ的に似たエリアを検索)

Elasticsearch のMore Like This Query(以下MLT)を使ってみました。

More Like This Query(MLT)について

MLTは名前の通り、あるドキュメントに似たドキュメントを探す検索クエリです。

f:id:azotar:20200729121846p:plain

ドキュメントそのもののレコメンドに使ったり、あるプロフィールのユーザーと似たユーザーやあるいはモデルユーザーを発見するといった、if文を積み上げた判定ロジックでは実現が難しいようなマッチングに使えそうです。

また、やや邪道かもしれませんが、あらかじめピックアップしたドキュメントAとドキュメントBの類似度を測るカジュアルな方式としても使えそうですね。良い例えではないかもしれませんが、模範回答との大雑把な比較だとか、NGワード集に設定されている単語が使われすぎていないかといった一次判定に使えるような気がします。

MLTの類似度の考え方(の雑な説明)

じゃあ、何を持って似ているのかといういうと、公式サイトにもそれらしきことが書いてありますが、土台が全文検索エンジンならではということで、tf-idfとかterm_vectorという単語の出現頻度に関する指標をよろしく用いているようです。

あるドキュメントAがあって、AとA以外の複数のドキュメントの集合Sのうち、SのうちAに最も類似するドキュメントはAが選ばれることになりますが、これはAに出現する単語とそれらの単語の頻度などが比較元のAとSの中に紛れているAとで同じになる/なってしかるべきという論法でしょうか。

単語のカウントベースですので、ディープラーニングなどの手法に比べると、ヒトでは気づかなかった思わぬ類似ドキュメントを発見するということはまれでその点では面白みにかけます。

一方、意外性は欠けるものの、類似と見なされたAとBというドキュメントの単語の文字面は感覚的には近いものになる傾向があるといえそうです。というかアナライズにより正規化・標準化はされるものの、単語を数えているのでそうなっちゃいます。

よって、この類の話でありがちな(揉めがちな)、なんでAとBが似ていると判定されたのか?という説明は比較的楽ですね。

Elasticsearch MLT での実際の検索方法

検索方法としては大きく分けて次のような方式での問い合わせが可能です。(ver 7.x)

  • (A)ドキュメントIDを指定して、このドキュメントに似たドキュメントを検索
  • (B)フリーテキストを指定して、そのフリーテキストに似たドキュメントを検索

検索結果は、類似度が高い順にスコアリングされて戻ってきます。

実際のシンタックスは公式に書いてあるとおりです。

www.elastic.co

シンタックス自体は説明しませんが、あとで、前述の(A)でのサンプル例を示します。

なお、この記事のまとめにあたって、Elasticsearch ver 6.8 で確認していますが、難しいトリックなどは利用していないので、7.x でも動作すると思います。

また、次のブログで、主なパラメータが紹介されています。 私自身は公式の英語を読みこむのがめんどくさい時に参照させていただいています。

qiita.com

[補足] MLTはElasticsearch 検索DSLの一種

MLTのクエリは、Elasticsearchの各種検索DSLの一種ですので、boolクエリ(複数の検索DSLのANDやORの組み合わせ)など複合クエリにぶら下げることが可能です。

つまり、日本とMLBの野球選手のうち、MLB所属の選手に限って、選手Aと似た別の選手を検索するといった用法も可能です。

MLT自体のチューニングパラメータは

  • max_query_terms
  • min_term_freq
  • min_doc_freq
  • max_doc_freq
  • min_word_length
  • max_word_length

のように、TF-IDF の世界観を感じさせる各種パラメータがありますが、私自身の経験則としては、MLTそのものチューニングよりも、他の絞込み条件とのAND検索にしてしまう方法の方が、類似文書検索として期待した結果を得やすい/定性的な仕様・要件としてコントロールしやすいのではと感じています。


★実際にやってみる

前説

このブログの過去記事で、Livedoorレストランデータを取り込んでていますので、このデータを派生させて、エリアごとの特徴的なレストランカテゴリデータを用意します。このエリア特徴カテゴリデータを用いて、あるエリアAと似た全国のエリアが存在するかという検索を行なってみます。

例えば、

銀座エリアの類似エリアを検索すると、神楽坂や六本木エリアが類似エリアとして抽出されるか

というような実験です。

レストランデータにからめるなら、店Aの類似の店を探すのがMLT界隈の王道かと思いますが、なんとなく記事としては面白くないので、「似たエリア」を探すというストーリーにしています。

なお、例自体はシンプルなのですが、取り上げたサンプルは、他記事の手順の積み上げとなりますので、最初から手順をなぞると結構な分量になってしまいますが、ご容赦ください*1

手順

(1)検索データ作成

次の記事の手順にそって、Livedoorレストランデータを取り込んでください。

itdepends.hateblo.jp

これにより、レストランデータが、ldgroumetというインデックスに取り込まれることになります。

続いて、次のsignificant_termsクエリで、エリアとそのエリアにおいて特徴的なレストランカテゴリ情報を抜き出します。

POST /ldgourmet/_search?filter_path=agg*
{
  "aggs": {
    "a1": {
      "terms": {
        "field": "pref.raw",
        "size": 47
      },
      "aggs": {
        "a2": {
          "terms": {
            "field": "area_name.raw",
            "size": 100
          },
          "aggs": {
            "a3": {
              "significant_terms": {
                "field": "cates.raw",
                "size": 50,
                "min_doc_count": 5,
                "chi_square": {}
              }
            }
          }
        }
      }
    }
  }
}

// ↓ 戻り値イメージ

{
  "aggregations" : {
    "a1" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 39,
      "buckets" : [
        {
          "key" : "13__東京都",
          "doc_count" : 64565,
          "a2" : {
            "doc_count_error_upper_bound" : 0,
            "sum_other_doc_count" : 0,
            "buckets" : [
              {
                "key" : "多摩(府中・立川・八王子)",
                "doc_count" : 4554,
                "a3" : {
                  "doc_count" : 4554,
                  "bg_count" : 214237,
                  "buckets" : [
                    {
                      "key" : "醤油ラーメン",
                      "doc_count" : 218,
                      "score" : 266.40500798796563,
                      "bg_count" : 3635
                    },
                    {
                      "key" : "ラーメン",
                      "doc_count" : 657,
                      "score" : 217.22213013602024,
                      "bg_count" : 18050
                    },
                    {
                      "key" : "つけ麺",
                      "doc_count" : 170,
                      "score" : 202.84038655009383,
                      "bg_count" : 2862
                    },

                    以下省略

※ あたかもMLTを実行するには、significant_terms*2前捌きが必要かのように書いていますが、実際はそうではありません。 しかし、ここでは、ドキュメントの特徴的な情報をsignificant_termsで抜き出しておけば、特徴が際立ったデータ間どおしで類似度をMLTで判定すれば、よりハッキリMLTの動作が分かりやすいだろうという意図と単に手元の環境のデータ容量の都合だったりします。

戻り値のJSONテキストを、foo.txtとして保存しておきます。

(2)検索用データインポート

前項で取得したグルメ特徴データ(foo.txt)をMLT用にインデックスにインポートします。

(2-1) mapping設定

まず、インポートにあたり、前もって、MLTの対象とするフィールドを意識して、Elasticsearchに次のインデックスのmapping設定を行います。

PUT mlt_example2?include_type_name=false
{
  "mappings": {
    "properties": {
      "bkts": {
        "type": "text",
        "term_vector": "with_positions_offsets",
        "store":true,
        "analyzer": "whitespace"
      }
    }
  }
}

※1 MLTの対象フィールドは、keywordかtextかでいうと、アナライザーが実行されるtextが対象になります。これは、MLTが「類似文書検索」という名のとおり、フリーテキストAとそれに類似のフリーテキストを検索するという目的からすると自然なのですが、ここでは、ある種のチューニング効果を狙うのと、加工済みデータから作成したデータというところの都合もあり、フリーテキストではなく、単語の羅列になっています。

■ XXエリアのグルメ特徴データ(特徴カテゴリ一覧情報)

茶店 カフェ・喫茶 定食・食堂 お好み焼き その他郷土料理 お好み・もんじゃ・たこ焼き・そばめし 寿司 郷土料理 その他 洋食 焼肉 洋食・欧風料理

※2 この他、term_vectorやstoreなどいろいろポイントがあるのですが、この記事のストーリー独特かもしれない点だけ補足すると、今回取り込むデータは上記のようなsignificant_termsによる標準化されたワードの羅列なので、ここのワードの粒度は前処理で洗練されている前提で「whitespace」でanalyzeすることにしています。

(2-2) バルクロード

続いて、次のPythonスクリプトでグルメ特徴データをインデックス(mlt_example2)にインポートします。

他の用途のためのものを流用したので、今回は必要のない中途半端な汎用ロジックになっていて、その分冗長になっていますのでご容赦ください。

import glob
import pandas as pd
import json
import sys
import os

AGGS = "aggregations"
BKTS = "buckets"
aNames = ['a1','a2','a3']


def myAggs4DF(result):
    def _outerBuckets(result):
        return  result[AGGS][aNames[0]][BKTS]

    def _getInnerBuckets(_bucket, _inner_aNameNo):
            if aNames[_inner_aNameNo] in _bucket:
                return _bucket[aNames[_inner_aNameNo]][BKTS]
            return []            
            
    _bs = _outerBuckets(result)
        
    # aggs階層はa1、a2を基本として想定しているが、a3まで存在するなら、a2、a3でデータを構成するようにデータを一段掘り下げる
    aX = 1 
    if aNames[aX + 1] in _getInnerBuckets(_bs[0],aX)[0]:
        __bs = []
        for _b in _bs:
            __bs.extend( _getInnerBuckets(_b,aX))
        _bs = __bs
    
        aX += 1
    
    aggs = []
    
    for _b in _bs:
        k = _b['key']
        dc = _b['doc_count']
        bcks = list(_getInnerBuckets(_b, aX))
        innerBs = [ ib['key'] for ib in bcks]
        少数dc閾値 = 30
        戻り最大件数 = 12 if ( 少数dc閾値 < dc ) else 2
        aggs.append([k, dc, ' '.join(innerBs[:戻り最大件数])])
    
    return aggs,['k','dc','bkts']
    
files=glob.glob('foo.txt')
_f=files[0]
_r = open(_f, 'r').read()
aggs = myAggs4DF(json.loads(_r))
aDF = pd.DataFrame(aggs[0], columns=aggs[1])
aDF.to_csv(sys.stderr)
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk
endpoint = 'http://localhost:9200'
indexname = 'mlt_example2'
es = Elasticsearch(endpoint)

aDF['_index'] = indexname
aDF['_type'] = '_doc'
bulk(client=es, actions=aDF.to_dict(orient='records'))

(3)MLT 実行

ここまでの手順で、mlt_example2 というインデックスに、エリアとそのエリアにおいて特徴的なレストランカテゴリの一覧情報が入りました。

随分前置きが長くなりましたが、広島市エリアとグルメ的に似たエリアをMLT検索する」ということをやってみたいと思います。

なお、広島市エリアは、広島風お好み焼きや(かき料理などに関連すると思われる)和食や創作料理系のカテゴリに特徴・特色ありというデータになっています。

よって、

広島県内の他エリアや関西の粉物で有名なエリアがヒットするんじゃないか

という経験的期待のもとで進めていきます。

(3-1) 広島市エリアのIDを確認

今回、インポートしたデータではこんな感じでした。

      {
        "_id" : "f8E1mHMBqLsHNliUmwDU",
        "_source" : {
          "k" : "広島市",
          "dc" : 2008,
          "bkts" : "広島風お好み焼き お好み・もんじゃ・たこ焼き・そばめし 鉄板焼き お好み焼き 和食 創作料理 和食その他 その他和食 チーズ料理 スペイン料理 南欧・地中海料理 カニ"
        }
      }
(3-2) 広島市エリアを検索キーにしたMLT

MLTのクエリはこんな感じです。「広島市エリア」のドキュメントIDを指定して、これに似たドキュメントを検索するというクエリになっています。

※「bkts」というフィールドを「類似」スコアリングに使います。 ※ "include": true を指定して、「広島市エリア」自体も検索結果の戻り値に加わるようにしています。

GET mlt_example2/_search
{
  "size":10,
  "query": {
    "bool": {
      "must":[
        {
        "more_like_this": {
          "fields": [
            "bkts"
          ],
          "like": [
            {
              "_index": "mlt_example2",
              "_id": "f8E1mHMBqLsHNliUmwDU"
            }
          ],
          "min_term_freq": 1,
          "min_doc_freq":2,
          "max_query_terms": 25,
          "include": true
        }
      }
      ]
    }
  }
}

↓ 検索結果(整形済み)

{
  "hits" : {
    "hits" : [
      {
        "_score" : 28.441929,
        "_source" : {
          "k" : "広島市",
          "dc" : 2008,
          "bkts" : "広島風お好み焼き お好み・もんじゃ・たこ焼き・そばめし 鉄板焼き お好み焼き 和食 創作料理 和食その他 その他和食 チーズ料理 スペイン料理 南欧・地中海料理 カニ"
        }
      },
      {
        "_score" : 12.378284,
        "_source" : {
          "k" : "福山、尾道、備後",
          "dc" : 615,
          "bkts" : "広島風お好み焼き 鉄板焼き お好み・もんじゃ・たこ焼き・そばめし 欧風料理 アイスクリーム カニ モツ鍋 串揚げ・串かつ お好み焼き もんじゃ 焼き鳥 甘味処"
        }
      },
      {
        "_score" : 11.468344,
        "_source" : {
          "k" : "名古屋駅",
          "dc" : 741,
          "bkts" : "和食その他 その他和食 炉端焼き カニ モツ鍋 和食 甘味処 西洋料理その他 串揚げ・串かつ うなぎ うなぎ・どじょう 創作料理"
        }
      },
      {
        "_score" : 11.249291,
        "_source" : {
          "k" : "高松市",
          "bkts" : "讃岐うどん そば・うどん類 和食 うどん 創作料理 和食その他 その他和食 カフェ 紅茶専門店 カニ ホルモン焼き おでん"
        }
      },
      {
        "_score" : 10.131022,
        "_source" : {
          "k" : "松山市",
          "bkts" : "カフェ お好み焼き カフェ・喫茶 お好み・もんじゃ・たこ焼き・そばめし ファーストフード 大阪風お好み焼き 広島風お好み焼き 鉄板焼き 居酒屋 クレープ 洋食 ファミリーレストラン"
        }
      },
      {
        "_score" : 9.382338,
        "_source" : {
          "k" : "徳島市",
          "bkts" : "創作料理 居酒屋 和食その他 居酒屋・ダイニングバー ジンギスカン その他 その他和食 串揚げ・串かつ うどん 欧風料理 カニ 洋食"
        }
      },
      {
        "_score" : 9.360852,
        "_source" : {
          "k" : "岡山市",
          "bkts" : "創作料理 ダイニングバー 割烹 和食その他 懐石・精進・料亭・割烹 洋食 居酒屋・ダイニングバー 居酒屋 その他和食 その他 炉端焼き カニ"
        }
      },
      {
        "_id" : "RsE1mHMBqLsHNliUmwHV",
        "_score" : 8.5516815,
        "_source" : {
          "k" : "高知市",
          "bkts" : "その他郷土料理 居酒屋 郷土料理 居酒屋・ダイニングバー 和食その他 創作料理 その他和食 その他 パーティー・宴会 無国籍料理 和食 洋食"
        }
      },
      {
        "_score" : 8.020034,
        "_source" : {
          "k" : "栄・新栄",
          "bkts" : "バー ワインバー 和食その他 その他和食 その他西洋各国料理 パブ 西洋料理 西洋料理その他 ジンギスカン フルーツパーラー スペイン料理 韓国料理"
        }
      },
      {
        "_score" : 7.942418,
        "_source" : {
          "k" : "河原町、四条大宮、御池、二条、京都",
          "bkts" : "京料理 懐石・精進・料亭・割烹 懐石料理 創作料理 割烹 甘味処 会席料理 甘味・パーラー 和菓子 お好み焼き 湯葉 鉄板焼き"
        }
      }
    ]
  }
}

定性的な結果としては、今回設置したデータの範囲では、確かに似ていると呼べる地域がヒットしているように思われます。

実のところこの記事での味付けであるsignificant_termsでの前捌きがあるため、純粋なMLTの実力が少し隠れてしまったところもありますが、ある程度は納得感がある結果だと思います。

一方で、実体験からくる主観を多少織り込んで見てみると、広島風お好み焼きと(関西風もしくは広義の意味での)お好み焼きの違いの差が思ったほど出なかったため、広島県内の他の地域は「類似」と見なされたものの、トップヒットではなかったというのは気になるところです。

例えば、お好み焼き宗派によっては、互いに一緒にしてほしくない(これらのエリアは似ていない)という意見がありそうです。

また、"名古屋駅"、"高松市"、"徳島市"あたりは、実際にそれらの地域を訪問した方にとっては(グルメ以外も含めた街の雰囲気・空気感からして)分からんでもないもの、"栄・新栄"エリアや "河原町四条大宮、御池、二条、京都"エリアは、"広島市"エリアと類似と言われた時に、これらより類似度が高いエリアがあっても良いのではという気もしないでもないです。

このような結果になった理由としては、(お好み焼宗教戦争は別の話に飛び火するのでそれ自体は深追いしませんが、)今回のMLTの比較条件に用いた各エリアの特徴語はsignificant_termsを使ってあらかじめ抽出したものというところが、逆にMLTのTF-IDFでの類似度比較に作用しなかったというのが理由の一つと思われます。

類似度を効率よく判定するために、今回はあらかじめsignificant_termsで特徴抽出しています。本来significant_termsではスコア順でより特徴的なものからの順序になりますが、一方で、類似ドキュメント検索用のインデックスに登録するにあたっては、今回は作業都合で、スコアが高いものも低いものも、1回の出現回数で箇条書きのフリーテキストの体裁で登録しているので、より特徴を表したグルメカテゴリとそうでないものについてある種メリハリがつかない状態になっていたのかなというところです。


現場エンジニアリング視点(いかに取り繕うか)での考察*3

ということで、MLTってだいたい良い感じなのではというインプレッションに至ったのですが、とちょっとした後悔として

広島市の類似エリア検索では、 "福山、尾道、備後"エリアや"安芸、廿日市広島県西部"エリア、"備北"エリアが 上位ヒットして欲しかったという (ヒットしたものもあったが、上位ではなかった)

というものが残ります。

もちろん、そのようになりがちな前処理データを用いたというところの副作用の可能性も大きく、MLTさんからみるとフェアではないという反論もあるところですが...

significant_termsをからめず、より素直に、MLTのパワーをアテにして、大元のレストランデータの地域ごとののべカテゴリ一覧情報を集計・束にしたフィールドを持つインデックスを作成し、これに対してMLTをしてみればよかったかもしれません。

あるいは、significant_termsの前捌きありの方向性*4で推し進めるとして、「(ある方面の)類似感の納得度を高める」とすると次のような工夫が考えられるのかなと思いました。

チューニングの方向性

  1. boolクエリ(のmust)として、インプットドキュメント側の上位ワードによるmatchやtermクエリとそもそものMLTのAND検索にする。
  2. 上位のワードを、配列に複数回挿入して、term_freqを増やし「重み」に見せかける。
  3. ワードを桁数少なめのハッシュ値やコード値に変換して、配列に複数回...(以下、前項と同様)。
  4. ベクトル表現にする。※これをやるなら最初からそうすべきではと思いつつ、アンサンブル学習的な美点はあるかも。
  5. 上位のワードは別フィールドにも再掲して、MLTの追加対象フィールドとする。(more_like_this.fields)

2、3などは、今回の作業都合で失われてしまった頻度による特徴をやや乱暴な方法で復活させる方法です。 挙げてはみたもののの、TF-IDFにおけるレア度による「貴重なワードは出現頻度が小さくても重みは大きい...」といった本来のメリットを阻害する、別の不確定要素が入り込みそうですね。

実際は1(および1の変種としての上位ワードが含まれるほど加点する絞込み検索DSLを設ける)や5が良いかな...

ということで、1の考え方にそって、検索クエリを見直してみます。 (MLTの検索結果を広島市エリアの「広島風お好み焼き」を特徴データとして含むようなエリアに絞り込む。)

GET mlt_example2/_search
{
  "size":10,
  "query": {
    "bool": {
      "must":[
        {
        "more_like_this": {
          "fields": [
            "bkts"
          ],
          "like": [
            {
              "_index": "mlt_example2",
              "_id": "f8E1mHMBqLsHNliUmwDU"
            }
          ],
          "min_term_freq": 1,
          "min_doc_freq":2,
          "max_query_terms": 25,
          "include": true
        }
      },
      {
        "match": {
          "bkts": "広島風お好み焼き"
        }
      }
      ]
    }
  }
}

↓ 結果

{
  "hits" : {
    "hits" : [
      {
        "_score" : 31.89145,
        "_source" : {
          "k" : "広島市",
          "bkts" : "広島風お好み焼き お好み・もんじゃ・たこ焼き・そばめし 鉄板焼き お好み焼き 和食 創作料理 和食その他 その他和食 チーズ料理 スペイン料理 南欧・地中海料理 カニ"
        }
      },
      {
        "_score" : 15.278094,
        "_source" : {
          "k" : "福山、尾道、備後",
          "bkts" : "広島風お好み焼き 鉄板焼き お好み・もんじゃ・たこ焼き・そばめし 欧風料理 アイスクリーム カニ モツ鍋 串揚げ・串かつ お好み焼き もんじゃ 焼き鳥 甘味処"
        }
      },
      {
        "_score" : 13.580544,
        "_source" : {
          "k" : "松山市",
          "bkts" : "カフェ お好み焼き カフェ・喫茶 お好み・もんじゃ・たこ焼き・そばめし ファーストフード 大阪風お好み焼き 広島風お好み焼き 鉄板焼き 居酒屋 クレープ 洋食 ファミリーレストラン"
        }
      },
      {
        "_score" : 10.363851,
        "_source" : {
          "k" : "備北",
          "dc" : 84,
          "bkts" : "広島風お好み焼き お好み・もんじゃ・たこ焼き・そばめし 喫茶店 お好み焼き カフェ・喫茶 焼肉 中華料理その他 その他中華料理 カフェ 洋食 中華料理 洋食・欧風料理"
        }
      },
      {
        "_score" : 9.34375,
        "_source" : {
          "k" : "安芸、廿日市、広島県西部",
          "bkts" : "広島風お好み焼き ファミリーレストラン お好み・もんじゃ・たこ焼き・そばめし 定食・食堂 会席料理 回転寿司 うどん お好み焼き 洋食 ステーキハウス 和食 カフェ"
        }
      }
    ]
  }
}

広島の地名と瀬戸内海を挟んで向かいのグルメ文化圏的に近いと思われる松山市が入り込んだランキングになりました。

臭いものに蓋をした感はありますが、なかなかですね。

1や5は、賛否両論ありそうな「類似」判定例を間引くことができますので、オトナのエンジニアリング世界では欠かせない、「ご説明の都合」がつきやすいです。

もちろん、「気づきや意外な発見」のようなものは失われる面もありますが...

殺伐とした世界(?)で生き残っていくには、このようなケンカ殺法もうまく活用していきたいところです。

以上

*1:せめて本例に絞った直前の生データを公開できれば良いのですが、思わぬところで利用規約に反しても困るので、今回は控えます

*2:significant_terms自体の例は、

itdepends.hateblo.jp

でご紹介しています。

*3:「言い訳」ですね...

*4:significant_termsを使ってある種タグクラウド相当の情報が得られることで、MLTの類似度判定が実際に腑に落ちるかを感覚的に確かめやすいという検討の進め方における隠れたメリットがあります。