静かなる名辞

pythonとプログラミングのこと

2019/03/22:TechAcademyがteratailの質問・回答を盗用していた件
2019/03/26:TechAcademy盗用事件 公式発表と深まる疑念


scikit-learnのSVMを自分で計算したカーネルで使う

はじめに

 多くの機械学習手法では入力される特徴量はベクトルで表されますが、ベクトルとして表現するのが難しい情報もあります。そのような場合でも、個体間の類似度さえ計算できれば機械学習を使えるというケースがあります。これが世にいうカーネル法です。

 カーネル法と言えばSVM, SVMといえばカーネル法。sklearnのSVCも、自作カーネルをサポートしています。さっそくやってみましょう。

データ

 以前MDSで可視化したhogehogeっぽい文字列とfugafugaっぽい文字列の編集距離のデータ(私謹製)を使います。

多次元尺度構成法(MDS)で文字列の編集距離を可視化してみる - 静かなる名辞

 編集距離(小さいほど近い)が得られているので、何らかの形で類似度(大きいほど近い)に変換してあげれば良いことになります*1

変換方法

 まあいろいろあり得ますが、今回はシンプルに-1をかけてみましょう。こんなのでも受け付けてくれるので大丈夫です。

実装

 リファレンスを見ながら使い方を考えます。

sklearn.svm.SVC — scikit-learn 0.21.3 documentation

 とりあえず、kernel="precomputed"のオプションを指定して類似度行列を渡せば使えそうです。

import Levenshtein
import numpy as np
from sklearn.svm import SVC
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import accuracy_score

def main():
    X = ["hogehoge", "hogghheg", "fogefoge", "hagbhcgd", "hogeratt",
         "hohohoho", "fugefuge", "hokehoke", "hogehope", "kogekoge",
         "fugafuga", "fugafuge", "fufufufu", "faggufaa", "fuuuuuuu",
         "fhunfhun", "ufagaguf", "agufaguf", "fogafoga", "fafafaoa"]
    label = np.array(["hoge"]*10 + ["fuga"]*10, dtype=object)
    le = LabelEncoder()
    y = le.fit_transform(label)

    A = np.zeros((len(X), len(X)))
    for i in range(len(X)):
        for j in range(i + 1, len(X)):
            d = Levenshtein.distance(X[i], X[j])
            A[i,j] = A[j,i] = d
    A = -A

    svm = SVC(kernel="precomputed")
    svm.fit(A, y)
    pred = svm.predict(A)
    print(y)
    print(pred)
    print(accuracy_score(y, pred))
   
if __name__ == "__main__":
    main()

""" =>
[1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0]
[1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0]
1.0
"""

 100%正解できました。学習データだから当たり前ですけど。

もう少し実用的に使う

 いちいち類似度行列を外側で計算するのは使いづらいので、違うやり方を考えます。

 なお、この辺の事情はユーザーガイドに多少載っています。これを自分で実装しないといけない方は、この辺を参考にしながら実装してください。

1.4. Support Vector Machines — scikit-learn 0.21.3 documentation

方法1:wrapクラスを作る

 正確にやろうとするとsklearnのestimatorとして相応の形式を整えないといけないのですが、そうでなければ簡単です。

import Levenshtein
import numpy as np
from sklearn.svm import SVC
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import accuracy_score
from sklearn.model_selection import cross_validate

class LevenshteinSVC:
    def __init__(self, svc):
        """SVCはkernel="precomputed"になっているとする
        """
        self.svc = svc
    
    def _kernel(self, X, Y):
        if X is Y:
            A = np.zeros((len(X), len(X)))
            for i in range(len(X)):
                for j in range(i + 1, len(X)):
                    d = Levenshtein.distance(X[i], X[j])
                    A[i,j] = A[j,i] = d
        else:
            A = np.zeros((len(X), len(Y)))
            for i in range(len(X)):
                for j in range(len(Y)):
                    d = Levenshtein.distance(X[i], Y[j])
                    A[i,j] = d
        A = -A
        return A

    def fit(self, X, y):
        self.train_X = X
        return self.svc.fit(self._kernel(X, X), y)

    def predict(self, X):
        return self.svc.predict(self._kernel(X, self.train_X))

    # cross_validateに投げるためにこれだけは投げやり実装
    def get_params(self, deep=None):
        return {"svc":self.svc}

def main():
    X = ["hogehoge", "hogghheg", "fogefoge", "hagbhcgd", "hogeratt",
         "hohohoho", "fugefuge", "hokehoke", "hogehope", "kogekoge",
         "fugafuga", "fugafuge", "fufufufu", "faggufaa", "fuuuuuuu",
         "fhunfhun", "ufagaguf", "agufaguf", "fogafoga", "fafafaoa"]
    label = np.array(["hoge"]*10 + ["fuga"]*10, dtype=object)
    le = LabelEncoder()
    y = le.fit_transform(label)

    svm = SVC(kernel="precomputed")
    lsvm = LevenshteinSVC(svm)

    lsvm.fit(X, y)
    pred = lsvm.predict(X)
    print(y)
    print(pred)
    print(accuracy_score(y, pred))

    res = cross_validate(lsvm, X, y, scoring="accuracy", cv=10)
    print(res["test_score"].mean())
   
if __name__ == "__main__":
    main()

""" =>
[1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0]
[1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0]
1.0
0.95
"""

 ほら、ちゃんとcross_validateで交差検証までできています。交差検証(ただし10分割なので限りなく甘い)でも95%正解できました。

 雑な実装なのとコードを見ればわかるので特に説明はしませんが、参考にしたい人は参考にしてください。

方法2:kernelにcallableを渡す

 本来はこちらの使い方の方が良いと思います。きっとこんな感じですね。

def _kernel(X, Y):
    if X is Y:
        A = np.zeros((len(X), len(X)))
        for i in range(len(X)):
            for j in range(i + 1, len(X)):
                d = Levenshtein.distance(X[i], X[j])
                A[i,j] = A[j,i] = d
    else:
        A = np.zeros((len(X), len(Y)))
        for i in range(len(X)):
            for j in range(len(Y)):
                d = Levenshtein.distance(X[i], Y[j])
                A[i,j] = d
    A = -A
    return A

# 略
lsvm = SVC(kernel=_kernel)

 でもfitさせようとすると、ValueError: could not convert string to float: 'hogehoge'が出ます。内部でcheck_X_yが呼ばれてしまい、floatに変換できない型は拒絶される訳です。なんてこった。

 なので、今回のように特殊なデータでやりたいという場合は、自分で類似度行列を計算して渡すことになると思います。逆に、floatで表現できるデータで行くときはこの方法で大丈夫です。

まとめ

 このようにscikit-learnでも自作カーネルを使えます。

 まあ、こういうことする人はだいたい研究でやっている訳で、研究でscikit-learn使うか?(libsvm自分で叩けばいいんじゃないの)という気もしますが、気軽にできることだけは知っておきましょう。

*1:しばらくこれに気づかなくて「なんでクソ精度なんだ?」って迷ってました。距離のままだと使えません