🤖

Char2Matrixの性質について

2022/07/17に公開

はじめに

前回に引き続き、Char2Matrixについて考えていきます。Char2Matrixってなに?新手の何か勉強すべき技術か?と思った方はすみません。期待の新技術とかではなく、私が妄想に妄想を重ねて独自に(?)提案した手法です。

Char2Matrixは、自然言語の文を対象にして、「あらゆる文を文字単位の行列積として表す」 手法です。「オイオイ、いきなり何を言いだすんだ頭イカれちまったのか」と思った方はそっ閉じして頂くとして、少しでも興味を持った方はお付き合いください。

今回、説明は改めて最初からしますが、一応前回の記事へのリンクを貼っておきます。

https://zenn.dev/azamshato/articles/7f4f93fb2cbea2

Char2Matrixに出来ること

任意長の文をひとつの正方行列に押し込めることが出来る

精度上の問題や消失・発散の危険は若干あるかもしれませんが、正方行列のサイズ次第ではそれなりに長い文でも保持できます。

概算してみた感じでは、48x48サイズもあれば100文字くらいは保持できそうです。

計算の根拠を示しておきます。まず行列は浮動小数点数を要素に持つのですが、最終的にはこれはfloat全域分の32bitを使い切ることは無いけれど、最低でもプラスマイナスの二極の値、つまり1floatにつき1bit程度は表すだろうと判断します。

そこで、utf8一文字の日本語の範囲が6万文字種程度で収まる=2byte(16bit)あればほぼ足りるということから、48x48の正方行列であれば16x3x48で16bitの文字が144個分収まる計算になります。

お・前・は・も・う・死・ん・で・い・る (各 48 x 48) -> 
お前はもう死んでいる (48 x 48)

任意の文の間で類似度を比較することができる

圧縮された行列は文字列単位の個性を保っているので、たとえば、「お前はもう死んでいる」と「あなたは既に死んでいます」の類似度は0.43くらいである。と計算することができます。

実動するコードを最後に貼りますが、これは妄想ではなく実際にそうなります

まず、これが出来るための条件として、各文字の持つ行列の値を出来る限り単位行列に近づけておく必要があります

お・前・は・も・う・死・ん・で・い・る

の各文字が単位行列Iに近いとき、「お」に対して「前・は・も・う・死・ん・で・い・る」もまた単位行列に近いので、「お」の成分がそのまま「お前はもう死んでいる」に残ります。

他の文字についても同じです。圧縮された行列に各文字の成分が残っているので、行列間でコサイン類似度を取ると構成文字が似ている文の間で似た類似度を弾き出します。

逆行列をかけることで任意の部分を取り出せる

さて、今「お前はもう死んでいる」は行列積の結果の行列として計算できました。精度的に欠損が無ければ、これはもうただの行列ですので、左から「(お前は)^-1(逆行列)」を掛けることで

(お前は)^-1・お前はもう死んでいる = もう死んでいる

が取り出せるはずです。そして、実際取り出せます。

今度は右から「(死んでいる)^-1」を掛けることで

もう死んでいる・(死んでいる)^-1 = もう

を取り出せます。そして、この逆行列による抽出はさらに応用が効きます。

逆行列をかけることで任意の「差分」を取り出せる

↑では、既知の文に対して既知の部分の逆行列を掛けることで中身を取り出しましたが、これだけでは面白くないので、思いきって取り出したい部分を適当なマスクで置き変えた文の逆行列を掛けてみます。すると、

お前はもう死んでいる・(誰がもう死んでいる?)^-1 ≒ お前は・(誰が?)^-1

になります。

……「イヤイヤ、そうはならんだろ」と思いましたか?「値が真っ当な通常の行列」を扱ってきた人にとっては、これはとても成り立たないおかしな計算で、見た瞬間怒り心頭せざるを得ないような荒唐無稽な絵空事かもしれません。

しかし思い出して下さい。私はChar2Matrixを扱うにあたって、「各文字の持つ行列の値を出来る限り単位行列に近づけておく必要があります」と述べました。つまり今、「お前はもう死んでいる」も「誰がもう死んでいる?」も、各要素である文字の行列はほぼ単位行列です。

なので、「誰が」をほぼ単位行列として無視してしまって、

お前はもう死んでいる・(誰がもう死んでいる?)^-1 
≒ お前はもう死んでいる・(もう死んでいる?)^-1
≒ お前は

としても、「大体」成り立ちます。もちろん「厳密には」成り立ち得ないのですが、後述するコードで実際に計算できてしまっていますのでご確認頂ければと思います。

Char2Matrixに出来ないこと

行列化された文は既知の辞書と比較することでしか復元できない

ここまで、当然のように「お前はもう死んでいる」に逆行列をかけて部分を取り出したりしていましたが、「お前はもう死んでいる」は行列として計算された時点で「『お前はもう死んでいる』っぽい何かの行列」に成り下がってしまっていて、もはや文字列として見えていません。

「『お前はもう死んでいる』っぽい何かの行列」が確かに「お前はもう死んでいる」という文だろうという判定は、「お前はもう死んでいる」からもう一度対応する行列を生成し、「『お前はもう死んでいる』っぽい何かの行列」と類似度を取って1であることを確認する以外に方法が無いです。

↑で

お前はもう死んでいる・(誰がもう死んでいる?)^-1 
≒ お前はもう死んでいる・(もう死んでいる?)^-1
≒ お前は

として「『お前は』っぽい行列」を取り出しましたが、これは厳密には「お前は」行列ではありません。これが他のどの語よりも「お前は」に似ていると確認するためには、確認したいあらゆる文字列を行列化し、「『お前は』っぽい行列」と類似度を取り、「お前は」に相当する行列との類似度が一番高い、という判定をするしか無くなります。

これは計算上ではデメリットかもしれませんが、言葉と1対1対応していない概念を表現できるという点では、メリットかもしれません。

実動するミニマルなコード

大体の性質を説明したので、この挙動が妄想でなく実際に動くものであることの証明も兼ねて、pythonでの実装コードを乗せます。numpyしか使っていないので起動時の初期化が重かったりしますが、検証程度なら問題なく動くでしょう。

import numpy as np

import pickle
import os

if __name__ == '__main__':
    save_as_pickle = False  # 次回起動時のために重みファイル(700MB程度につき注意)を保存するならTrueに

    unicode_cjk_japanese_range_end = 40960  # 簡単のため標準的な日本語utf8の範囲を使っているが、実用的には小規模なサブセットにするべき
    num_special_characters = 2  # 0: 0行列, 1:UNKNOWN
    total_characters_number = unicode_cjk_japanese_range_end + num_special_characters
    concept_matrix_size = 48

    pickle_path = 'char_matrices.pickle'
    if os.path.exists(pickle_path):
        with open(pickle_path, 'rb') as f:
            char_matrices = pickle.load(f)
    else:
        np.random.seed(1234)
        char_matrices = np.random.randn(unicode_cjk_japanese_range_end, concept_matrix_size, concept_matrix_size)
        char_matrices /= np.linalg.norm(char_matrices)  # 行列単位でなく全体のノルムを取り、ほぼ0に近い乱数へ
        char_matrices[0] = char_matrices[0] * 0
        if save_as_pickle:
            with open(pickle_path, 'wb') as f:
                pickle.dump(char_matrices, f)

    def char2code(char):
        code = ord(char) + num_special_characters
        if code > total_characters_number:
            code = 1  # special code as UNKNOWN
        return code

    def text2codes(text):
        return np.array([char2code(c) for c in text])

    I = np.eye(concept_matrix_size)

    def fold_concept_matrices(matrices):
        matrices = np.array(matrices)
        matrices = matrices + I
        result_matrix = I
        for matrix in matrices:
            result_matrix = result_matrix @ matrix
        result_matrix -= I
        return result_matrix

    def text2matrix(text):
        codes = text2codes(text)
        matrices = np.take(char_matrices, codes, axis=0)
        result = fold_concept_matrices(matrices)
        return result

    def invert_concept_matrix(mat):
        mat = mat + I
        mat = np.linalg.inv(mat)
        mat -= I
        return mat

    def most_similar_matrix_matrices(matrix, candidate_matrices):
        matrix = matrix.reshape((concept_matrix_size ** 2, -1))
        matrix /= np.linalg.norm(matrix, axis=0, keepdims=True)
        candidate_matrices = np.array(candidate_matrices)
        candidate_matrices = candidate_matrices.reshape((-1, concept_matrix_size ** 2))
        candidate_matrices /= np.linalg.norm(candidate_matrices, axis=-1, keepdims=True)
        similarities = candidate_matrices @ matrix  # コサイン類似度を取っているだけ
        similarities = similarities.reshape((-1, ))
        winners = np.argsort(similarities)[::-1]
        return winners, np.take(similarities, winners)

    def most_similar_matrix_texts(matrix, candidate_texts):
        candidate_matrices = [text2matrix(candidate) for candidate in candidate_texts]
        winners, similarities = most_similar_matrix_matrices(matrix, candidate_matrices)
        return np.take(candidate_texts, winners), similarities

    def most_similar_text_texts(text, candidate_texts):
        matrix = text2matrix(text)
        return most_similar_matrix_texts(matrix, candidate_texts)

    def cqa(context_text, q_text, answer_candidate_texts):
        sample_mat = text2matrix(context_text)
        sample_q = text2matrix(q_text)
        inv_sample_q = invert_concept_matrix(sample_q)
        sample_mat_extracted = fold_concept_matrices([inv_sample_q, sample_mat])  # 逆関数を掛けるだけで余分な要素を削除できる

        return most_similar_matrix_texts(sample_mat_extracted, answer_candidate_texts)

    winners, similarities = most_similar_text_texts('こんにちは世界', ['こんにちは世界', 'こんばんは世界', '東京特許許可局'])
    # [('こんにちは世界', 0.9999999999999988), ('こんばんは世界', 0.7396085375181379), ('東京特許許可局', 0.01841840579272322)]
    print(list(zip(winners, similarities)))

    winners, similarities = most_similar_text_texts('お前はもう死んでいる', ['あなたは既に死んでいます'])
    # [('あなたは既に死んでいます', 0.4330696388172398)]
    print(list(zip(winners, similarities)))

    # 単語自体は保持していないので解候補を用意して逐一比較する必要がある
    winners, similarities = cqa('評価関数はモデルの性能を測るための関数です',
                                '評価関数はモデルの何を測るための関数ですか?',
                                ['関数', '性能', '愛情'])
    # [('性能', 0.6403257310700666), ('関数', 0.031239041961416456), ('愛情', 0.01066968504285443)]
    print(list(zip(winners, similarities)))

    winners, similarities = cqa('評価関数はモデルの性能を測るための関数です',
                                '評価関数は何をするための関数ですか?',
                                ['モデルの性能を測る', 'モデルの身長を測る', 'モデルとして食っていく'])
    # [('モデルの性能を測る', 0.7194571486540424), ('モデルの身長を測る', 0.5170574947345881), ('モデルとして食っていく', 0.2508273809803828)]
    print(list(zip(winners, similarities)))

実装コードの細かいこと

各文字の行列は、実際には各自が単位行列Iに近くあってほしいのですが、学習などで変化する可能性も考えて、実用上は0に近い値にしておき、計算時にIを足します。

行列の実際の値は、何か文字の特性を学習したベクトルを利用した方が良いのかな?と考えていたんですが、今回やる程度の文の差分抽出程度なら全くランダムな値で大丈夫でした。

他にも細かいところがあったように思いますが、分からなければ聞いてもらえればと。

おわりに-今後の展望

Char2Matrixの性質を述べました。この性質を活かして何か応用的なことが出来ないだろうか?と考え、JGLUEのJSquadというQA質問解答タスクに挑戦してみたのですが、Char2Matrix+ルールベースで正答率30%程度でした(ベースラインはBERTの90%正答なのでボロ負け)。

まだ編集距離とかtf-idfとか使った方がマシな気がする……。

https://github.com/yahoojapan/JGLUE

文字行列をランダムな値から、例えば辞書などを使って「oo=xxxxxxxx」となるように学習できれば、あるいはwikipediaなどを使って普通のWord2VecのようにSkipGram等でより文字的に近い文字を近く判定できるよう学習させれば使いものにできるかな?とも思うのですが、学習時間がかかったり、そういった学習のセオリーを私が知らないこともあって上手くいっていません。

何か応用的な使い方を思いつく方がいらっしゃれば教えてもらえると助かります。

今回は以上です。

Discussion