株式会社スマレジの開発部でスマレジのサーバサイドを作っています

閑話休題:ここ半年で登った山報告(2)

こんばんは!株式会社スマレジ 、開発部のmasaです。 また、日が開いてしまい申し訳ないです。。。

今回は、以前ブログで書いていたように、登山の報告です笑 前回のブログは↓から。

masa2019.hatenablog.com

霊仙山(登頂回数:7回)

masa一推しの山です。8月は流石に暑すぎて断念しましたが、基本月1くらいのペースで登っています。

yamap.com

僕が気に入っている理由ですが、この山は7号目以降が巨大なカルスト地形になっています。

f:id:masa2019:20211110220654j:plain
霊仙山のカルスト

とても広い草原地帯の中を歩くのがとても気持ちよく、晴れの日は暖かい日差しの中でお昼寝すると、めちゃくちゃリフレッシュできます。

アクセスですが、米原駅からタクシーで行くのがオススメです。 米原市は事前予約することで一律500円でタクシーが利用可能な「まいちゃん号」という制度があります。 (どこでもいけるわけではなく、いくつかのチェックポイント間だけですが・・・)

www.city.maibara.lg.jp

米原駅からまいちゃん号で醒ヶ井の養鱒場(ようそんじょう)まで移動して、山頂を目指すルートです。 大阪から出発しても始発の新幹線を使えば朝8時ごろには登り始められるので、意外と日帰りでも行けちゃいます。 (帰りは新快速を使えば交通費もある程度抑えられます)

伊吹山(2回)

「会社のみんなで登山に行こう!」第二弾で選ばれたのがこの山です。

yamap.com

ここは六甲山と同じく、登山道がかなりしっかり整備されていて初心者向きの山です。 でも、標高は1000メートルを超えており、コースもそれなりの長さがあるので、登っていて飽きないところもおすすめです。

また、伊吹山は花の保護地帯がたくさんあり、保護されたお花畑が多いのも特徴です。

f:id:masa2019:20211110221939j:plain
伊吹山の花
f:id:masa2019:20211110221523j:plain
伊吹山の花

もちろん景色も絶景です!

f:id:masa2019:20211110222228j:plain
伊吹山の8号目からの景色

また、山頂はお土産物屋さんやカフェ山小屋などもあり、食べ物飲み物をそこまで気にしなくてもOKなのもおすすめポイントです。

最後に

実は他にも登りに行っているんですが、今回はおすすめの二つの山をご紹介してみました。 これからはスキーのシーズンなのでしばらく登山はお休みになりそうですが、またおすすめの山があったら、折を見てご紹介します。

チケットの見える化にチャレンジしてみる(2-7: 準備編 BM25の実装)

こんにちは!株式会社スマレジ 開発部のmasaです。

投稿が空いてしまい申し訳ないです。うっかり先週の投稿を忘れてしまってました。。。汗

さて、今回はBM25の実装です・

BM25とは

BM25は一言で言うと「ある文書Qから見て、ある文書Dがどれくらい類似しているか」を表すスコアになります。 この時の文書Qを「クエリ」、文書Dを「ドキュメント」と言ったりします。

イメージとしては、クエリは検索エンジンに入れる文字列で、ドキュメントはネット上にある検索対象の記事が近いです。

このイメージのように、BM25は検索のアルゴリズムとしてよく使われるのですが、ここでは二つの文書がどれくらい類似しているかを比較することに用いてみます。

イメージとしては、redmineの各チケットをクエリとして、全チケットをドキュメントとして総当たりでスコアを出していけば、あるチケットから見た類似度が数値としてわかるようになります。

これを全チケットで実施すれば、任意の二つのチケットの類似度を図ることができます。

BM25の定義式

Wikipediaより。

https://wikimedia.org/api/rest_v1/media/math/render/svg/43e5c609557364f7836b6b2f4cd8ea41deb86a96

Wikiの引用で大丈夫か不安になる人もいそうですが、先日案内した参考文献のうち「情報検索の基礎」にも同様の数式があります。

masa2019.hatenablog.com

  • IDF(qi)は見た通り、qiというワードのIDFで、f(qi, D)は文書DにおけるqiのTFになります。
  • |D|は文書Dの単語数です
  • avgdlは今回の場合であれば比較したいredmineチケット群の平均単語数です。(単純に二つの文書だけで比較したい場合は二つの文書の単語数の平均値でOKです)
  • b, k1はパラメータです。算出結果を調整するためにこの数字を変えていきます。デフォルトではb = 0.75, k1 は1.2~2.0の間の数を用いられます。今回はk1 = 1.6で計算しています。

BM25の最大のポイントは単に単語出現数と逆頻度をかけるTF-IDFと異なり、文書長をavgdlで正規化し、その上でTFをパラメータで調整してから、IDFとの積の総和として文書の類似度を算出しているところにあります。要するに、全体集合として比較対象の二つ以外にも文書が存在していて、その中でこの二つがどれくらい類似しているのかを算出できるわけです。

通常、Webの検索で使用される際は全体集合が検索対象全体となるため、非常に膨大ですが、このテーマのようにredmineのような有限の文書集合では非常にこの性質がよく働きます。

実装

from janome.tokenizer import Tokenizer
from janome.analyzer import Analyzer
from janome.charfilter import *
from janome.tokenfilter import *

import json
import requests
import math
import itertools

class Tf:
    word = ""
    documentNumber = 0
    score = 0.0

    def getScore(self, word, documentNumber):
        if word == self.word and documentNumber == self.documentNumber:
            return self.score
        else:
            return 0.0


class Idf:
    word = ""
    score = 0.0

    def getScore(self, word):
        if word == self.word:
            return self.score
        else:
            return 0.0


def getDescriptionOfRedmineIssueByIssueId(id):
    """
    redmineのチケットをID指定で取得して、説明を返す。
    :param id: int
    :return: Bool|string 取得成功なら文字列、失敗ならFalse
    """

    headers = {'X-Redmine-API-Key': "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
    response = requests.get("http:/redmineのdomain/issues/" + str(id) + ".json", headers=headers)
    statusCode = response.status_code
    if statusCode >= 400:
        return ""
    text = response.text
    if len(text) < 1:
        return ""

    resJson = json.loads(text)
    if "issue" not in resJson:
        return ""

    issue = resJson["issue"]
    sentence = issue["description"]
    if sentence is None:
        sentence = ""
    return sentence


def culcBM25(targetDocumentNumber, queryDocumentNumber, vectors):
    bm25score = 0.0
    for queryWord in vectors[queryDocumentNumber]:
        currentTfScore = 0.0
        currentIdfScore = 0.0
        for idf in idfScores:
            currentIdfScore = idf.getScore(queryWord)
            if currentIdfScore != 0.0:
                break
        for tf in tfScores:
            currentTfScore = tf.getScore(queryWord, targetDocumentNumber)
            if currentTfScore != 0.0:
                break
        denominator = currentTfScore + k1 + (1 - b + b * targetDocumentLength / averageLength)
        molecule = currentTfScore * (k1 + 1)
        bm25score += currentIdfScore * molecule / denominator
    return bm25score


# 辞書定義
char_filters = [UnicodeNormalizeCharFilter(), RegexReplaceCharFilter('<("[^"]*"|\'[^\']*\'|[^\'">])*>', u'')]
tokenizer = Tokenizer()
token_filters = [CompoundNounFilter(), POSKeepFilter(['名詞']), LowerCaseFilter(), TokenCountFilter()]
analyzeInfo = Analyzer(char_filters=char_filters, tokenizer=tokenizer, token_filters=token_filters)

sentences = []

for i in range(72):
    if i == 0:
        continue
    sentences.append(getDescriptionOfRedmineIssueByIssueId(i))

# redmineチケットの説明を取得(ID指定)
sentence_v1 = getDescriptionOfRedmineIssueByIssueId(70)
sentence_v2 = getDescriptionOfRedmineIssueByIssueId(70)

# ベクトル化

vectors = []
wkVector = []
for wkSentence in sentences:
    if len(wkSentence) < 1:
        vectors.append([])
        continue
    wkVector = []
    for result in analyzeInfo.analyze(wkSentence):
        wkVector.append(result[0])
    vectors.append(wkVector)

# 全文書の単語一覧
wordList = set(list(itertools.chain.from_iterable(vectors)))

# TF及IDFの計算
tfScores = []
idfScores = []
for targetWord in wordList:
    idfScore = 0
    for index, tmpVector in enumerate(vectors):
        wkTfScore = 0
        if targetWord in tmpVector:
            idfScore += 1
        for currentWord in tmpVector:
            if targetWord == currentWord:
                wkTfScore += 1
        if len(tmpVector) < 1:
            score = 0
        else:
            score = wkTfScore / len(tmpVector)
        tf = Tf()
        tf.word = targetWord
        tf.documentNumber = index
        tf.score = score
        tfScores.append(tf)
    idf = Idf()
    idf.word = targetWord
    idf.score = math.log((len(vectors) - idfScore + 0.5) / idfScore + 0.5)
    idfScores.append(idf)

# パラメータ設定
b = 0.75
k1 = 1.6

# 平均文書長の計算
totalLength = 0
for tmpVector in vectors:
    totalLength = totalLength + len(tmpVector)

averageLength = totalLength / len(vectors)

# BM25の計算

targetDocumentLength = len(vectors[targetDocumentNumber])
bm25ScoreList = [[0] * len(vectors) for i in range(len(vectors))]

for targetDocumentNumber in range(1, len(vectors) - 1):
    for queryDocumentNumber in range(1, len(vectors) - 1):
        bm25ScoreList[targetDocumentNumber][queryDocumentNumber] = culcBM25(targetDocumentNumber, queryDocumentNumber, vectors)
        if bm25ScoreList[targetDocumentNumber][queryDocumentNumber] > 0:
            print("" + str(queryDocumentNumber) + " -> " + str(targetDocumentNumber) + " = " + str(bm25ScoreList[targetDocumentNumber][queryDocumentNumber]))

前半のベクトル作成部分は特に変わりはないです。 TF,IDFの計算はエンティティクラスを作成して、結果をオブジェクトの配列として管理するように変更しています。

BM25の計算部分については、見ての通り、culcBM25で計算実行し、それをとってきたredmine全体で実施しています。 もしこれを使用する場合は、予めチケットの中身を取ってきてDBやテキストファイルとして持っておくか、検索条件を絞るようにした方が良いです。 総当たりの計算に時間がかかるのもそうですが、apiで都度とってくると、redmineのサーバ側に非常に負荷がかかるので・・・。

これで、計算ロジック部分の解説はおしまいです。 計算結果の評価などもしたいのですが、流石に会社のredmineの結果を張るわけにもいかず。。。 肌感になり申し訳ないですが、これを試してみたところ、ある程度の精度で順位付けはしてくれます。 性質の異なる複数のプロダクトを抱えているSaaSであれば、全部ごちゃ混ぜにしたとしても同じプロダクトのチケットは上位にランク付けされますし、機能で絞っても同様です。

例えば、BM25用にある機能の重要ワードを記載した意味のないチケットを作成して、それの類似度を定期的に調べておくと、 ある瞬間から高スコアのチケットばかりになったりすれば、何か不具合が起きているかもしれません。 (学生時代はこういう調査に使う文書をアンカー(錨)と言ったりしました)

これで類似度まではでてくるようになったので、次はこれを見える化することを考えていきます。

チケットの見える化にチャレンジしてみる(準備編の参考文献)

こんばんは!株式会社スマレジ 、開発部のmasaです。

今回は、BM25の説明をしようかと思っていたのですが、前にも紹介したクラメソさんの記事をほぼ丸コピになってしまう内容だったので、理論編はなしで実装だけ紹介しようかと思います。クラメソさんのナイスな記事は↓を参照ください。

dev.classmethod.jp

参考文献

理論編の代わりに、というわけではないですが、シリーズが長くなってきたので、このシリーズに関連するmasaが使ってきた参考文献を紹介します。

情報検索のアルゴリズム

ここで紹介している内容は実装で必要な部分にフォーカスしていますが、tf-idfやBM25以外にも文書情報の類似性を評価する手法は、情報検索の分野には存在します。

そういった、情報検索については下記の本が網羅的に記載されていて、おすすめです。

www.kyoritsu-pub.co.jp

非常にボリュームが多いのですが、数学モデルとその説明が丁寧なので、ネットの説明だけでピンとこないときは、こちらを見るのがおすすめです。

グラフライブラリ(JS)

今、準備編では文書そのものの評価をしていますが、実際は出たスコアを見える化する作業があります。

今回はWebアプリとして作成する予定なので、JSのライブラリで見える化をします。

このブログで扱うのは、D3.jsです。

d3js.org

Data Driven DocumentのイニシャルをとってD3.jsと呼ぶのですが、これはmasaが学生の頃からある息の長いライブラリです。 データ可視化用ライブラリのイメージが強いですが、実際はjQueryと同じく、セレクタによるDOM操作も可能かつ、jQueryと異なり、SVGも操作できる汎用ライブラリだったりします。

こちらは公式のドキュメントが結構丁寧なので、参考書を見る必要はあまりないかなとも思うのですが、 D3.jsを用いてデータマイニングをするための入門書がありますので、紹介します。

D3.jsの使い方だけではなく、データを可視化するまでのプロセスについても事例ベースで説明があるので、これからデータ可視化を扱うプログラミングをやろうという方にはとっつきやすくていいかなと思います。 ただ、この本少し古いため、Alt JSとしてCoffee Scriptを採用しています。今はTypeScriptもありますから、ご自身の環境に合わせて取捨選択してもらえればと思います。

閑話休題:ここ半年で登った山報告(1)

こんばんは!株式会社スマレジ 、開発部のmasaです。

今日は、いつものチケット見える化シリーズをおやすみです。

たまには休日の過ごし方的な記事も挟もうかな、と思い今回はmasaの趣味の登山の報告です。

登山を始めたきっかけなどは、以前のブログでも書いているので、みてみてください。

 

masa2019.hatenablog.com

 

ここ半年で登った山

masaが登山を始めたのが3月なので、ちょうど今月で半年になります。

緊急事態宣言もあったので、なかなかTryできない時もあったのですが、ひとまず半年の振り返りをしてみようかなと思います。

ポンポン山

yamap.com

社会人になって、初めて登った山です。

初めの山にここを選んだ理由は、単純に住んでいたところからのアクセスが良かったこともあるんですが、「登山を始めよう!」となったときにいろいろ調べているうちに、こんなものに行き着きまして。

www.yamanosusume.com

このアニメの中で、「初めて登る場合は、絶対に登れそうな山にすること」というアドバイスがあり、学生時代に登った山よりも標高が低く山頂までのアップダウンも小さいポンポン山にしたんです。

masaはこのブログではあまり上げてないですが、アニメも結構見る方なんです。ただ、好きなアニメのジャンルが、

caroleandtuesday.com

とか、

www.netflix.com

とかなんで、好みのジャンルがちょっと離れているせいで、絵柄が可愛い系のアニメは積極的には見なかったんですが、「アニメなら流しながら仕事できるし、登山と関係なかったら切ったらいいや」と思って見てみたんです。(昔はジャンル問わずよく見てたんですけど流石に時間が。。。)

ところが実際見てみると、初心者が登山をする上でかなり大事なアドバイスが網羅的にされていて、めっちゃ役に立ちました。もちろんその後、ネットのハウツーサイトやYouTubeの解説も見てみたんですが、キャラクターが実際に登山に挑むケーススタディ形式になっている分、同じポイントでも結構伝わり方がリアルだなと感じました。

皆様も登山始めよっかな?と思った時に、見てみてください。ショートアニメなので1話15分くらいなので結構さくっと見れます。

 

妙見山天台山

yamap.com

妙見山は大阪と兵庫にかかる山で、日蓮宗のお山でもあります。

www.myoken.org

ポンポン山が行程的に物足りなさを感じたので、もう少し標高と距離を上げて手軽に挑める山ということで調べました。

妙見山はいくつかのコースがあるのですが、masaは見出しにもある通り、天台山コースにすることにしました。

noseden.hankyu.co.jp

 

妙見山の登山コースの中では一番長くアップダウンもあります。

また、能勢電鉄さんのページにもあるかもですが、このコースは道が分かりにくいところがいくつかあります。なので登るのを決めてから、地図アプリなどでポイントをしらべてからTryしました。

masaがこのコースにTryしたのは3月下旬で、雪もなく草もボーボーでもない時期でしたから、ポイントも簡単に判別することができましたが、真夏・真冬にこのコースに行かれる方は要注意だと思います。1度わかりやすい時期に登頂してコースをある程度覚えてから、真夏・真冬に挑むのが一番かなと感じました。

あと、ここは駅を降りて即登山道なので、(天台山コースは駅からちょっと距離ありますが)アクセスが良いのも魅力です。駅を降りたところで猪フランクフルトを出してくれるお茶屋さんがあったり、山頂の展望台にもご当地サイダーを売ってたりと、週末のお出かけにも良さそうでした。

六甲山(2回)

yamap.com

ポンポン山、妙見山が共に体力にやや余裕を残して登頂できたので、もう一ランク上げちゃえ!と言うことで挑みました。関西の山の代表格。六甲山です。

 

実はこの頃から、スマレジ 社内でも一部の社員で「登山しようぜ!」という動きになってきてて、最近登山を始めたmasaに「おすすめの山ない?」と話を振られて登りました。

2回なのは、1回目はロケハンで、僕一人で登っているからです。

補足ですが、ロケハンは強制とかではなく、基本masaは自分が登ったことない山に自分以外の人間を連れていくことをしたくないので、自分でやっています。(この時も「六甲山なら登る予定あるんで、OKですよ」って感じで行ってます)

山のコンディションは登るまでわからない部分が多いですが、少なくとも1回登ってコースの確認と危険地帯の把握はしておかないと、万が一けが人が出たりしたら、僕はかなり後悔して引きずると思うので。

ポンポン山にしようかなと最初は思ったのですが、メンバーが割と体力のある方ばかり(一番ないのがmasa)だったのと、登山の目的の一つに「綺麗に整備された山を見たい!」という要望があり、近畿で整備が行き届いていそうでアクセスしやすい山、ということで白羽の矢が立ちました。(なんでそんな要望が立つんや、という話は追々...)

登った感想ですが、妙見山と比べてもかなりハードでした。

ただ、近代登山発祥の地なだけはあり、ロックガーデンを始め、多くの山で揃えている難所(岩場・鎖場・急騰)を一通り体験できるような作りになっていて、正直感動しました。あれを維持するのも大変だろうな・・・と。

あと、降った後の有馬温泉が最高でした♨️

arimaspa-kingin.jp

 

長くなるので、今日はここで切らせていただきます。

次回はさらに難度を上げて1000m越えの低山に登っているので、その時のお話をしたいと思います。ただ、プライベートな記事が続くのも趣旨に逸れるので、BM25の実装が終わったあたりでやろうかなと思います。

 

 

チケットの見える化にチャレンジしてみる(2-6: 準備編 tf-idfの実装)

こんばんは!株式会社スマレジ 、開発部のmasaです。

まだまだ残暑厳しいですが、少しずつ涼しくなってきたことを感じます。 会社でも、暑さが和らいできたので、アウトドアイベントをしたいなーといった話が出るのですが、 緊急事態宣言もあるので、なかなか実現できない今日この頃です。。。

皆さんの会社では、コロナ禍のなかでどんなイベントをしていますか?

今回は、if-idfのpython + chasenでの実装になります。

tf-idfの実装

まずは実装を。

from janome.tokenizer import Tokenizer
from janome.analyzer import Analyzer
from janome.charfilter import *
from janome.tokenfilter import *

import json
import requests
import math


def getDescriptionOfRedmineIssueByIssueId(id):
    """
    redmineのチケットをID指定で取得して、説明を返す。
    :param id: int
    :return: Bool|string 取得成功なら文字列、失敗ならFalse
    """

    headers = {'X-Redmine-API-Key': "redmineのキー"}
    response = requests.get("http://redmineのドメイン/issues/" + str(id) + ".json", headers=headers)
    statusCode = response.status_code
    if statusCode >= 400:
        return ""
    text = response.text
    if len(text) < 1:
        return ""

    resJson = json.loads(text)
    if "issue" not in resJson:
        return ""

    issue = resJson["issue"]
    sentence = issue["description"]
    if sentence is None:
        sentence = ""
    return sentence


# 辞書定義
char_filters = [UnicodeNormalizeCharFilter(), RegexReplaceCharFilter('<("[^"]*"|\'[^\']*\'|[^\'">])*>', u'')]
tokenizer = Tokenizer()
token_filters = [CompoundNounFilter(), POSKeepFilter(['名詞']), LowerCaseFilter(), TokenCountFilter()]
analyzeInfo = Analyzer(char_filters=char_filters, tokenizer=tokenizer, token_filters=token_filters)

sentences = []

# redmineチケットの説明を取得(ID指定)
for i in 取得したいチケットのIDを持った配列:
    if i == 0:
        continue
    sentences.append(getDescriptionOfRedmineIssueByIssueId(i))

# ベクトル化

vectors = []
wkVector = []
for wkSentence in sentences:
    if len(wkSentence) < 1:
        vectors.append([])
        continue
    wkVector = []
    for result in analyzeInfo.analyze(wkSentence):
        wkVector.append(result[0])
    vectors.append(wkVector)

# TF及びIDFの計算
tfScores = []
idfScore = 0
targetWord = "tf-idfで対象にしたい単語"
for tmpVector in vectors:
    wkTfScore = 0
    if targetWord in tmpVector:
        idfScore += 1
    for currentWord in tmpVector:
        if targetWord == currentWord:
            wkTfScore += 1
    if len(tmpVector) < 1:
        tfScores.append(0)
    else:
        tfScores.append(wkTfScore / len(tmpVector))

print(tfScores)
print(math.log(len(vectors) / idfScore))

getDescriptionOfRedmineIssueByIssueIdは前回の使い回しなので割愛します。 詳しくは↓

masa2019.hatenablog.com

TFの計算

TFの定義は前回のブログ

masa2019.hatenablog.com

こちらで記載しているように、ある文書に対するその単語の出現回数を全文書で割ったものになります。

masa2019.hatenablog.com

この部分に対応しているのが下記の部分になります。(上のソースからIDF関連の部分を除去したものになります)

# TFの計算
tfScores = []
idfScore = 0
targetWord = "tf-idfで対象にしたい単語"
for tmpVector in vectors:
    wkTfScore = 0
    for currentWord in tmpVector:
        if targetWord == currentWord:
            wkTfScore += 1
    if len(tmpVector) < 1:
        tfScores.append(0)
    else:
        tfScores.append(wkTfScore / len(tmpVector))

vectorsには各チケットの単語リストが入っています。(今回は70チケットあり、キーがチケットIDで値が単語リスト)

そして、各単語リストについて、検索対象の単語(targetWord)でマッチングをかけて、マッチした回数をwkTfScoreで記憶、 そして、その文書の全単語数で割っています。

IDFの計算

IDFの計算はもっとシンプルになります。 IDFは比較したい文書の集合(今回でいう70個のチケット)のなかでターゲットの単語が出現した数で全文書数(今回は70)を割ったものに対数をとってあげることになります。

# TF及IDFの計算
idfScore = 0
targetWord = "tf-idfで対象にしたい単語"
for tmpVector in vectors:
    wkTfScore = 0
    if targetWord in tmpVector:
        idfScore += 1

print(math.log(len(vectors) / idfScore))

ループの中では単語の出現した文書の数をカウントしておき、その結果を全文書数(len(vectors)))で割って対数をとっているのがわかると思います。

次回は、この二つのスコアをベースに使用するBM25について説明します。

チケットの見える化にチャレンジしてみる(2-5: 準備編 tf-idf)

こんばんは!株式会社スマレジ 、開発部のmasaです。

最近ふと振り返ってみると、僕がスマレジ に入ってもう二年半が経っていて、ちょっとびっくりしました。もうそんなに経っていたのか・・・。

年々時間の流れが早くなっている気がしますが、きっと気のせいですね(違

では、今日も続きをやっていきます。

単語の出現頻度から類似性を見つけるのがTF-IDF

前回のブログで紹介したコサイン類似度は単語の出現・非出現のみで2文書間の文書の類似度を決めていました。

masa2019.hatenablog.com

masa2019.hatenablog.com

ただ、これだとredmineのように「複数のチケットの中の2つのチケットの類似度」を知りたいケースでは不十分になることが多いです。 なぜなら、「比較したい文書全体(ここでいうところのredmineのissue」の中で二つがどれくらい似ているのかという観点が盛り込まれていないからです。

それを盛り込んだものにtf-idfがあります。

TF (term frequency)

読んで字のごとく、そのワードの出現頻度です。

計算式もシンプルで、ある単語Xの文書AにおけるTF=文書AのXの出現回数÷文書Aの全ワード数となります。 通例、文書Aの全ワードにはブロックリストで除外した単語は含めません。特に日本語の場合、「てにおは」は人によって使い方の違いがあるので、除外して影響を与えないようにするのが無難だと思います。

また、TFも値は0~1を取ります。(0は出現なし、1はその単語のみ出現)コサイン類似度と同じですね。

IDF (Inverse Document Frequency)

比較したい文書全体での単語の出現頻度の逆になります。つまり出現がレアなものほどIDFの数値は高くなります。

算出方法は


単語Xのidf = \log{\frac{比較したい文書の数}{単語Xの出現した文書数}}

になります。

参考

今回の説明ですが、僕のブログの流れに書き下して載せて説明はしていますが、他のブログでもっと詳細な解説をしてくれています汗 (このブログはmasaの勉強のために書いている部分も多いので、それはそれでいいんですが汗)

dev.classmethod.jp

こちらの記事が非常に直感的に分かりやすい説明でした!(内容ほぼ被ってます。。。そしてBM25の紹介くらいまではクラメソさんの記事に似通っちゃいます。)

今後の流れですが、テキストの類似度のお話が終わったら、見える化するためのJSライブラリ(D3.jsを考えてます)と見える化の実装、 もし余裕があったら、物理モデルを掛け合わせて使用したAppendixに挑戦する予定です。

チケットの見える化にチャレンジしてみる(2-4: 準備編 コサイン類似度の実装)

こんばんは!株式会社スマレジ 、開発部のmasaです。

再び緊急事態宣言が出てしまい、masaもリモートワークをしています。 ワクチンの摂取もまだ始まったばかりなので、集団免疫の獲得にはまだ時間がかかりそうな様子です。 出口の見えない不安な日々が続きますが、スマレジ では引き続き小売・飲食店のお客様へ向けて、 私たちにできることで、サポートしていきますので、レジやお店の運営で相談したいことがあれば、 弊社までお問い合わせください。

また、ウェビナーなどを通じて情報発信も行なっております。詳しくは弊社ホームページをご覧ください。

smaregi.jp

さて、今回は前回解説したコサイン類似度をpythonで計算してみます。

コサイン類似度の計算

ソースコード

from janome.tokenizer import Tokenizer
from janome.analyzer import Analyzer
from janome.charfilter import *
from janome.tokenfilter import *

import json
import requests
import math

def getDescriptionOfRedmineIssueByIssueId(id):
    """
    redmineのチケットをID指定で取得して、説明を返す。
    :param id: int
    :return: Bool|string 取得成功なら文字列、失敗ならFalse
    """

    headers = {'X-Redmine-API-Key': "<redmineのキー>"}
    response = requests.get("https://<redmineのドメイン>/issues/" + str(id) + ".json", headers=headers)
    statusCode = response.status_code
    if statusCode >= 400:
        return False
    text = response.text
    if len(text) < 1:
        return False

    resJson = json.loads(text)
    if "issue" not in resJson:
        return False

    issue = resJson["issue"]
    sentence = issue["description"]

    return sentence

# 辞書定義
char_filters = [UnicodeNormalizeCharFilter(), RegexReplaceCharFilter('<("[^"]*"|\'[^\']*\'|[^\'">])*>', u'')]
tokenizer = Tokenizer()
token_filters = [CompoundNounFilter(), POSKeepFilter(['名詞']), LowerCaseFilter(), TokenCountFilter()]
analyzeInfo = Analyzer(char_filters=char_filters, tokenizer=tokenizer, token_filters=token_filters)

# redmineチケットの説明を取得(ID指定)
sentence_v1 = getDescriptionOfRedmineIssueByIssueId(比較したいチケット番号1)
sentence_v2 = getDescriptionOfRedmineIssueByIssueId(比較したいチケット番号2)

# ベクトル化
vector1 = []
for result in analyzeInfo.analyze(sentence_v1):
    vector1.append(result[0])

vector2 = []
for result in analyzeInfo.analyze(sentence_v2):
    vector2.append(result[0])

# ベクトルの大きさを計算
scalar_1 = math.sqrt(len(vector1))
scalar_2 = math.sqrt(len(vector2))

# 内積の計算
innerProduct = 0
for word_1 in vector1:
    if word_1 in vector2:
        innerProduct += 1

# コサイン類似度を計算
cosineSimilarity = innerProduct / (scalar_1 * scalar_2)

# 出力
print(cosineSimilarity)

文書ベクトルの作成

vector1 = []
for result in analyzeInfo.analyze(sentence_v1):
    vector1.append(result.surface)

janome形態素解析された情報をresult変数一つ一つ取り出しています。 今回はTokenCountFilter()をフィルターに指定しているので、result[0]形態素そのもの、result[1]にその出現回数が入っています。 なので、result[0]を配列に入れていけば文書ベクトルの完成です。 これを比較したいチケットごとに実施しています。(vector1,vector2)

ベクトルの大きさの計算

masa2019.hatenablog.com

前回のブログで紹介したように、各ベクトルの大きさは単語の種類数、つまりそれぞれvector1,vector2の長さの平方根になります。 そのため、scalar_1 = math.sqrt(len(vector1))で計算されます。

内積の計算

# 内積の計算
innerProduct = 0
for word_1 in vector1:
    if word_1 in vector2:
        innerProduct += 1

内積は両方のベクトルでダブっている単語の数になるので、一方のベクトルでループを生成し、それぞれの形態素がもう一方のベクトルに入っているかを調べることで算出できます。

コサイン類似度を算出

最後に二つのベクトルのcosを算出します。

# コサイン類似度を計算
cosineSimilarity = innerProduct / (scalar_1 * scalar_2)

コサイン類似度は内積÷ベクトル1の大きさ×ベクトルにの大きさで算出されます。 詳しくは前回のブログをご覧ください。

実際に上記のプログラムで同じチケットを指定すると、出力は1になり、 類似しているチケットほど数値は1に近くなっていることを確認できると思います。