月別アーカイブ: 2015年5月

gensimでLDA(Latent Dirichlet Allocation)

トピックモデルを試そうとgensimのLdaModelを使ってみたのですが、参考にした記事「LSIやLDAを手軽に試せるGensimを使った自然言語処理入門」は対象とするgensimのバージョンが古いようだったので、現状にあうようアレンジして試してみました。この記事で使ったgensimのバージョンは0.11.1です。

過去のgensimと今のものとでは、メソッド名等の命名規則が変わっています。旧来はcamel caseでしたが、今は小文字とアンダースコアの組み合わせになっています。たとえばnumTopicsという名前付き引数はnum_topicsに変わっています。

それ以外にも多少の違いはあるのですが、yuku_tさんのgistのコードを今のバージョンのgensimで動くように修正したのが以下になります。

#!/usr/bin/python
# -*- coding: utf-8 -*-

import logging
import sys
import os.path
import bz2

from gensim.corpora import WikiCorpus
from gensim.corpora.wikicorpus import filter_wiki
import MeCab

logger = logging.getLogger('jawikicorpus')
logger.setLevel(logging.INFO)

tagger = MeCab.Tagger()

DEFAULT_DICT_SIZE = 100000
ARTICLE_MIN_CHARS = 500

def jatokenize(text):
    node = tagger.parseToNode(text.encode('utf-8')).next
    while node:
        if node.feature.split(',')[0] == '名詞':
            yield node.surface.lower()
        node = node.next

def tokenize(content):
    return [token for token in jatokenize(content) if not token.startswith('_')]

class JaWikiCorpus(WikiCorpus):
    def getArticles(self, return_raw=False):
        articles, articles_all = 0, 0
        intext, positions = False, 0
        for lineno, line in enumerate(bz2.BZ2File(self.fname)):
            if line.startswith('      <text'):
                intext = True
                line = line[line.find('>') + 1 : ]
                lines = [line]
            elif intext:
                lines.append(line)
            pos = line.find('</text>') # can be on the same line as <text>
            if pos >= 0:
                articles_all += 1
                intext = False
                if not lines:
                    continue
                lines[-1] = line[:pos]
                text = filter_wiki(''.join(lines))
                if len(text) > ARTICLE_MIN_CHARS: # article redirects are pruned here
                    articles += 1
                    if return_raw:
                        result = text
                    else:
                        result = tokenize(text) # text into tokens here
                        positions += len(result)
                    yield result

        logger.info("finished iterating over Wikipedia corpus of %i documents with %i positions"
                     " (total %i articles before pruning)" %
                     (articles, positions, articles_all))
        self.numDocs = articles # cache corpus length

if __name__ == '__main__':
    logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s')
    logging.root.setLevel(level=logging.INFO)
    logging.info("running %s" % ' '.join(sys.argv))
    program = os.path.basename(sys.argv[0])
    if len(sys.argv) < 3:
        #print globals()['__doc__'] % locals()
        sys.exit(1)
    input, output = sys.argv[1:3]
    wiki = JaWikiCorpus(input)
    wiki.dictionary.save_as_text(output + "_wordids.txt")
    from gensim.corpora import MmCorpus
    MmCorpus.serialize(output + "_bow.mm", wiki)
    #del wiki
    from gensim.corpora import Dictionary
    id2token = Dictionary.load_from_text(output + '_wordids.txt')
    mm = MmCorpus(output + '_bow.mm')
    from gensim.models import TfidfModel
    tfidf = TfidfModel(mm, id2word=id2token, normalize=True)
    MmCorpus.save_corpus(output + '_tfidf.mm', tfidf[mm], progress_cnt=10000)
    logging.info("finished running %s" % program)

WikiCorpusクラスにはsaveAsTextと等価なメソッドが今は存在しないので、個別にDictionaryとCorpusをファイルに出力しています。

生成されたモデルをもとにLSI, LDAモデルを作成する流れは、メソッド名等を(たとえばPrintTopic→print_topic)書き換える点以外は元記事とほぼ変わりません。

また、LDAモデルの生成をマルチコアで実施するgensim.corpura.LdaMulticoreというクラスも用意されています。worker=2等とワーカースレッド数を指定できる点以外はLdaModelクラスと同じです。しかし、あまりよくスケールするわけではないようで、CPU論理コア数8の環境でworker=7としてみても対して速度が上がることはないようです(参考:Multicore LDA in Python: from over-night to over-lunch)。一応workerプロセスは指定しただけ起動はするのですけど。加えてマルチコアで処理した場合、出力されるログが少なくなって進捗状況がわかりづらくなるようです。

ところで以前書いたword2vecの記事でgensimのword2vecでのマルチスレッド動作について書いていなかったので、同様にworkerスレッド数を指定できることを追記しておきました。

また、Google C言語実装のword2vecのマルチスレッド処理について一つだけ気になるところがありました。それは、単純にファイルをバイト単位でスレッド数に分割し、処理している点です。単語の区切りを一切考慮しないので、その箇所がたまたま単語の途中であってもお構いなしに処理を進めてしまいます。

もっとも、出現頻度の少ない単語は捨てられるので、実用上問題になることはほとんどないはずではあります。運悪くUTF-8の途中で切られてしまい、なおかつ語彙として記録されてしまうと、gensimで読み込もうとするときにInvalidUnicodeErrorが発生してしまうので、その点だけは気を付ける必要があります。

LanguageToolで長音記号チェック

漢字変換ミスで、たまに長音記号がかな文字以外の後に置かれる事例を見かけたので、それをLanguageToolのルールにして取り込んでもらいました

現状ある日本語のルールをざっと見たのですが、うまい具合に表現する方法が思い浮かばなかったので、思い切ってメーリングリストで聞いてみました。

メイン開発者のDanielさんは親切で反応も早い方で、「正規表現でUnicodeの範囲を使えばできる」という方法を示してくれました。また、過去にも日本語のルールを書いているSilvanさんからは、JavaにおけるUnicodeのクラス表現(\p{IsXxx})を紹介してくれました。

これらを踏まえ、以下のようなルールをpull requestとして書き、無事masterにマージしてもらえました。

                <rule id="SINGLE-MARKON" name="長音">
                  <pattern case_sensitive="no">
                    <token regexp="yes">[^\p{IsKatakana}\p{IsHiragana}]+</token>
                    <token >ー</token>
                  </pattern>
                  <message>不適切な長音符</message>
                  <example><marker>リンカー</marker></example>
                  <example correction=""><marker>隣家ー</marker></example>
                </rule>

カタカナとひらがな以外が長音記号の前にあるとき、LanguageToolは警告を出します。

$ echo "隣家ー" |java -jar languagetool-commandline.jar -l ja-JP -c UTF-8
Expected text language: Japanese (no spell checking active, specify a language variant like 'en-GB' if available)
Working on STDIN...
1.) Line 1, column 1, Rule ID: SINGLE-MARKON[1]
Message: 不適切な長音符
隣家ー
^^^
Time: 529ms for 1 sentences (1.9 sentences/sec)

この機会に、Doc-ja Wikiの”LanguageTool使い方メモ“も若干修正しました。自前のgrammar.xmlを指定して起動する方法と、ソースコード上で変更をしたときにルールのテストをする方法について新た説明を加えています。

LanguageToolのリリースバージョンは現在2.9ですが、次期バージョン3.0ではgrammar.xmlの書式も変わっているため、いずれWiki内の説明もそちらに合わせて修正したいところです。

pyroongaの挙動

GroongaをPythonから扱うのにpyroongaを使ってみたのですが、いくつかハマリポイントがありました。

Groonga serverが必要

rroongaは単独で動作するのに対して、pyroongaはGroongaがserverとして起動している必要があります。

カラム名が予約語だとハマる

pyroongaはテーブル名をクラス名に、カラム名をアトリビュートにマッピングして動作します。したがって、”from”などの予約語がカラム名だとうまく動きません。あまりPythonには詳しくないのですが、これを回避する方法は思いつきませんでした。

複数語のクエリで期待した結果が返ってこないことがあある

いまいち原因がわからないのですが、”foo bar”というようなクエリをコマンドから与えた時に得られる結果と、queryメソッドで同じクエリを与えた時の結果が異なります。ちょっとよくわかりません。

対処: subprocess呼び出し

非常にダサい方法ではありますが、pythonからgroongaコマンドを直接呼び出すことで期待する結果が得られるようになりました。

#!/usr/bin/python3
# -*- coding: utf-8 -*-
import subprocess

grn = "/opt/groonga/bin/groonga"
db = "/home/knok/grmbox/index.db"
gcmd = "select --table Mail --match_columns body --query '%s'"

query = "test query"

cmd = [grn, db, gcmd % query]

#json = commands.getoutput(cmd)
json = subprocess.check_output(cmd)
print(json.decode('utf-8'))

これで期待通りの結果が得られるようになりました。

追記(2015/0512)

ロケールが設定されてない環境(CGIなど)でqueryがunicode文字列の場合、引数をきちんとUTF-8でencodeしてやる必要があります。でないと、UnicodeEncodeErrorが発生します。私はこれで数時間悩みました。