皆さんは「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 c
でC言語に、
nim cpp
でC++に、
nim objc
でObjective-Cに、
nim js
でJavaScriptにコンパイルされます。
遊んで見る
少し自然言語処理のようなことをして遊んでみましょうか。
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言語に手を出してみてください!
それでは今回はこのへんで。
また次回!