例題11-1:反応時間は正確に測れるか?

B:

A: どうしたB君、出し抜けに。

B: 確か前回の終わりに例題11はグラフの描き方をやると聞いたような気がするんですが…

A: あぁ、その、なんだ。反応時間は心理実験を語る上で避けて通れない超重要課題だからな、思い立ったらすぐにでも取り上げないと。

B: で? 今がその「思い立った」時なんですか。

A: そういうことにしておいてくれ。それで、今回は反応時間の話なわけだが。反応時間を計測したい場合はどうしたらいいかわかるかな?

B: へ? VisionEgg.time_func()で時刻をチェックすればいいんじゃないですか。

A: もう少し詳しく。

B: 詳しく? ええと、スペースキーが押されるまでの反応時間を計るのなら、スペースキーが押されたのを確認したら直ちにVisionEgg.time_func()を呼ぶ、でいいですか?

A: まず最初に反応時間を計測する起点になる時刻をVisionEgg.time_func()で得るってのが抜けてるがな。まあいいか。B君の案をpythonで書くとこんな感じかな。

isDone = False
startTime = VisionEgg.time_func()
while not isDone:
    for e in pygame.event.get():
        if e.type==KEYDOWN and e.key==K_SPACE:
            ractionTime = VisionEgg.time_func() - startTime
            isDone = True

B: ぼくならwhile notなんて書き方はしませんが…

A: 細かいことはどうでもいい。まあ素直に考えればこうだよな。

B: 素直に…って、これじゃ駄目なんですか? 今までこう書いてたんですけど。

A: ふむ。細かいことを言わなければ、これでいい。けど、細かいことを言うとこれじゃ駄目なんだよ。

B: ???

A: これじゃなんのことかさっぱりだろうから、わかりやすい例を挙げよう。

  • 行番号なしのソースファイルをダウンロード→ 11-1a.py

 1import VisionEgg
 2import VisionEgg.Core
 3import pygame
 4
 5from pygame.locals import *
 6
 7screen = VisionEgg.Core.get_default_screen()
 8viewport = VisionEgg.Core.Viewport(screen = screen, stimuli=[])
 9
10isDone = False
11
12starttime = VisionEgg.time_func()
13t = []
14while not isDone:
15    currenttime = VisionEgg.time_func()
16    t.append(currenttime-starttime)
17    for e in pygame.event.get():
18        if e.type==KEYDOWN and e.key==K_SPACE:
19            isDone = True
20    screen.clear()
21    viewport.draw()
22    VisionEgg.Core.swap_buffers()
23

B: …んん? ぼくが言った方法と違うような。ていうか、これ、反応時間採れます?

A: いや、これは反応時間を得るプログラムではなく、14行目からのwhileループが1周するのに要した時間をtというリストに保存するプログラムだ。まあ、いいから実行してみなさい。 そうそう、 プログラム終了後にtの値をチェックするのでIPythonからrun 11-1a.pyとして実行すること

B: はいはい。run 11-1a.py、と。…これ、スペースを押せばいいんですか?

A: 実行したら数秒待ってスペースを押してほしい。それでプログラムは終了する。

B: スペース、っと。

A:

B:

A: ん? どうした?

B: スペースを押しましたが終了しないんですが。VisionEggのウィンドウに(応答なし)とか表示されてますし。

../_images/11-1-01.png

A: ああ、そうか。IPythonで実行するとプログラムが終了してもscreenが解放されないからな。 IPythonのプロンプトからscreen.close()と入力 してくれ。そうするとスクリーンが閉じる。

B: close()、と。おお、こんな方法知りませんでした。

A: 普通に起動する場合は不要な作業だからな。さて、IPythonを使ってtの値を調べるわけだが。 そうだな、せっかくだからグラフ描画の練習もするか。えー、ごほん。IPythonを立ち上げた人は from pylab import * としてpylabを読みこんでください。 このページの図はpylabを立ち上げて描いていますが、pylabで立ち上げた方はすでにpylabがロードされているので改めてimportする必要はございません。

B: また誰に話してるんだかわからないセリフを…。

A: じゃあ、準備が出来たらまずtをプロットしてみるか。 pylab.plotはかなり柔軟な引数を持つ関数で、引数がひとつだけの場合は、その値を折れ線グラフとして描く。 引数がxだとしたら、横軸はrange(len(x))、縦軸はxだ。

In [12]: plot(t)
Out[12]: [<matplotlib.lines.Line2D object at 0x03E051D0>]
../_images/11-1-02.png

B: なんだかそっけないグラフですね。

A: プロットしてるデータが単調だからな。tの値はwhileループが始まってからの時間だから、こういうきれいな一直線を描くというのは一定の速度でwhileループが回っているという証拠だ。

B: ふうん。でもそれにしてもただ定規で線を引いただけみたいだなあ。

A: 話を進めるために一つだけ確認しておくが、グラフの横軸は0から始まって200ちょいで終わっている。 これはスペースキーを押すまでの時間に依存しているので毎回変化するが、ここでlen(t)としておくと今回実行した分ではtの長さが200強であることがわかる。 先ほど、引数が一つしかない時には横軸はrange(len( ))になるといったのはそういうことだ。

In [13]: len(t)
Out[13]: 214

B: ふむふむ。じゃあ横軸を指定する場合はどうしたらいいんですか。

A: 横軸の値を指定すればいい。例えばこんな風に。 そうそう、plotは何も指定しなければ一枚の図にプロットをどんどん重ねてしまうので、一度グラフのウィンドウを閉じてから実行してみてくれ。

In [14]: x = [sqrt(i) for i in range(len(t))]

In [15]: plot(x,t)
Out[15]: [<matplotlib.lines.Line2D object at 0x0471CCB0>]
../_images/11-1-03.png

B: おお、きれいな放物線になった。

A: なぜ放物線になるかはわかるよな? わからない場合はリストの内包表記(例題3-3)を復習すること。 とにかく、同じ長さのリストを対にしてplotに渡すと、第1引数が横軸、第2引数が縦軸の値としてグラフが描かれる。

B: なるほど。

A: もうひとつだけplotの機能を紹介して反応時間の話に戻るか。3番目の引数に、グラフの線やマーカーを表す文字列を指定することが出来る。 全部プロットすると点の数が多すぎるから最初の20要素だけプロットしてみるよ。

In [16]: plot(x[0:20],t[0:20],'rx:')
Out[16]: [<matplotlib.lines.Line2D object at 0x04C07590>]
../_images/11-1-04.png

B: おお、こうやって見ると完璧にきれいな放物線じゃないですね。この'rx:'ってのが指定なんですよね?

A: そう。1文字目から'r'が赤色、'x'がマーカーの形、':'は点線を示している。本来ならここでこれらのフォーマット文字を紹介するべきだが、どんどん反応時間の話から逸れてしまうからまたの機会にしよう。 さて、ここからがいよいよ本題だが、このtの隣り合う要素間の差を計算して、whileループを一周するのにどれだけ時間がかかっているかを確認しよう。 今までならここでforループを…といくところだが、ここでもpylabの関数を使う。 pylab.diff()関数は隣り合う要素の差を一気に計算してくれる。戻り値はnumpy.ndarray型の配列だ。

In [17]: dt = diff(t)

In [18]: type(dt)
Out[18]:

B: numpy.ndarray?

A: 実は例題2-1で一度触れているんだがな。まあそれは後ほど詳しく。当然だが、長さは元のtより1減って213となる。わかるよな?

In [19]: len(dt)
Out[19]: 213

B: いくらなんでもそのくらいわかります。

A: よし。じゃあdtをプロットするか。

In [20]: plot(dt)
Out[20]: [<matplotlib.lines.Line2D object at 0x0BBA5210>]
../_images/11-1-05.png

B: ちょっとギザギザしてますがほぼ水平なグラフですね。ええっと、値は…0.016と0.017の間くらい?

A: そうだな。VisionEgg.time_func()は「秒」の単位で時刻を返すので、縦軸の単位は秒だ。ミリ秒に直すと16ミリ秒から17ミリ秒。 さて、このグラフが意味することがわかるかな?

B: ? whileループが1周するのに17ミリ秒弱かかるってことじゃないんですか? それが何か?

A: そう。プログラムをよく見てほしいんだが、pygame.event.get()はwhileループ1回につき1回しか実行されない。ということは?

B: あ…。もしかして、 約17ミリ秒刻みでしか反応時間が測れない 、ってことですか?

A: ほぼ正解。もう少し正確に言うと、約17ミリ秒に1回しかキーをチェック出来ないので、 得られた反応時間はOSがキー入力イベントを受け取ってから最大17ミリ秒弱遅れているかもしれない ってことだ。 この「かもしれない」ってのが厄介で、必ず17ミリ秒遅れるんなら17ミリ秒引けばいいだけだが、キー押しのタイミングによってほとんど遅れがないこともあるんだ。そして、遅れているのかどうかはこのプログラムの書き方では知りようがない。

B: むむっ。VisionEgg.time_func()が1ミリ秒未満の精度で時刻を返してくるのですっかり信頼していましたが、それじゃダメなんですね。

A: そういうこと。

B: もっと正確に測る方法はないんですか?

A: うん、当然そうくるわな。でもその話に入る前に、なぜこんなに遅れてしまうのかって話をしておこう。 理由がわからないと対策も立てられないからね。というわけで、今度はこのプログラムを実行してみて。 今度はいちいちscreen.close()と入力しなくてもいいように最後にscreen.close()を入れておいたぞ。

  • 行番号なしのソースファイルをダウンロード→ 11-1b.py

 1import VisionEgg
 2import VisionEgg.Core
 3import pygame
 4
 5from pygame.locals import *
 6
 7screen = VisionEgg.Core.get_default_screen()
 8viewport = VisionEgg.Core.Viewport(screen = screen, stimuli=[])
 9
10isDone = False
11
12t = []
13while not isDone:
14    startloop = VisionEgg.time_func()
15    for e in pygame.event.get():
16        if e.type==KEYDOWN and e.key==K_SPACE:
17            isDone = True
18    screen.clear()
19    viewport.draw()
20    startswap = VisionEgg.time_func()
21    VisionEgg.Core.swap_buffers()
22    endswap = VisionEgg.time_func()
23    t.append([startloop,startswap,endswap])
24
25screen.close()

B: ええと、今度はstartloop、startswap、endswapって値をtに保存しているのか。 それぞれループ開始と…swap_buffersの直前と、直後?

A: その通り。この時点で察しのいい人には何が犯人かバレバレだろうがな。とにかく実行してみて。

B: はい。…スペースを押して、と。終了しました。

A: さて、これから犯人を明らかにするわけだが。ここでちょっとtがリストのままだと面倒くさいので、numpy.ndarray型に変換する。 numpyは数学的な意味での行列のサポートをはじめとする高度な処理を可能にするためのパッケージだ。 非常に多くのパッケージがnumpyに依存している。 リストからnumpy.ndarray型に変換するにはnumpy.array()を利用すればいい。pylabを使っていればすでにimportされているので、単にarray(t)とすればいい。

In [21]: t2 = array(t)

A: さて、numpy.ndarray型に変換してしまうと、行列の特定の行や列を抜き出して差を求めるといったことが簡単にできる。 まず、shapeという属性を見ると、行数と列数がわかる。さらに、通常のリストでの要素の指定方法を拡張した書式が利用できる。 例えば行列の列だけを抜き出すには以下のようにする。

In [22]: t2.shape
Out[22]: (249,3)

In [23]: t2[:,1]
Out[23]:
array([ 4031.75311458,  4031.76706629,  4031.78281636,  4031.79952386,
        4031.81918253,  4031.83282659,  4031.84954213,  4031.8661481 ,
(以下省略)

B: ええと、[ ]の中はどういう意味ですかね。

A: 通常の多次元リストではa[121][2]のように[ ]を並べて要素にアクセスしたが、numpy.ndarray型では多次元リストと同様の方法に加えて一つの[ ]の中に , で区切って要素を指定することができる。 Matlabを使っている人にはなじみ深い表記のはず だ。 そして、その次元のすべての要素を示したい時には : とだけ書く。この表記自体は通常のリストと同じなんだが、この例のようにある特定の列のすべての要素を取り出すような操作は出来ない。 変換する前のtで試してみるといい。

In [24]: t[:,1]
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)

d:\work\11-1b.py in <module>()
----> 1
      2
      3
      4
      5

TypeError: list indices must be integers

In [25]: t[:][1]
Out[25]: [4031.766981234, 4031.7670662867135, 4031.7827117716788]

B: むむむむ。難しいです。t[:,1]がエラーになるのはわかるんですが、t[:][1]がよくわかりません。

A: t[:][1]はt[1]と同じ意味だよ。(t[:])[1]と括弧でくくってみるとよくわかる。わからなければリストの演算子のよーく復習するように。 さて、numpy.ndarrayはリストと違って長さが同じもの同士であれば要素ごとの引き算が出来るので、以下のようにすると2列目と1列目の差が計算できる。

In [26]: t2[:,1]-t2[:,0]
Out[26]:
array([  1.56313095e-04,   8.50527135e-05,   1.01143768e-04,
         1.40988282e-04,   3.13430743e-03,   1.24514108e-04,
(以下略)

B: むむむむむー。

A: では、この計算結果をプロットしてみよう。 ちょっと値が小さいので1000倍して縦軸の単位をミリ秒に変換しておくよ。ちなみにこんな風に1000という定数をかけて全ての要素を1000倍に出来るのもnumpy.ndarrayの機能だ。

In [27]: plot(1000*(t2[:,1]-t2[:,0]))
Out[27]: [<matplotlib.lines.Line2D object at 0x0471CE50>]
../_images/11-1-06.png

B: ん? 縦軸はミリ秒にしたって言いましたよね。最初に一瞬だけ3ミリ秒近くかかっている部分もありますが、ほとんど0.2ミリ秒もかかっていない…?

A: そう。1列目はループ内の処理が始まった時刻、2列目はswap_buffers()を実行する直前の時刻だから、この間の処理には1ミリ秒もかかっていないってことなんだよ。 続いて3列目と2列目の差を計算してswap_buffers()の実行に要する時間を調べてみよう。上の例をみたらB君にもわかるよな?

B: さすがにこのくらいなら。どれどれ…

In [28]: plot(1000*(t2[:,2]-t2[:,1]))
Out[28]: [<matplotlib.lines.Line2D object at 0x048878B0>]
../_images/11-1-07.png

B: うわっ、こりゃ酷い。whileループ一周に17ミリ秒弱かかるって話でしたけど、ほとんどswap_buffers()の処理にかかる時間ってことですか?

A: そう、そうなんだよ。このswap_buffers()が犯人なのさ。もうここまで来たら目的は達成したも同然だけど、最後にswap_buffers()が終わってから次のループが始まるまでの時間も確認しておこう。 こちらはちょっと厄介で、例えば1行目のendswap(3列目)の値と2行目のstartloop(1列目)、といった具合に1行ずれた値の差をとらないといけない。 そろそろこの回も長くなってきて疲れてきたので、答えと簡単なヒントだけ書いておくよ。通常のリストと同様に、numpy.ndarray型でも:の前に整数を付けて行列をスライスすることが出来る。 これを利用して以下のように書けばforなどを使わずに一気に計算できる。

In [29]: plot(1000*(t2[1:,0]-t2[:-1,2]))
Out[29]: [<matplotlib.lines.Line2D object at 0x0C562F90>]
../_images/11-1-08.png

B: えーと、この式の[ ]の中は…

A: 考えておくように。宿題ね。 さて、結果の方は見ての通り、次のループが始まるまでの間は0.02ミリ秒もかかっていない。 というわけで、間違いなく犯人はswap_buffers()というわけだ。

B: なんでswap_buffers()はこんなに時間がかかるんですか?

A: 例題1-4のVisionEggの仕組みで話したswap_buffers()の働きを思い出してもらう必要があるな。 勢いにまかせて一気にいきたいところだけど、さすがに疲れたのでここで一区切りするか。次回はswap_buffers()がなぜ時間がかかるのかを説明した後、この問題を克服するにはどうすればいいかって話を…出来ればいいな。わからん。

B: なんか疲れてますねぇ。んじゃ、次回期待しときます。