Nim言語を使って簡単に文章の類似度を計算してみる

皆さんは「Nim」という言語をご存知でしょうか?
Nimは「Pythonをブラッシュアップして、秘伝の悪魔のタレをかけたような感じ」と比喩されるような言語です。
そしてNimはC言語などにコンパイルされ、C言語などのコンパイラを使ってバイナリにコンパイルされます。
そんなNim言語を触っていきましょうか。

Nim言語について

公式サイトは以下です。
https://nim-lang.org/

文法はPythonに影響を受けており、インデントが意味を持ついわゆる「オフサイドルール」というものを採用しています。
また静的型付けです。

Hello World

とてもシンプルです。

echo "Hello World"

コンパイル

こちらも簡単です。
以下のような形になります。

$ nim c -r 保存ファイル名

nim cで「C言語コンパイル」を意味します。
-rコンパイル後即実行です。これを省略すると、バイナリコンパイルだけが行われます。

蛇足ですが、nim cC言語に、
nim cppC++に、
nim objcObjective-Cに、
nim jsJavaScriptコンパイルされます。

遊んで見る

少し自然言語処理のようなことをして遊んでみましょうか。
n-gramを自作してみましょう。

自然言語におけるn-gram

n-gramのnは変数です。
例えば、uni-gram、bi-gram、tri-gram、4-gram…のような感じになります。

例を見てみましょう。
例文:「今日はいい天気」

uni-gram

uni-gramでは以下のように分解されます。

今
日
は
い
い
天
気

bi-gram

bi-gramでは以下のように分解されます。

今日
日は
はい
いい
い天
天気

このような感じになります。

n-gramを作成するプログラムを書いてみる

以下のコードをtest.nimとして保存します。

import unicode

proc createNGram*(n: int, text: string): seq[string] =
    ##
    ## n-gramデータを作成します
    ## 
    ## n: n-gramのnに当たる数値
    ## text: n-gramに分解(コーパス)したい文字列
    
    # マルチバイト文字列を扱えるようにテキストをルーン化する(Goとかにもある、アレ)
    let runeText = text.toRunes()

    # 何文字目まで連結させたかを保持しておく変数(Runeは1文字ずつ扱うため、indexでどこまで扱ったかをカウントしておく)
    var index = 0
    # n-gramで何文字ずつの文字列(コーパス)にするかを決めるためのカウント変数
    var cnt = 0
    # n-gramでの文字列を作成する際に利用するtmp変数
    var tmp: string

    while true:
        # cntがn-gramの指定文字数を超えたらそこで切り出す(安全のため<=としている)
        if n <= cnt:
            # resultは暗黙変数(nimでは返り値を定義すると自動的にresult変数が生成される)
            result.add(tmp)

            tmp = ""
            cnt = 0

            # n-gramの特性上一つ前の文字をもう一度利用するので、n-1をしている
            index = index - (n - 1)
            
        
        if text.runeLen() <= index:
            break
        
        # 1文字ずつ連結していく
        tmp = tmp & $runeText[index]

        cnt = cnt + 1
        index = index + 1

echo createNGram(2, "今日はいい天気")

コマンドプロンプトやターミナルで以下のコンパイルコマンドを実行すると、
「今日はいい天気」という文字列がn-gramに分解されて表示されると思います。

$ nim c -r test.nim

@["今日", "日は", "はい", "いい", "い天", "天気"]

いい感じですね!
コメントにも書きましたが、このように分割したものを「コーパス」と呼んだりします。

さて、続きはこのコーパスをどのように利用していくかを見ていきましょう。

TF

Term Frequencyといえばなんかかっこよさげですが、意味は「その単語の出現回数」です。
「今日」という単語が何回出てきた?「天気」という単語が何回出てきた?
そんな意味です。

ではNimでそれを計算してみましょうか。 test.nimを書き換えます。

import unicode
import tables

proc createNGram*(n: int, text: string): seq[string] =
    ##
    ## n-gramデータを作成します
    ## 
    ## n: n-gramのnに当たる数値
    ## text: n-gramに分解(コーパス)したい文字列
    
    # マルチバイト文字列を扱えるようにテキストをルーン化する(Goとかにもある、アレ)
    let runeText = text.toRunes()

    # 何文字目まで連結させたかを保持しておく変数(Runeは1文字ずつ扱うため、indexでどこまで扱ったかをカウントしておく)
    var index = 0
    # n-gramで何文字ずつの文字列(コーパス)にするかを決めるためのカウント変数
    var cnt = 0
    # n-gramでの文字列を作成する際に利用するtmp変数
    var tmp: string

    while true:
        # cntがn-gramの指定文字数を超えたらそこで切り出す(安全のため<=としている)
        if n <= cnt:
            # resultは暗黙変数(nimでは返り値を定義すると自動的にresult変数が生成される)
            result.add(tmp)

            tmp = ""
            cnt = 0

            # n-gramの特性上一つ前の文字をもう一度利用するので、n-1をしている
            index = index - (n - 1)
            
        
        if text.runeLen() <= index:
            break
        
        # 1文字ずつ連結していく
        tmp = tmp & $runeText[index]

        cnt = cnt + 1
        index = index + 1


proc tf*(corpus: seq[string]): Table[string, int] =
    ##
    ## コーパスの中のTFを計算します
    ## 
    ## corpus: コーパスが格納されたseq配列を指定します

    for c in corpus:
        # 連想配列にその単語があれば1加算、なければその連想配列のキーを作成し、1を代入
        if result.hasKey(c):
            result[c] += 1
        else:
            result[c] = 1


# コーパスを取得
let corpus = createNGram(2, "今日はいい天気")

# TF値を計算
let tfTable = tf(corpus)
echo tfTable

これを先ほどと同じようにnim c -r test.nimコンパイルすると以下のような出力を得ることができます。

{"いい": 1, "はい": 1, "い天": 1, "日は": 1, "天気": 1, "今日": 1}

コーパスの出現回数が取得できました。
全部出現回数は1ですね。
そして順番は担保されていませんが、今回は順番は関係ないのでこのまま進めます。

文章の類似度を求める

2つの文章の類似度を求めてみましょう。
ここではメジャーなコサイン類似度を利用してみましょうか。
コサイン類似度の他にも、ユークリッド距離、マンハッタン距離などでも求めることができます。

コサイン類似度とは?

ざっくり説明します。本当にざっくりと。

コサイン類似度はその名の通りコサインを利用します。
コサインは、角度が0度に近づけば1、90度に近づけば0になるという特性があります。

要は2つの要素が近ければ(似ていれば)1に近づき、
離れていれば(似ていなければ)0に近づくといえます。

詳しくは数学系のサイトを見てみると良いでしょう。

コードを改変

以下の様にコードを改変していきます。

import unicode
import tables
import math

proc createNGram*(n: int, text: string): seq[string] =
    ##
    ## n-gramデータを作成します
    ## 
    ## n: n-gramのnに当たる数値
    ## text: n-gramに分解(コーパス)したい文字列
    
    # マルチバイト文字列を扱えるようにテキストをルーン化する(Goとかにもある、アレ)
    let runeText = text.toRunes()

    # 何文字目まで連結させたかを保持しておく変数(Runeは1文字ずつ扱うため、indexでどこまで扱ったかをカウントしておく)
    var index = 0
    # n-gramで何文字ずつの文字列(コーパス)にするかを決めるためのカウント変数
    var cnt = 0
    # n-gramでの文字列を作成する際に利用するtmp変数
    var tmp: string

    while true:
        # cntがn-gramの指定文字数を超えたらそこで切り出す(安全のため<=としている)
        if n <= cnt:
            # resultは暗黙変数(nimでは返り値を定義すると自動的にresult変数が生成される)
            result.add(tmp)

            tmp = ""
            cnt = 0

            # n-gramの特性上一つ前の文字をもう一度利用するので、n-1をしている
            index = index - (n - 1)
            
        
        if text.runeLen() <= index:
            break
        
        # 1文字ずつ連結していく
        tmp = tmp & $runeText[index]

        cnt = cnt + 1
        index = index + 1


proc tf*(corpus: seq[string]): Table[string, int] =
    ##
    ## コーパスの中のTFを計算します
    ## 
    ## corpus: コーパスが格納されたseq配列を指定します

    for c in corpus:
        # 連想配列にその単語があれば1加算、なければその連想配列のキーを作成し、1を代入
        if result.hasKey(c):
            result[c] += 1
        else:
            result[c] = 1

proc cosineSimilarity*(text1: string, text2: string, ngramNum: int): float =
    ##
    ## 文章の類似度を調べます
    ## 
    ## text1: 1つ目の文章
    ## text2: 2つ目の文章
    ## ngramNum: 何gramにテキストを分解するか
    ##

    # 文章をそれぞれコーパスに分解します
    let text1Copus = createNGram(ngramNum, text1)
    let text2Copus = createNGram(ngramNum, text2)

    # text2のTF値を求めます
    let text2Tf = tf(text2Copus)

    # コサイン類似度の計算に必要な分子分母の変数
    var c = 0.0
    var m1 = 0.0
    var m2 = 0.0

    for t1c in text1Copus:
        # text2のコーパスにtext1のコーパスがあるかないかで類似度を計算することにします
        # text2のコーパスにtext1のコーパスがあれば1、なければ0を使います
        var n = 0.0
        if text2Tf.hasKey(t1c):
            n = 1.0
        
        # コサイン類似度に利用する分子分母の数値を計算
        c += (1 * n)
        m1 += 1 * 1
        m2 += n * n
    
    # コサイン類似度の計算
    if m1 == 0 or m2 == 0:
        return 0
    result = c / round(sqrt(m1) * sqrt(m2))

# 2つの文章のコサイン類似度を計算する
# ------------------------------------

# bi-gramでは同じ文章なので完全一致(1.0)
echo cosineSimilarity("今日はいい天気ですね", "今日はいい天気ですね", 2)

# bi-gramでは文章の構成ワードが一緒なので完全一致になる(1.0)
echo cosineSimilarity("今日はいい天気ですね", "いい天気ですね今日は", 2)

# bi-gramでは土地名が違うだけなのでやや類似(0.8)
echo cosineSimilarity("渋谷でお買い物", "新宿でお買い物", 2)

# bi-gramでは完全に違う文章(0.0)
echo cosineSimilarity("スイカは果物", "ピーマンは嫌い", 2)

これで文章の類似性をざっくり出すことができました。
もっと丁寧に文章を解析する手法として「形態素解析」や「構文解析」などを用いる方法があります。
これをやると精度が上がります。

今回は入門ということでn-gramを使ってみましたが、
機会があればやってみたいですね。

終わりに

ちょっと詰め込みすぎましたが

  • Nim言語は良いぞぉ!
  • n-gramを使って文章の類似度を出す

ということを行いました。
これを機会に、よかったらNim言語に手を出してみてください!

それでは今回はこのへんで。
また次回!