例題26-1:見せてもらおうか、PsychToolbox由来の新キーボードモジュールの性能とやらを¶
A: すんごく久しぶりの更新です。前回が2015年8月だから4年近いブランクです。みなさんいかがお過ごしでしょうか?
B: これだけ間が空いちゃうと前に見ていた人はもう見ていないと思いますよ。えーと、みなさま初めまして。ある時はなーんにもわからない学生、またある時は習ってもいないC言語のコードに的確なツッコミを入れられる切れ者、作者の都合で変幻自在なご都合キャラのBです。よろしくお願いします。
A: で、さっそく本題なんだが。
B: えー。久しぶりの登場なんだしもう少しどうでもいい話しましょうよー。
A: 私はB君と違って余裕がないのだ。○○○とか○○○とか○○○○とか○○○○○○○とか、本当にもう勘弁してほしいのだ。
B: その思わせぶりな伏字はなんなんですかね。ぱーっとばらしちゃえばいいのに。
A: そんな恐ろしいことできるもんかよ。こちとら生活がかかっているのだ。
B: ほほう、それはつまり… ごにょごにょ…
A: ごにょごにょ…
B: ふむふむ。それはご愁傷様ですな。ならさっさと話を進めますか。
A: うむ。そうしてくれたまえ。
B: してくれたまえって、今日は何の話をするのかぼくは知らないんですが。
A: 今日はまた反応時間に関する話なのだ。例題 11-1、 11-2、 13-1、 19-5 と散々取り上げてきて、また反応時間の話なのだ。
B: (なのだっていう語尾が気になる…)
A: なぜこのタイミングでまた反応時間を取り上げるかというと、2019年4月下旬にPsychoPyの3.1がリリースされ、なんとPsychToolboxのコードが組み込まれた新しいKeyboardモジュールが追加されたんだ。この話は以前からPsychoPy Discourseなんかで出ていたんだけど初めてこの計画を聞いた時には結構感慨深かったのだ。
B: PsychToolbox…?
A: 例題20-2で取り上げただろう。忘れたのか。
B: 例題20-2では なぜか ぼくの存在は抹殺されたのですが。Aさんこそ覚えていますか?
A: ほぇ?そんなことあったっけ。 まあそんなことどーでもいい。
B: どーでもいいってあんた。
A: PsychoPyとPsychToolboxのどちらを使うかを天秤にかけてPsychToolboxを選んだという人も多いだろうが、PsychToolboxを使うアドバンテージのひとつとして、キーボードのキー押し検出がPsychoPyより優れているというのがあった。
B: へえ、そうなんですか。初耳。
A: まぁ私はPsychToolboxをメインで使う予定がなかったので「ふーん」程度に聞いていたのだが、この度PsychToolboxのコードがPythonにportされてPsychoPyから利用できるようになったのだ。こりゃどの程度違うのか試さない手はないだろう?
B: そこですぐ試そうという発想になるのが変態ですなぁ。
A: ま、そうはいってもコードを解析して具体的に何をやってるのか理解するような時間はないし、工作をする元気もなかったので気になりつつ悶々としていたのだが、どこかでPsychToolboxのキー押し検出はOSをすっ飛ばしてポートを直接読みに行ってるから速いというのを読んだんよ。どこで見たんだったか忘れちゃんたんだけど。
B: 出典はこまめにメモしておかないといけませんぜ、ダンナ。
A: で、だ。ポートを直接読みに行くんなら、OSからキー押しのイベントを受け取る従来のpsychopy.event.getKeys()と新しいモジュールを同一のプログラム内で実行したら 同一のキー押しを両方のモジュールで検出できるんじゃね? と思ったわけよ。それなら絶対的なlatencyを測るのは無理でも両者の時間差は測れるわけじゃん。あ、これはいけるかな、と思ったわけよ。
B: ?? もうすこし詳しく!
A: テキトーな図を書くとこうなるかな。
B: 「キー押し情報を取得」が2つありますね。
A: うむ。これについては例題○を…、って言おうと思ったら、よく見たら今まで「Pythonで心理実験」でこの話には触れていないな。Sが認知心理学会のチュートリアルで話したんだったけか。ぶつぶつ。
B: どうしました?
A: あ、あぁ。いま詳しく説明する時間がないのだが、簡単に言うと、キーが押されるとキーボードからキーボードの入力ポートに情報が伝わるわな。
B: 入力ポート?
A: USB接続のPCならUSBポートだよ。
B: ああ、そういうことですか。
A: で、図の白い矢印の流れだが、入力ポートに伝わった情報はOSに伝えられ、そこでええ感じにあれやこれやされてイベントキューに登録される。
B: AさんAさん、いくらなんでもええ感じにあれやこれやはダメなんじゃないですか。
A: んーっと、OSの大切な役割なんやが、B君はネット通販を使おうと思ってブラウザに個人情報を入力した時にその情報が他のアプリに流出してたら嫌やろ。
B: なにそれ怖い。なんですか藪から棒に。そんなの嫌に決まってるでしょ。
A: だろ? 今どきのOSは複数のアプリが同時に実行されているのは当たり前なんだが、あるアプリに情報を入力しているのにそれが他のアプリに伝わっちゃ困る。 どのアプリが入力を受け取るべきか交通整理をするのがOSの大切な役割 なのだ。
B: ほへー。
A: だから、OSはキーボードなどの入力デバイスからの情報を一手に引き受け、その情報を受け取るべきアプリを判断してリクエストされるまで保持しておく。この情報を保持しておくところのことをイベントキューとかメッセージキューとか呼ぶ。
B: んん? リクエスト? リクエストとは?
A: 最近はマルチコアとかマルチプロセッサとかいろんなのがあってややこしいんだが…。そうさな、擬人化して考えたまえ。OSさんとアプリさんがPCん中でお仕事をしてるとする。
B: ほうほう。OSさんやアプリさんってどういうキャラデザなんだろう。
A: そんなんどうでもええがな。ともかく、OSさんとアプリさんはそれぞれ別に仕事をしているので、うまいこと二人のタイミングが合うとは限らない。だから、OSさんが「アプリさんにメッセージが届いてるから後で見といてや」といってメールを送っとくわけだ。そして、アプリさんは自分の都合がいいタイミングでメールを確認して「ああ、こんなんが届いてたんか、ほなアレしよか」とやるわけだね。このメールボックスにあたるのがイベントキューというわけだ。
B: なぜにエセ関西弁…。
A: ともかく。キー押し情報が実験プログラムのプロセスのイベントキューに登録された後、実験プログラムがイベントキューを確認しに行った時点で初めて実験プログラムはキーが押されていたことを知るわけだ。これが従来のpsyhcopy.event.getKeys()の動作。
B: うーん、結構大変なんですねえ。
A: そう。これが「行儀がいい」アプリケーションの作法なのだ。この作法を守っておけば、OSにバグがあったりとか人間が操作ミスをしたりしない限り、入力した情報が他のアプリに勝手に盗聴される心配はない。だが、 反応時間を測るプログラムというはすげえ行儀が悪いことをしたい のだ。OSがイベントキューに情報を登録してくれるのを待つなんてまどろっこしい。OSも忙しいから登録がちょっと遅れるかもしれない。そのちょっとが憎らしくて仕方がない。
B: なんかすごい話になってきたな。
A: で、このたびPsychoPyに導入された、PsychToolbox由来のコードというのはまさにこの行儀が悪いことをするのだ。入力ポートに直接アクセスして、何食わぬ顔で情報を持って行ってしまう。
B: えー、そんなことしていいんですか。
A: だから行儀が悪いと言ってるだろう。行儀が悪いけど、そこまでしてパフォーマンスが欲しいのだ。
B: むむー。業が深いですな。
A: それで話は戻るが、入力ポートから直接情報を取ってくるという話を聞いて、こりゃ簡単にパフォーマンスの比較が出来るんじゃないのと思ったわけよ。
B: あ、そういえばそういう話をしてたんでしたっけね。忘れてました。
A: うむ。PsychToolbox方式では、入力ポートを覗き見するだけでキー押し情報はそのままOSへ伝えられる。ということは、普通にイベントキューにキー押し情報が登録されるはずだ。ならば、PsychToolbox方式でキー押し情報を取得した後、さらに従来のPsychoPyの方法でイベントキューから同一のキー押しの情報が取れるはずだ。
B: むむむ、わかるような、わからないような。
A: というわけで書いてみたのが以下のコードである。PsychoPy 3.1.0、3.1.1では日本語環境での実行に問題があり、Windowsでは64bit版しか対応していないので、64bitのPsychoPy 3.1.2以上で実行してほしい 。
行番号なしのソースファイルをダウンロード→ 26-1.py
1import psychopy.visual
2import psychopy.core
3import psychopy.event
4import psychopy.hardware.keyboard
5import random
6
7ptb_keyboard = psychopy.hardware.keyboard.Keyboard()
8
9win = psychopy.visual.Window(fullscr=True, units='height')
10stim = psychopy.visual.TextStim(win, height=0.05)
11clock = psychopy.core.Clock()
12
13target_keys = ['F', 'J'] * 50
14random.shuffle(target_keys)
15
16diffs = []
17
18for trial, target_key in enumerate(target_keys):
19 stim.text = "Trail {}\nPress key as quick as possible.\nReady?".format(trial+1)
20 stim.draw()
21 win.flip()
22
23 psychopy.core.wait(1.0)
24
25 stim.text = "{}".format(target_key)
26 stim.draw()
27 win.flip()
28
29 ptb_keyboard.getKeys(['f', 'j'], waitRelease=False)
30 psychopy.event.getKeys(keyList=['f','j'])
31
32 ptb_keyboard.clock.reset()
33 clock.reset()
34
35 ptb_get = False
36 event_get = False
37 while not (ptb_get and event_get):
38 if not ptb_get:
39 ptb_keys = ptb_keyboard.getKeys(['f', 'j'], waitRelease=False)
40 if len(ptb_keys) > 0:
41 ptb_rt = 1000 * ptb_keys[0].rt
42 ptb_get = True
43
44 if not event_get:
45 event_keys = psychopy.event.getKeys(keyList=['f','j'])
46 if len(event_keys) > 0:
47 event_rt = 1000 * clock.getTime()
48 event_get = True
49
50 stim.draw()
51 win.flip()
52
53 print('ptb:{:.1f}, event:{:.1f}, diff:{:4.1f}'.format(ptb_rt, event_rt, event_rt-ptb_rt))
54 diffs.append(event_rt-ptb_rt)
55
56
57with open('output.txt','w') as fp:
58 fp.write('diff\n')
59 for d in diffs:
60 fp.write('{}\n'.format(d))
B: おお、久しぶりのコードだ。緊張する。
A: まぁこの例題は上級向けだと思うので、psychopy.event.getKeys()でキー押しを取得する程度のコードはご理解いただけることを前提に解説する。一応ざーっと解説しておくと以下のような感じ。画面上にFかJの文字が表示されたら、出来るだけ早くその文字のキーを押すというのが実験参加者への課題だ。
1-5行目: モジュールのimport
7行目: PsychToolbox由来のキーボードモジュールの準備
9-11行目: 刺激描画ウィンドウと文字列表示、時間計測の準備
13-14行目: キー押し課題としてF, Jを50個ずつ、合計100個並べたリストを作って無作為にシャッフル
16行目: 時間差を記録しておくためのリストを準備
19-23行目: 教示?を1秒間表示する
29-36行目: 反応時間計測用の時計やフラグの初期化、教示画面中にキーが押された場合のためにここまでのキー押しを取得しておく
38-42行目: PsychToolboxの方法でキー押しを調べ、押されていたらフラグをセットして時刻を記録
44-48行目: 従来のpsychopy.event.getKeys()でキー押しを調べ、押されていたらフラグをセットして時刻を記録
53-54行目: 時間差を出力するとともにリストに追加
57-60行目: 時間差をoutput.txtというファイルに出力
B: ううっ、厳しい。
A: ここからがキモの部分。まず4行目、PsychToolbox由来の新しいキーボードモジュールはpsychoy.hardware.keyboardという名前である。これをimportする。そして7行目で使用する準備。psychopy.hardware.keyboard.Keyboard()でキーボードモジュールを作成して、ptb_keyboardという変数に代入しておく。いちいちPsychtoolbox由来の…のとかいうのが面倒くさいのでPTBキーボードと呼ぶことにしよう。
B: PTBはPsychToolboxの略ですね。ふむふむ。
A: で、次はいきなり29行目。PTBキーボードでのキー押しの取得はgetKeys()というメソッドを用いる。第1引数は検出するキーで、waitReleaseはTrueなら押したキーが離されるのを待つ。ここではFalseにして、とにかく押されたら直ちに検出するようにしている。
B: 離されるのを待つ? ということはキー押しだけじゃなくてキー離し(?)時刻も検出できます?
A: 出来る。が、今回の例題の話題ではない。この29行目はさっきも言ったように、押すべきキーが画面に表示される前にフライングしてキーが押された場合に、その情報を掃除しておくのが目的だ。
B: キー離し時刻計測のお話はいつかしてもらえるんですかね。
A: 時間があって、リクエストがあればな。んで32行目。PTBキーボードオブジェクトはMouseオブジェクトと同様に内部にClockを持っている。32行目のようにptb_keyboard.clock.reset()とすればこの時点を0.0としてキー押し時刻が計測される。
B: (まあキー離しの件はおいといて、まずはこっちの話を聞いておくか)
A:: 続いて39行目。今度は画面上に押すべきキーが表示されている状態で、PTBキーボードでキー押しを検出している。引数はさっきの29行目と同じだから問題ないだろう。今度は戻り値をptb_keysという変数に格納している。
B: ふむふむ。
A: 40行目。ptb_keysにはキー押し情報がシーケンスとして格納されているので、長さが0ならキー押し検出されず、0より大きければキー押しが検出されたということだ。if文で分岐する。
B: キーが押されていた場合の処理ですね。
A: 41行目。今回は押されたキーが正答かどうか判定しないので、最初のキー(ptb_keys[0])を使ってキー押し時刻を計測する。キー押し時刻はrtというデータ属性に格納されている。 B: リストには押されたキー名じゃなくてなんかよくわかんないオブジェクトが入ってるんですね? じゃあ押されたキーの名前が知りたい場合はどうしたらいいですか?
A: nameというデータ属性に格納されているぞよ。rtの値は他のPsychoPyのオブジェクトと同様、単位が秒なのでミリ秒に換算するために1000を掛け算している。そして42行目で「キーが押されたぞ」とフラグを立てておく。
B: ptb_get=Trueですね。
A: そう。んで、このキー押し検出をループしている37行目のwhile文なんだけど、PTBキーボードでキー押しを検出したことを示すptb_getと、従来のevent.getKeyで検出したことを示すevent_getの両方がTrueになったら終了する。両者の間に時間差があるという前提でプログラムを書いているのだから、このような工夫が必要となる。
B: なるほど。
A: で、後はもういいよね。両方の方法で計測したキー押し時刻の差を計算して変数diffsに詰め込んで、最後にファイルに書き出している。実際にこのファイルを実行してみた結果をヒストグラムにしてみたのがこれだ。
B: なんかずいぶん平らな分布ですね。
A: だな。面白いのが分布の範囲で、まず54行目を見ればわかるように、event.getKeys()の結果からPTBキーボードの結果を引いているので、この値が正ならPTBキーボードの方が速いわけだ。最小値が0.0より大きいので、一貫してPTBキーボードの方が速いということだ。私が気になっていた「PTBキーボードの方が優れている」という話は正しかったわけだ。
B: なるほど。これはイイ話ですね。
A: さらに、最大値が16.812msだったんだけど、まあだいたい16.6666...ms(=1/60s)までの範囲に収まっているってこと。これは 例題19-5 のioHubの挙動とそっくりなんよ。つまり、ここからは充分な検証をしていないので想像だが、従来のpsyhcopy.event.getKeys()が「遅い」理由ってのは要するに 例題11-1 からずっと悩まされてきた「キー押しイベントの取得タイミングがスクリーンのflipに制限される」問題にあるのであって、PTBキーボードはioHubと同様にこの問題を克服できるってことだ。
B: ふむむむむ。
A: コードを示したときに64bit版で動かすようにお願いした通り、PTBキーボードは64bit版のPythonをベースとしたPsychoPyでしか利用できない。しかし、その点さえクリアできればevent.getKeys()より確実に優れているので、PTBキーボードを使わない手はないと思う。幸い、PsychoPy 3.1以降ではBuilderで作った実験でも自動的にPTBキーボードが利用できるかを判定し、利用できるならPTBキーボードを使ってくれるようになっている 。利用できない場合は従来のevent.getKeys()を使う。
B: おお、これもイイ話じゃないですか。すごいすごい。
A: いまPsychoPyはPyhton2からPython3へ、32bitから64bitへの移行が進んでいるが、正直なところ現在32bitのPython2で満足している人は無理に移行する必要はないんじゃないのと思ってたんだよね。でも、これを見てたらそろそろ移行を勧めた方がいいのかなーという気がしてきた。まだPython3版特有のバグが残っているかもしれないので、現在の環境をごっそりアンインストールして移行するのはお勧めしないけど、適当なマシンにインストールして試してみると良いと思う。
B: ここで心強い味方が当サイトのPortable Pythonですね!
A: と言いたいところだが、まだ3.1.2のPortable版は作っていない。
B: なんだ、がっかり。
A: 今回、勢いに乗って一気にこの例題を書いたけど、ホントにいま余裕がないんよ。もう少し待ってほしい。
B: うーん。仕方ないですね。じゃあ今日はこれでお開きですか。
A: ん。最後にPTBキーボードがイベントキューとは違う方法でキー押しを受け取るということを実感してもらえるようにもうひとつスクリプトを紹介しておく。
B: 余裕ないんじゃなかったんですか?!
A: 26-1.pyからほんの数行書き換えただけだ。
行番号なしのソースファイルをダウンロード→ 26-2.py
1import psychopy.visual
2import psychopy.core
3import psychopy.event
4import psychopy.hardware.keyboard
5import random
6
7ptb_keyboard = psychopy.hardware.keyboard.Keyboard()
8
9win = psychopy.visual.Window(fullscr=False, units='height')
10stim = psychopy.visual.TextStim(win, height=0.05)
11status = psychopy.visual.TextStim(win, height=0.04, pos=(0,-0.4))
12clock = psychopy.core.Clock()
13
14target_keys = ['F', 'J'] * 5
15random.shuffle(target_keys)
16
17diffs = []
18
19for trial, target_key in enumerate(target_keys):
20 stim.text = "Trail {}\nPress key as quick as possible.\nReady?".format(trial+1)
21 stim.draw()
22 win.flip()
23
24 psychopy.core.wait(1.0)
25
26 stim.text = "{}".format(target_key)
27 stim.draw()
28 win.flip()
29
30 ptb_keyboard.getKeys(['f', 'j'], waitRelease=False)
31 psychopy.event.getKeys(keyList=['f','j'])
32
33 ptb_keyboard.clock.reset()
34 clock.reset()
35
36 ptb_get = False
37 event_get = False
38 while not (ptb_get and event_get):
39 if not ptb_get:
40 ptb_keys = ptb_keyboard.getKeys(['f', 'j'], waitRelease=False)
41 if len(ptb_keys) > 0:
42 ptb_rt = 1000 * ptb_keys[0].rt
43 ptb_get = True
44
45 if not event_get:
46 event_keys = psychopy.event.getKeys(keyList=['f','j'])
47 if len(event_keys) > 0:
48 event_rt = 1000 * clock.getTime()
49 event_get = True
50
51 status.text = 'ptb:{} event:{}'.format(ptb_get, event_get)
52
53 stim.draw()
54 status.draw()
55 win.flip()
56
57 print('ptb:{:.1f}, event:{:.1f}, diff:{:4.1f}'.format(ptb_rt, event_rt, event_rt-ptb_rt))
58 diffs.append(event_rt-ptb_rt)
59
B: で、これは何が違うんです?
A: フルスクリーンじゃなくウィンドウモードで実行するようにしたこと(9行目)、ptb_getとevent_getの値を表示するためのTextStimオブジェクトを追加したこと(11行目, 51行目, 54行目)が違いだ。あと試行数を減らして結果の出力も省略してある。で、実行したら刺激描画ウィンドウ以外のどこかをマウスでクリックして、刺激描画ウィンドウを非アクティブにしてほしい。
B: はいはい、…と。しましたよ。
A: そうしたら刺激描画ウィンドウが非アクティブな状態のまま、刺激描画ウィンドウにFかJが表示されている時にそのキーを押してみてほしい。
B: 押しました。こんな状態ですが。
A: うむ。…これ、ヤバイと思わないかね?
B: へ? 何がですか?
A: さっきB君が「なにそれ怖い」といった状態に、今まさになっているのだよ。刺激描画ウィンドウは非アクティブなのだから、いま押されたキーは本来他のアプリケーションが受け取るべき情報だ。この図の例ではデスクトップをクリックして非アクティブ化しているから、デスクトップが受け取るべき情報なんだ。従来のevent.getKeys()は行儀よくOSからイベントとしてキー押しを受け取るから、この情報はもらえない。だから刺激描画ウィンドウ下部に示された変数event_getの値はFalseのままだ。event_getがFalseのままなのでwhileループは終了せず、次の試行に進まない。それに対して…
B: ああ! 確かにptb_getの方はTrueになってるのにevent_getはFalseのままですね! ということはPTBキーボードはデスクトップが受け取るべき情報を受け取ったということ?!
A: その通り。これが「行儀が悪い」ってことなんだよ。
B: うーーーーーむぅ。なるほど…。こういう風に見せられると、ちょっと怖いですね。
A: そうだね。だからみんな気を付けて使ってほしい。そんなわけで、久しぶりの例題26-1はここまで。またネタがあればお会いしましょう。ではでは。
B: えっ、もう終わり? (慌てて)んでは、さようならー