Swift での自然言語処理

Apple は iOS5 からトークン分け、言語判定、品詞分解などの自然言語処理ができる API を開発者に公開しています。それに加わり Swift と PlayGrounds の登場により、Cocoa プラットフォームで自然言語処理が以前よりかなり快適にできるようになりました。今回は、Venmo の iOS チームである Ayaka Nonaka さんに Swift での NLP についてお話していただきました。発表では、どのように Swift でスパム検知を行うか調べ、その基本の理論から “Naive Bayes Classifier(単純ベイズ分類器)” の実装まで説明がされています。発表で使われているコードは GitHub にあります!


Naive Bayes Classifier(単純ベイズ分類器)

理論 (2:54)

機械学習の単純ベイズ分類器は、訓練データと言われる過去のデータをベースに処理が行われます。この理論は、与えられた事象が別の事象を引き起こす確率を計算するときに使うベイズ理論から来ています。 式は P(A|B) = P(A) × P(B|A) ÷ P(B) となります。この公式は、A と B が起こる確率を計算する式から得られます。

“スパム” & “ソディウム” の例 (5:16)

この確率の問題を解き、クラス分けにどう関係してくるのかを理解していくためにメールをスパムかどうか分類する具体的な問題を使って見ていくことにしました。 メールの全件数と ”spam” か “sodium” の単語を含むメールの数が与えられています。そして、”sodium” という単語を含むスパムのうち何件がスパムであるのか計算しようとしました。しかし、確率を計算する過程である問題にぶち当たりました。

そこで “条件付き独立性” を仮定することにより、確率の計算をもう少し簡単にして与えられている値を公式に当てはめることができました。今回の場合は、2つの単語について “クラス分け” を行っていますが、それでも確率を出すのに十分な情報がありませんでした、しかし、この仮定を置くことによって進めていけそうです。英語には無数の単語がありますが、今回考える “Naive” という仮定が、進めていく上で問題を避ける糸口になってくれました。

Code: NSLinguisticTagger! (13:28)

NSLinguisticTagger は、iOS5 から利用できる機能です。ちょうど iOS で Siri が導入されたときですので、Mac 用ではなく iOS 用だとも言えます。これはかなり便利な機能だと思います。NSLinguisticTagger は、”running” を “run” に変換するような “lemmatization(単語を見出し化)” と言われる処理を行えます。これは “was” が “is” に変換されるので、”stemming(語幹化)” とはまた違う処理です。それに加え、品詞分解のようなこともできます。与えたテキストを 名詞、形容詞、副詞のように分類分けをしていくことができます。また、言語判定も行えます。これらの処理が全てローカルのデバイス上で行え、他の言語で作られた API などを叩く必要がないのです。

記事の更新情報を受け取る

Live Coding (14:28)

Swift PlayGrounds を使い “Naive Bayes classifier” と “NSLinguisticTagger” についてコードを見ていきました。PlayGrounds のおかげで今までのようにテストを書いたりする必要なくコードを簡単に試すことができました。デモで使われたコードは ここ にあります!

“tag” 関数は、text と scheme を引数にとり、タグ付けしたトークンの配列を返します。”NSLinguisticTagger” ではまずオプションを設定します。今回は、空白と句読点と文字以外は無視する設定にしました。”TagScheme” を “NSLinguisticTagger” に与えることでインスタンスを生成します。トークン分けしたい文章をこのオブジェクトに渡し、トークンを受け取ります。中で新しいトークンとタグが配列に追加され返されます。

typealias TaggedToken = (String, String?) // Can’t add tuples to an array without typealias. Compiler bug... Sigh.

func tag(text: String, scheme: String) -> [TaggedToken] {
    let options: NSLinguisticTaggerOptions = .OmitWhitespace | .OmitPunctuation | .OmitOther
    let tagger = NSLinguisticTagger(tagSchemes: NSLinguisticTagger.availableTagSchemesForLanguage("en"),
        options: Int(options.rawValue))
    tagger.string = text

    var tokens: [TaggedToken] = []

    // Using NSLinguisticTagger
    tagger.enumerateTagsInRange(NSMakeRange(0, count(text)), scheme:scheme, options: options) { tag, tokenRange, _, _ in
        let token = (text as NSString).substringWithRange(tokenRange)
        tokens.append((token, tag))
    }
    return tokens
}

以下の関数では、”tag” 関数をそれぞれ異なる引数で呼んでいます。一つ目の “partOfSpeech” 関数は、品詞分解したものをタグ付けして返します。二つ目の “lemmatize” 関数は、単語の見出し語化したものをタグ付けして返します。三つ目の “language” 関数は、単語が何語なのかタグ付けしたものを返します。

func partOfSpeech(text: String) -> [TaggedToken] {
    return tag(text, NSLinguisticTagSchemeLexicalClass)
}

// returns I as a pronoun, went as a verb, to as a preposition, etc
partOfSpeech("I went to the store")

func lemmatize(text: String) -> [TaggedToken] {
    return tag(text, NSLinguisticTagSchemeLemma)
}

// turns "went" into "go"
lemmatize("I went to the store")

func language(text: String) -> [TaggedToken] {
    return tag(text, NSLinguisticTagSchemeLanguage)
}

language("Ik ben Ayaka") // tag is nl for Nederlands
language("Ich bin Ayaka") // tag is de for Deutsch
language("私の名前は彩花です") // tag is ja for Japanese

そして、ついに “Naive Bayes Classifier(単純ベイズ分類器)” に入っていきます。上の方には、確率を計算し記憶しておくための変数があります。”tokenizer” を引数として渡し初期化を行い、訓練フェーズとクラス分けのフェーズに移っていきます。訓練フェーズは、カテゴリ付けされた過去のデータを覚えさせていくフェーズです。一度それを行ってしまえば、あとは “クラス分け” に移っていきます。

訓練フェーズは特に面白くありません。”trainWithText” メソッドは、”trainWithTokens” メソッドを中で呼んで記憶しています。”trainWithTokens” メソッドの中では、すべてのカテゴリの確率を計算し、その確率が最大だったカテゴリに分類されます。”maxCategory” と “maxCategoryScore” を定義するところから始め、それらの値は各ループで更新されていきます。計算したスコアが、今までの最大値よりも大きい場合は、新しいカテゴリとしてセットし、”maxCategoryScore” も更新します。そして、最終的に一番大きかったカテゴリを返します。

// MARK: - Training

    public func trainWithText(text: String, category: Category) {
        trainWithTokens(tokenizer(text), category: category)
    }

    public func trainWithTokens(tokens: [String], category: Category) {
        let tokens = Set(tokens)
        for token in tokens {
            incrementToken(token, category: category)
        }
        incrementCategory(category)
        trainingCount++
    }

    // MARK: - Classifying

    public func classifyText(text: String) -> Category? {
        return classifyTokens(tokenizer(text))
    }

    public func classifyTokens(tokens: [String]) -> Category? {
        // Compute argmax_cat [log(P(C=cat)) + sum_token(log(P(W=token|C=cat)))]
        var maxCategory: Category?
        var maxCategoryScore = -Double.infinity
        for (category, _) in categoryOccurrences {
            let pCategory = P(category)
            let score = tokens.reduce(log(pCategory)) { (total, token) in
                // P(W=token|C=cat) = P(C=cat, W=token) / P(C=cat)
                total + log((P(category, token) + smoothingParameter) / (pCategory + smoothingParameter * Double(tokenCount)))
            }
            if score > maxCategoryScore {
                maxCategory = category
                maxCategoryScore = score
            }
        }
        return maxCategory
    }

以下が、訓練データです。このようなデータとカテゴリのセットを与えていきます。一度、データを読み込みトレーニングさせてしまえば、新しいデータを与えた時どのクラスに分類されるのか判断させることができるようになります。

nbc.trainWithText("spammy spam spam", category: "spam")
nbc.trainWithText("spam has a lot of sodium and cholesterol", category: "spam")

nbc.trainWithText("nom nom ham", category: "ham")
nbc.trainWithText("please put the ham and eggs in the fridge", category: "ham")

まず始めは、文章を空白で単語に区切ってクラス分けを行ってみました。次に以前作った、”partOfSpeech” 関数を使って改善を行い、最後は、”lemmatize” 関数を使ってさらに精度を上げていきました。改善を加えていく毎に、クラス分けの精度は上がっていくところが見れました。

let nbc = NaiveBayesClassifier { (text: String) -> [String] in
    return text.componentsSeparatedByString(" ")
}

nbc.classifyText("sodium and cholesterol") // "spam"
nbc.classifyText("spam and eggs") // "spam"
nbc.classifyText("do you like spam?") // "spam"

nbc.classifyText("use the eggs in the fridge") // "ham"
nbc.classifyText("ham and eggs") // "ham"
nbc.classifyText("do you like ham?") // "spam", INCORRECT
nbc.classifyText("do you eat egg?") // "spam", INCORRECT

// partOfSpeech
let nbc = NaiveBayesClassifier { (text: String) -> [String] in
    return partOfSpeech(text).map { (token, _) in
        return token
    }
}

nbc.classifyText("do you like ham?") // changed to "ham"
nbc.classifyText("do you eat egg?") // "spam"

// lemmatize
let nbc = NaiveBayesClassifier { (text: String) -> [String] in
    return lemmatize(text).map { (token, tag) in
        return tag ?? token
    }
}

nbc.classifyText("do you eat egg?") // now is "ham"!

関連するリンク (30:09)

Q&A (31:56)

Q: 確率を計算する前になぜ log を取られたのですか?
Ayaka: もし、log を取らずに計算していた場合、英語には無数の単語があるのでそれぞれの確率というものはとても小さい正の小数となります。それら全てを掛け合わせると、かなり小さな小数になり、小数の桁あふれを引き起こす可能性があります。log をとることで、コンピュータが表現できないような小さな数になることを避けて計算が行えます。

Q: Venmo でもこのような技術を使っているのですか?
Ayaka: いいえ、”Parsimmon” は完全に再度プロジェクトです。Venmo ではこれに関連した技術を今のところ使っていません。

Q: これをどうやって単語じゃなくてスパムっぽいフレーズなどに拡張していきますか?
Ayaka: 単語じゃなくて、単語の集まりとして見ることもできます。案としてバイグラム、隣接する単語のペアや複数の単語を固まりとして見ていきます。

Q: かなりたくさんの単語をマッピングしていますが、制限があるデバイスのメモリ上ではどのくらいスケールできますか?
Ayaka: このメソッドを実際にロードしてテストしたことはありません。是非試してみてください。ネット上には研究で使われるようなたくさんのサンプルがありますし、限られた環境の中で一度テストしてみて、どのように動くのか確認してみてください。

Q: NSLinguisticTagger ではどのくらい設定可能ですか? NLTK とは違った、”lemming” や “stemming” などの方法はありますか? それらと比較した時にどのようなオプションがありますか?
Ayaka: そんなに多くありません。オプションを見たところ、OmitWords、OmitPunctuation、OmitWhitespace、OmitOther などがあります。そんなに多いというわけではなく、今見せたことことぐらいしかできませんが、それでもこれはクールな機能だと思います。

Q: Siri ではサーバーとデバイスでそれぞれどのくらい処理がされていますか?
Ayaka: わかりません。Siri で使われているかどうかも私の推測です。Apple からはそのことについて何も発表されていません。


野中 彩花

VenmoのiOSリードで最近はSwiftばかり書いています。iOS 4の頃からiOS開発を始め、テイラー・スウィフトの曲を聴きながらSwiftを書くのが大好きです。これまでSwiftにおける自然言語処理や、スクリプティング、VenmoアプリをSwiftで書き直したことなどをテーマに講演しました。東京生まれなので、東京のカンファレンスで話せることがとても楽しみです!よろしくお願いします。