Oculus Goでコントローラーが認識しなくなったときの対処法

Oculus Goを起動するとたまにコントローラーが動作しなくなり、画面上に「コントローラーが見つかりません」とダイアログ表示されることがある。ダイアログにはバッテリーを確認するかサポートへ連絡しろと書かれているが、自分の場合はスマホのOculusアプリからコントローラーを一旦ペアリング解除し、再度ペアリングし直すと解決した。

OpenBCI Cytonのファームウェアをv3.x.x系にアップデートするときの注意点

Arduino IDEを使ってCytonのファームウェアをv3.1.0にアップデートしたら、ボードから送られてくる脳波データの値が全てのチャンネルで0.0になってしまうという現象にぶち当たった。
公式Forumの情報によれば、原因はファームフェアの転送に使われるChipKIT Coreのバージョンをv2系にしていたかららしい。
情報通り、ChipKITをv1.3.1にダウングレードした上で再度ファームウェアを転送したら正常なデータが送られてくるようになった。

この現象に関する公式Forumでのやり取りは以下。
http://openbci.com/index.php/forum/#/discussion/1495/no-data-after-firmware-update

形態素解析のための語彙辞書を作る

前回ChaSen形式のコーパスリーダーを作ったので、続いてコーパス中に出てくる単語を集めて語彙辞書を作ります。この辞書を形態素解析器の単語知識のソースにする予定です。

語彙辞書のフォーマット

まず辞書のフォーマットを次のように決めておきます。
・1行につき1単語を書く
・1単語についての情報量と表現形式はChaSen形式を使う
・単語は重複しない(つまり、見出し形、読み、原形、品詞、活用種別・型が全て同じ単語は複数存在しない)
・五十音順は特に気にしない

辞書の一部の例を挙げれば以下のような感じです。

戦い    タタカイ    戦う    動詞-自立   五段・ワ行促音便    連用形
戦い    タタカイ    戦い    名詞-一般
闘い    タタカイ    闘い    名詞-一般
闘い    タタカイ    闘う    動詞-自立   五段・ワ行促音便    連用形
たたかい    タタカイ    たたかう    動詞-自立   五段・ワ行促音便    連用形

読みが同じだけど見出し形が異なる単語や、品詞が異なる単語は別単語として扱います。

プログラムの流れ

コーパスから単語を取り出して語彙辞書を作るプログラム全体の流れは次のようになります。
1. コーパスリーダーを使って、コーパスを1ファイル開く
2. コーパスリーダーからコーパスに含まれる単語を全てもらう
3. 単語を一つずつ見ていき、初出の単語であれば語彙リストに追加する
4. 全てのコーパスファイルについて1〜3を繰り返す
5. 語彙リストの内容をファイルに書き出す

5で出力されるファイルこそが欲しい語彙辞書そのものです。
1〜4の部分だけを素直に実装すると次のような感じですね。

vocabulary = []    # 語彙リスト
for corpus_file in corpus_file_paths:
    r = ChasenCorpusReader(corpus_file)    # コーパスから単語抽出
    for word in r.words:
        if word not in vocabulary:    # 語彙リストにまだない単語であれば
            vocabulary.append(word)   # 語彙リストに追加する

ぱっと見簡単そうですが、ひとつ大きな落とし穴があります。それはこの部分。

if word not in vocabulary:

ここでのvocabularyはただのlistなので、この処理はvocabularyに入ってる要素を一つずつwordと比較します。だから当然vocabularyの要素数に比例して処理時間が増えます。要素数が10とか100とかなら気にするほどでもないですが、語彙を集めるともなればその数は数万とか十数万とかになってしまいます。手元のcore i7(3GHz)マシンでざっと測ったところ、vocabularyの要素数が5000になると、上記のword比較の処理時間が1wordあたり平均1ミリ秒ぐらいかかりました。これが語彙数が増えるにつれて線形で長くなっていって、数千語のコーパスを1ファイルを処理するのに数分かかるようになった時点であきらめました。
このように語彙をlistで持つのは性能面で決定的に無理があるため、別のデータ構造を使う必要があります。自然言語処理の語彙を保持するのに適した伝統的なデータ構造としてTrie(トライ)というものがあるようですので、まずこのTrieを実装し、現実的な処理時間で辞書が作成できるように頑張ってみます。

Trieの実装

Trieはツリー構造の仲間です。Trieのイメージ図と詳しい説明はWikipediaを見るのが分かりやすいとして、今回の語彙辞書の作成にとってTrieが持つ一番重要な性質は、キーに対する高速な検索性能です。Trieでは検索にかかる時間は格納している要素数に依らず、検索キー文字列の長さで決まります。つまり何万語の語彙を格納しようが、listのように単語が既に存在するかどうかの判定が遅くなっていったりしないという都合の良い性質です。
Trieは語彙の保持以外にもいろんな用途に使えますが、今回は辞書作りに最低限必要な「要素の追加」操作と、保持している要素のダンプ機能だけを実装します。ちなみにTrieの実装形態としてはダブル配列、LOUDS、組み込みdictを使った方法など幾つかの選択肢があります。最初は人気のダブル配列で実装してみましたが、残念ながらまだ力不足のせいで、Trieへの動的な要素の追加でどうしても速度が出せませんでした。今回は組み込みdictを使って実装します。dict方式は一番見やすい上にそこそこ早いので最初はとっつきやすいです。近いうちにダブル配列にもリベンジ。

  1 class Trie(object):
  2     def __init__(self):
  3         self._root = {} # ルートノード
  4         
  5     # 新規要素を追加する
  6     def insert(self, key, value):
  7         node = self._root 
  8         for c in key:
  9             if c not in node:
 10                 node[c] = {} # 文字cに対応する子ノードを作成
 11             node = node[c] #子ノードへ移動
 12             
 13         # リーフノードに値を格納
 14         if 'value_list' not in node:
 15             node['value_list'] = [] # 同一キーに対する複数の異なる値の格納に対応
 16         for v in node['value_list']:
 17             if value == v: # キー、値共に同一の場合は何もしない
 18                 return
 19         node['value_list'].append(value)
 20         
 21     # 保持している全ての(Key, Value)のタプルのリストを返す
 22     def dump(self):
 23         return self._dump(self._root, [])
 24         
 25     def _dump(self, node, key):
 26         data = []
 27         if 'value_list' in node:
 28             k = key[:]
 29             for v in node['value_list']:
 30                 data.append((k, v))
 31                 
 32         for label, child_node in node.items():
 33             if label != 'value_list':
 34                 child_key = key[:]
 35                 child_key.append(label)
 36                 data += self._dump(child_node, child_key)
 37                 
 38         return data

動作はPython3.4で確認しています。
今回実装したTrieの特徴は、同じキーに対して複数の値を持てるようにしたところです。これにより、同音異義語や、同じ見出し形だけど別の品詞を持つ単語を単一のキーで格納することができます。ただしその代わり、単語を追加するときには同一キーで既に追加している単語全てと比較して、本当にまだ追加されていない同音異義語かどうかを判定する必要があります。そのためTrieが持つ、検索性能が既に格納している要素数によらないという性質が若干損なわれていますが、若干の性能低下と引き換えに使い勝手の方を優先します。

さっそくTrieを使って先ほどの語彙リスト生成処理を書き直します。

vocabulary = Trie() # 語彙リスト。listに代わりTrieを使用
    for corpus_file in corpus_file_paths:
        r = ChasenCorpusReader(corpus_file)
        for word in r.words:
            vocabulary.insert(word.lemma, word) # 見出し語をキーにして単語を追加。
                                # 単語が既に登録済みかどうかはTrieの中でのキー検索により判定している。

語彙辞書を生成するスクリプト

Trieの実装によって語彙辞書を現実的な時間で作るめどがたったので、いよいよ今回の目的である語彙辞書を生成するプログラムを書いてみます。
このプログラムでは、前回作成したChasenCorpusReaderとChasenWordも使います。

  1 import sys
  2 import glob
  3 import os
  4 import argparse
  5 
  6 from chasen import ChasenCorpusReader, ChasenWord
  7 import Trie
  8 
  9 
 10 if __name__ == '__main__':
 11     # 起動パラメータ取得
 12     parser = argparse.ArgumentParser()
 13     parser.add_argument('-s', '--src_root_path', nargs=None, type=str, action='store') # コーパスファイルのフォルダを指定
 14     parser.add_argument('-f', '--file_pattern', nargs=None, type=str, action='store') # ファイル名パターンを指定
 15     args = parser.parse_args()
 16     
 17     # ChaSenコーパスファイルのパスを集める
 18     corpus_file_paths = []
 19     for dir_path, sub_dirs, file_names in os.walk(args.src_root_path):
 20         file_list = glob.glob(os.path.expanduser(dir_path) + '/' + args.file_pattern)
 21         for file in file_list:
 22             root, ext = os.path.splitext(file)
 23             if ext == ".chasen":
 24                 corpus_file_paths.append(file)
 25                 
 26     # コーパスから語彙を集めてTrieで保持
 27     vocabulary = Trie()
 28     for corpus_file in corpus_file_paths:
 29         print('extracting words form ' + corpus_file)
 30         
 31         r = ChasenCorpusReader(corpus_file)
 32         for word in r.words:
 33             vocabulary.insert(word.lemma, word)
 34             
 35     # 語彙をファイルに書き出す
 36     with open('.'.join(['out', 'vocab']), 'w') as f:
 37         for key_val in vocabulary.dump(): 
 38             word = key_val[1]
 39             if word.lemma in ['EOS', 'BOS']:
 40                 continue
 41             line = str(word) + '\n'
 42             f.write(line)

使い方は、

python make_vocabulary.py -s [コーパスファイルフォルダ] -f [ファイル名パターン]

[コーパスファイルフォルダ]で指定したフォルダ以下にある全てのchasenファイルから語彙を抽出し、語彙辞書を生成してout.vocabというファイル名で出力します。[ファイル名パターン]は特定のコーパスファイルだけをフィルタリングするために使いますが、普通は全てのファイルを表すワイルドカード「*」を指定します。
このスクリプトで作った語彙辞書ファイルを使えば、形態素解析のための語彙知識をコンピュータに与えることができるようになります。
次は単語同士の接続の仕方を定義する接続表の作成に取り組んでいく予定です。

[補足]ChasenWordクラスの拡張

話の流れの都合で後回しにしてましたが、実はChasenWordクラスにいくつかの機能を追加しないと上で作ったTrieと辞書生成スクリプトは正しく動きません。
追加する機能は、比較演算子__eq__()と、文字列変換の特殊関数である__str__()です。__eq__()はTrieの実装の17行目で呼ばれますが、ChaSen形式の単語を表すユーザー定義クラスであるChasenWordにとって、それらが等しいとはどういうことかを明示的に実装する必要があります。また、__str__()は語彙辞書生成スクリプトの41行目のstr(word)のところで呼ばれます。ChasenWordの__str__()ではChaSen形式の単語を表す文字列を返すのが自然です。

class ChasenWord(object):
    # 等値比較。見出し形、読み、原形、活用種別、活用形の全てが一致する場合に等値とする
    def __eq__(self, other):
        return ((self.lemma, self.pron, self.base, self.pos, self.conj_type, self.conj_form) ==
                (other.lemma, other.pron, other.base, other.pos, other.conj_type, other.conj_form))

    def __ne__(self, other):
        return not self.__eq__(other)

    # 以下は比較演算子の定義。見出し形で辞書順に並ぶように比較する
    def __lt__(self, other):
        return self.lemma < other.lemma

    def __le__(self, other):
        return self.lemma <= other.lemma

    def __gt__(self, other):
        return self.lemma > other.lemma

    def __ge__(self, other):
        return self.lemma >= other.lemma

    # 文字列変換。ChaSen形式の単語文字列を返す
    def __str__(self):
        _str = '\t'.join([self.lemma, self.pron, self.base, '-'.join(self.pos)])
        if self.conj_type:
            _str = '\t'.join([_str, self.conj_type])
        if self.conj_form:
            _str = '\t'.join([_str, self.conj_form])
        return _str

今回必要な拡張比較関数は__eq__だけですが、pep8の推奨に従って他の比較演算(__ne__, __lt__, __le__, __gt__, __ge__)も明示的に実装しています。
これで今回分の実装は一通りそろいました。

自然言語処理プログラムを自作していく


Pythonの勉強がてら日本語用の自然言語処理ライブラリを見よう見まねで作ってみます。
途中で根本的に間違う可能性もありますが、失敗も含めて勉強の過程を生々しく書いていけたらと思います。

まずは形態素解析器の実装を目指していきます。

形態素解析器を作るまでの道のり

形態素解析器を作るまでに、だいたい次のステップを踏んでいきます。

1.日本語の語彙を集めた辞書を作る
2.品詞同士の接続可能性を集めた接続表を作る
3.語彙辞書と接続表を使って形態素解析器を作る

それぞれの段階にはもっと広大で深淵な要素が含まれていると思いますが、まずはざっくり全体像を掴みながら作っていく方針です。

1.語彙辞書を作る

コンピュータが与えられた文字列の中から形態素候補となり得る単語を識別するために、あらかじめ世間で使われている単語を集めた辞書を作っておいて、それをコンピュータに入力してあげる必要があります。(ちなみに、この文章では「形態素」と「単語」は同じ意味で使ってます。)
世の単語を手作業で集めて辞書を作る気力はないので、ここはフリーの公開コーパスをありがたく使わせて頂きましょう。
今回は下記サイトで配布されているChaSen形式のコーパスjeita_genpaku.tar.bz2、jeita_aozora.tar.bz2)を単語のソースとして使わせて頂きます。
http://lilyx.net/nltk-japanese-corpus/#jeitac

コーパスって単語ごとに分割されてはいるけど基本は元の文章そのままなので、コーパス中に同じ単語が重複して現れていて、このままだと語彙の辞書としては不向きです。なので、コーパスから単語を取り出して重複を無くしたものを語彙辞書として別に作成します。
そのためには、まずChaSen形式のコーパスを読み込むリーダーの作成から始めます。

ChaSenコーパスリーダーの実装

ChaSenコーパスファイルの中をのぞくと、例えば次のような形式で書かれています。

私	ワタシ	私	名詞-代名詞-一般
の	ノ	の	助詞-連体化
テーマ	テーマ	テーマ	名詞-一般
について	ニツイテ	について	助詞-格助詞-連語
お話し	オハナシ	お話し	名詞-サ変接続
する	スル	する	動詞-自立	サ変・スル	基本形

コーパスファイル内では1行につき1単語の情報が書かれています。ChaSenコーパスのフォーマットに関する公式な仕様は見つけられなかったですが、実物を観察すると一つの単語を表すフォーマットは以下のようになってるみたいです。

見出し形[Tab]読み[Tab]原形[Tab]品詞1(-品詞2)(-品詞3)(-品詞4)([Tab]活用種別[Tab]活用形)

小括弧でくくった項目は、単語によってあったりなかったりする項目です。つまりタブで区切られた項目が4つまたは6つで構成されていて、品詞の部分はさらにハイフン区切りで1〜4段階に細分化されて記述されるということになります。
このChaSen形式の単語を表すChasenWordクラスを作ってみます。
開発環境のPythonバージョンは3.4.2です。

class ChasenWord(object):
    def __init__(self, raw_str):
        self.lemma = ''
        self.pron = ''
        self.base = ''
        self.pos = []
        self.conj_type = ''
        self.conj_form = ''
        self.is_bos = False
        self.is_eos = False

        tokens = raw_str.split('\t')
        if len(tokens) not in [1, 4, 6]:
            raise Exception('invalid corpus line : ' + raw_str)
        self._extract(tokens)
    
    def _extract(self, tokens):
        if tokens[0] == 'BOS':
            self.is_bos = True
            self.lemma = self.pron = self.base = 'BOS'
            return
        if tokens[0] == 'EOS':
            self.is_eos = True
            self.lemma = self.pron = self.base = 'EOS'
            return

        self.lemma = tokens[0]
        self.pron = tokens[1]
        self.base = tokens[2]
        parts = tokens[3].split('-')
        for p in parts:
            self.pos.append(p)
        if len(tokens) == 6:
            self.conj_type = tokens[4]
            self.conj_form = tokens[5]
        else:
            self.conj_type = ''
            self.conj_form = ''

ChasenWordクラスは、ChaSen形式の1単語分の文字列リテラルでコンストラクトし、中で文字列を解析して構成要素をメンバに保持します。ChaSen形式の単語は基本的にはタブで区切られた4また6個の値で構成されていますが、特別な場合としてセンテンスの終わりを示すEOSがあり、この単語(?)は1要素だけを持つようです。なのでChasenWordでは1単語の要素数が1、4、6個であることを想定し、それ以外はエラーにしてます。
動作確認すると以下のようになります。

>>> from chasen_word import ChasenWord
>>> chasen_str = '立ち向かう\tタチムカウ\t立ち向かう\t動詞-自立\t五段・ワ行促音便\t基本形'
>>> cw = ChasenWord(chasen_str)
>>> cw.lemma
'立ち向かう'
>>> cw.pron
'タチムカウ'
>>> cw.base
'立ち向かう'
>>> cw.pos
['動詞', '自立']
>>> cw.conj_type
'五段・ワ行促音便'
>>> cw.conj_form
'基本形'

1単語ずつ生成するのも使いづらいので、ファイル単位でコーパスを読み込んでそこに含まれるすべての単語からChasenWordを生成するChasenCorpusReaderも作っておきます。

import os
from nlang.corpus.chasen.chasen_word_b import ChasenWord

class ChasenCorpusReader(object):
    def __init__(self, file_path):
        self.words = None
        self._read(os.path.expanduser(file_path))

    def _read(self, file_path):
        with open(file_path, 'r') as f:
            self.words = [ChasenWord(line[:-1]) for line in f.readlines()]

これで最低限コーパスファイルを読み込んでデータを取れるようになったので、次はこれを使って辞書を作っていきたいと思います。