.. _chapter-coder-intro: [Coder編] Coderへの招待 =================================================================== この章の概要 ----------------------------------- 本書はPsychoPy Builderを対象としていますが、この章では一般的なプログラミングへの橋渡しとしてBuilderの実験とPythonのコードで書いてみるとどうなるかという例を紹介したいと思います。 「 :numref:`{number}:{name} ` 」で述べたように、Builderは実験の実行時に exp_lastrun.py (expはpsyexpファイルの名前によって決まる)というファイルが作成され、そこにはBuilderで作成した実験をPythonのコードに変換したものが書かれています。 ではこのファイルを読めば、自分でPythonのスクリプトを書いて実験を作成する良いお手本になるかというと、残念ながらまったくそんなことはありません。 特にJavaScriptコードの出力に対応した最近のバージョンのBuilderは、ブラウザ上で動作するJavaScriptのコードとの共通化を図るため、とてもクセがあるコードを出力しますので、そちらの知識がないと「なぜこんなコードを出力しているのか」と頭を抱えることになりかねません。 本章では、ブラウザでの動作などは考えず、Pythonでできるだけシンプルなコードを書くことを目指します。 予備知識としては、本書の :numref:`第%s章 ` まで、特に変数と関数(:numref:`第%s章 `)、クラスとif-elif-else文(:numref:`第%s章 `)、メソッド(:numref:`第%s章 `)などを押さえておく必要があります。 :numref:`第%s章 ` からこの章に飛んできても読めるように簡単な解説はするつもりですが、必要に応じて前の章も参照してください。 どこから手をつけるか ------------------------------------------------------- スクリプトを書いたことがない人にとって、スクリプトの中身には何が書かれているのか、どこからどのように書いたらいいのか、わからないことだらけだと思います。 そんな方は、あまり良い例えではないかも知れませんが料理のレシピを想像してください。 最初に準備する材料が書いてあって、材料を切ったり混ぜたりといった作業が順番に書かれていて、その通りに進めると料理ができあがります。 手順の中には「じゃがいもの皮をむく」と「にんじんの皮をむく」のように、順番を入れ替えても最終的な仕上がりに影響がないものあれば、「みじん切りにしてから飴色になるまで炒める」の「みじん切りにする」と「炒める」のように順番を入れ替えると別物になってしまうものがあります。 実験のスクリプトも同じようなもので、最初に「材料」となるものを書いておき、その材料を使っておこなう作業を順番に書いていきます。 料理のレシピと同様に、順番を入れ替えても問題ないものと、順番を変えてはだめなものがあります。入れ替えても問題ない作業をどのような順番で書くかは、スクリプトを書く人の好みに依りますので、他人が書いたスクリプトを読んで勉強するときは、この辺りの加減が最初はわかりにくいかもしれません。 さて、そんなスクリプトを書くときにどこから手を付けるかですが、筆者のおすすめは「いきなり全体の流れを書くのではなく、個々の要素から書く」です。先に全体の流れを書くのは、全体の見通しがついていないと難しいものです。また、個々の要素から書けば、要素が完成する度に実行して、おかしな点がないか確認することができます。先ほどまで問題が無かったのに、新しい要素を追加しておかしくなったことに気づいたなら、おそらく新しく追加した要素に問題があるか、追加したものとこれまで書いたものの間で辻褄があっていない点があるかどちらかなので、問題を見つけやすいでしょう。 次節から、PsychoPy Coderを使って実際にスクリプトを書いてみましょう。目指すのは :numref:`第%s章 ` のサイモン課題です。 下準備をする ------------------------------------------------------- まず、Builderの実験の時と同様に、今から作る実験用にフォルダを作成することをお勧めします。CoderはBuilderと比べて自由度がとても高いので、「ひとつの実験 = ひとつのフォルダ」が守られていなくても問題ないように作成することも可能ですが、本書ではBuilderと揃えておきます。フォルダを作成したら、Coderを開いてメニューの「ファイル」から「新規」を選びましょう。すると untitled.py というタブが開いてまっさらなファイルが作成されます(:numref:`fig-create-run-code`)。ここへ文書作成アプリやメモ帳アプリのようにキーボードで文字を入力していくことができます。 .. _fig-create-run-code: .. figure:: fig_c01/create-run-code.png :width: 80% Coderで新しいPythonスクリプトを作成し、実行する 新しいファイルを作成したら、まず以下のように1行だけ書いてください。 **Pythonは大文字と小文字を区別する** ので、大文字小文字もそのまま正確に入力してください(とりあえず全部小文字ですが)。また、かな入力やローマ字入力のときに入ることがある全角文字と、それらをOFFにして入力した半角文字も区別されます。具体的に言うと、本書のフォントではわかりにくいかもしれませんが `A` と `A` は別の文字として扱われるということです(前者が全角文字)。日本語の文字などを入力する必要がある時以外は、かな入力やローマ字入力は原則OFFにしておきましょう。 .. code-block:: python from psychopy import visual これは「材料の準備」にあたるもので、psychopyに含まれるvisualという素材を使いますよという意味です。これを **import文** と呼びます。 **パッケージ** とは、さまざまな目的のために誰かが書いてくれたコードを詰め合わせたもので、PsychoPyは元来アプリケーションではなく実験作成に便利なコードを詰め合わせたパッケージとして開発されました。 import文の基本形は ``import xxx`` (xxxはパッケージ名)という形で、スクリプト内で ``import psychopy`` と書けば、以後そのスクリプト内で ``pscyhopy`` という名前を使ってPsychoPyパッケージを利用することができます。 PsychoPyのように多機能なパッケージの場合、パッケージ内部でさらにいくつかのグループに分割されてコードが収められています。このグループを **モジュール** と呼び、psychopyというパッケージに含まれる visualというモジュールを利用したい場合は上記のコードのように ``psychopy.visual`` とドット(``.``)区切りで書きます。 ``from xxx import yyy`` という書き方は、xxxパッケージの中のyyyモジュールだけ必要な時に使用します。 ``from psychopy import visual`` で、 ``psychopy.visual`` というモジュールを以後 ``visual`` と書くだけで使えるようになります。 続いて、この psychopy.visual パッケージを使って「ウィンドウを準備しなさい」というコードを追加します。 .. code-block:: python from psychopy import visual win = visual.Window() ``visual.Window()`` は ``visual`` の後に ``.`` がありますから、 ``visual`` の中にある ``Window()`` というものを使うという意味ですね。 ちなみに最初のimport文でfromを使っていなかったら、以下のように ``psychopy.visual.Window()`` と書く必要があります。 **#より右に書かれているのはコメント** で、スクリプトを実行するときにはPythonインタプリタから無視されます。 .. code-block:: python import psychopy.visual # fromを使わずにimportすると… win = psychopy.visual.Window() # psychopyを省略できない fromを使わない書き方は、どのパッケージから持ってきたものかわかりやすいという利点がありますが、タイプ量が多くなってしまいますし、長すぎる、読みにくくなる場合があるといった問題もあります。 どちらを使うかは好みですが、本章ではPsychoPyのモジュールはすべてfromを使ってimportします。 さて、ここからBuilderと比べて難しい部分です。この ``Window()`` は **関数** と呼ばれるもので、PsychoPyの刺激描画などに開くウィンドウを作成して **オブジェクト** を返します。 「オブジェクト」というのがわかりにくいですが、スクリプト内で「ウィンドウ」とか「キーボード」とかいった対象を表すための仕組みだと思っておいてください。 行頭の ``win =`` は、 ``Window()`` が返したオブジェクトを ``win`` という名前で後々使用できるようにするための操作で、この操作を「 ``win`` へ **代入する** 」と呼びます。 ``Window()`` の ``()`` の中には、「ウィンドウを作る時にこういう風にしてほしい」といった設定を書きます。これを **引数** と呼びます。なんでもかんでも自由に書けるわけではなくて、メソッドによって指定できるものが決まっています。 ``Window()`` の引数を調べる方法はいろいろありますが、PsychoPy公式ドキュメントの「API」を見るのが確実です。 https://psychopy.org/api/ にアクセスするとPsychoPyに含まれるモジュールがずらりと表示されますが、その中のpsychopy.visualを選び、表示されたページからさらにWindowを選んでください。 表示されたページの冒頭に以下のように表示されます(2025年11月現在)。 .. code-block:: python class psychopy.visual.Window(size=(800, 600), pos=None, color=(0, 0, 0), colorSpace='rgb', backgroundImage=None, backgroundFit='cover', rgb=None, dkl=None, lms=None, fullscr=None, allowGUI=None, monitor=None, bitsMode=None, winType=None, units=None, gamma=None, blendMode='avg', screen=0, viewScale=None, viewPos=None, viewOri=0.0, waitBlanking=True, allowStencil=False, multiSample=False, numSamples=2, stereo=False, name='window1', title='PsychoPy', checkTiming=True, useFBO=False, useRetina=True, autoLog=True, gammaErrorPolicy='raise', bpc=(8, 8, 8), depthBits=8, stencilBits=8, backendConf=None, infoMsg=None) 長くてびっくりしますが、先頭から見ていきましょう。 ``class`` はここに記されているのがクラス(:numref:`第%s章 `)であることを示していて、 ``psychopy.visual.Window`` はクラス名です。 続く ``()`` の中に書いてあるのが、このクラスのオブジェクトを作成するときに指定できる引数の一覧です。引数はカンマ区切りでかかれており、最初の引数は ``size=(800, 600)`` 、2番目の引数は ``pos=None`` という具合に解釈します。 ``size=(800, 600)`` の中に含まれるカンマは引数の区切りじゃないのかと言われるかもしれませんが、これは ``(800, 600)`` でひとまとまりの値なので、引数を区切るカンマではありません。 ``size`` は作成するウィンドウの画面上での大きさ、 ``pos`` はウィンドウの画面上での位置、 ``color`` はウィンドウの背景色…といった具合で、これらはBuilderの「実験の設定」ダイアログの「スクリーン」で設定する項目に対応しています。 ``=`` の後に書かれているのは、その引数を省略した時に自動的に設定される値(初期値)です。 「引数がたくさんあってこんなの覚えられないぞ!」と思われたかもしれませんが、初期値があるおかげで、 **初期値から変更したい引数だけ覚えておけばよい** のです。 ``Window()`` の引数でよく使うと思われるものを :numref:`tbl-window-arguments` にまとめておきます。 .. _tbl-window-arguments: .. csv-table:: ``Window`` の主な引数 :header: 引数, 説明 :widths: 24,76 ``fullscr``, ``True`` ならウィンドウをフルスクリーンで描画します。 ``False`` なら通常のウィンドウとして描画します。 ``None`` ならPsychoPyの設定に従います。 ``allowGUI``, ``True`` ならフルスクリーン時にマウスカーソルを表示されます。 ``False`` なら表示されません。 ``None`` ならPsychoPyの設定に従います。 ``color``, ウィンドウの背景色を指定します。Builderと同様、色名か3つの数値の組で指定できます。数値の組で指定する場合、色空間は引数 ``colorSpace`` で決まります。 ``units``, このウィンドウに描画する際の単位を指定します。 ``None`` ならPsychoPyの設定に従います。 ``monitor``, モニターのサイズや解像度、観察距離などの設定を、PsychoPyのモニターセンターで定義したモニター名で指定します。 ``None`` ならモニターセンターのデフォルトの設定に従います。 ``size``, ``monitor`` で設定したモニターの解像度と異なる解像度に設定したいときに指定します。 今回は、動作確認中にエラーが起こった時に中断することを考えて、 ``fullscr=False`` を指定しておくことにしましょう。また、スクリーンのサイズも小さめの ``size=(1280, 720)`` にしておきます。皆さんのPCのモニターの解像度が4Kなどの高解像度ならもっと大きくした方がいいでしょうし、もし小型のタブレットPCなどモニターの解像度が低い場合は小さくしないといけないかもしれません。 単位は念のため ``units='height'`` を明示的に指定しておきましょう。 これらの引数を ``Window()`` に追加します。 .. code-block:: python from psychopy import visual win = visual.Window(fullscr=False, size=(1280, 720), units='height') 先ほどの公式ドキュメントの引数の記載順序と異なっていますが、Pythonでは **引数を名前付きで書く場合、順番を入れ替えても構いません(これをキーワード引数と呼びます)。** ``units`` から書いても、 ``size`` から書いても同じ動作をします。 「名前付きではない場合とは?」と思われるかもしれませんが、それは次節で触れます。 ここまで書いたら、スクリプトを実行してみましょう。 :numref:`fig-create-run-code` 下段に示すように、Coderのウィンドウ上部のリボンにBuilderと同様のRunボタンがあり、これをクリックすると現在表示しているタブのコードを実行できます。 モードスイッチはPilotモードでも実行モードでも動作は同じなので、変更しなくて結構です(CoderにおけるPilotモードの利用法については「 :numref:`{number}:{name} ` 」参照)。 ここまでスクリプトを保存していなければ、ファイルを保存するかどうか尋ねてくるので「はい」を選択してファイルを保存してください。その際、最初に作成したこの実験用のフォルダ内に保存することと、「ファイルの種類」を「Pythonスクリプト」から変更しないように気をつけてください。スクリプトの拡張子は .py のままにしておきましょう。 スクリプトを実行し、少しまった後にスクリーン中央に灰色のウィンドウが現れて、すぐに消えてしまえば成功です。 おそらく単純なタイプミス以外に躓くところはないので、万一動かない場合は綴りが間違っていないか、大文字と小文字、カンマとピリオドを間違えていないか、行頭に余分なスペース文字が入っていないかなどを確認してください。 これで実験を作成する下準備ができました。次節では刺激を描画してみましょう。 チェックリスト - PsychoPy CoderでPythonスクリプトを作成して実行することができる。 - パッケージとモジュールの意味を説明できる。 - import xxx.yyy と from xxx import yyy の違いを説明できる。 - psychopy.visual.Window()を使ってウィンドウモードとフルスクリーンモードの描画ウィンドウを作ることができる。 - psychopy.visual.Window()を使ってフルスクリーンウィンドウを作る時に、マウスカーソルを表示するか否かを指定できる。 視覚刺激を描画する ------------------------------------------------------- 視覚刺激の描画には、psychopy.visualに含まれている視覚刺激オブジェクトを使用します。長方形なら ``psychopy.visual.Rect`` 、円なら ``psychopy.visual.Circle`` 、 正多角形なら ``psychopy.visual.Polygon`` 、 一般的な多角形なら ``psychopy.visual.ShapeStim`` 、文字なら ``psychopy.visual.TextStim`` 、画像ファイルなら ``psychopy.visual.ImageStim`` という具合です。BuilderのPolygonコンポーネントは ``Polygon`` 、 ``ShapeStim`` など複数のオブジェクトに対応しています(PsychoPyのバージョンによって異なります)。Textコンポーネントは ``TextStim`` 、Imageコンポーネントは ``ImageStim`` ですね。 これらの名前を知っている(または公式ドキュメントやサンプルコードから探してこられる)ことがCoderでスクリプトを書ける前提であり、Builderと比べてとっつきにくい理由でしょう。 これらのオブジェクトを作成するメソッドの引数は似ているので、代表として ``Circle`` を取り上げましょう。 .. code-block:: python class psychopy.visual.circle.Circle(win, radius=0.5, edges='circle', units='', lineWidth=1.5, lineColor=None, fillColor='white', colorSpace='rgb', pos=(0, 0), size=1.0, anchor=None, ori=0.0, opacity=None, contrast=1.0, depth=0, interpolate=True, draggable=False, name=None, autoLog=None, autoDraw=False, lineRGB=undefined, fillRGB=undefined, color=undefined, fillColorSpace=undefined, lineColorSpace=undefined) Builderになる程度慣れている人なら、引数を眺めていると「ああ、これはPolygonコンポーネントのあの項目に対応しているな」と想像できるのではないかと思います。 :numref:`tbl-visualstim-arguments` に、多くの視覚刺激オブジェクトに共通する引数を示します。 .. _tbl-visualstim-arguments: .. csv-table:: 視覚刺激オブジェクトを生成するメソッドの主な引数 :header: 引数, 説明 :widths: 24,76 :delim: ; ``win``; 刺激を描画する対象となるPsychoPyのWindowオブジェクトを指定します。したがって、視覚刺激オブジェクトを生成する前には ``Window()`` を実行しておく必要があります。 ``size``; 刺激の大きさを指定します。Builderにおける **[サイズ (w, h) $]** に対応します。 ``pos``; 刺激の位置を指定します。Builderにおける **[位置 (x, y) $]** に対応します。 ``anchor``; ``pos`` で指定した位置が図形のどこに対応するかを指定します。Builderにおける **[位置揃え]** に対応します。 ``opacity``; 刺激の不透明度を指定します。Builderにおける **[不透明度 $]** に対応します。 ``ori``; 刺激の回転角度を指定します。Builderにおける **[回転角度 $]** に対応します。 ``color``; 刺激の色を指定します。Builderにおける **[前景色]** に対応します。 ``fillColor``; 刺激の塗りつぶし色を指定します。Builderにおける **[塗りつぶしの色]** に対応します。 ``lineColor``; 刺激の枠線の色を指定します。Builderにおける **[枠線の色]** に対応します。 ``lineWidth``; 刺激の枠線の幅を指定します。Builderにおける **[枠線の幅]** に対応します。 注意が必要なのは最初の引数の ``win`` で、 ``win`` には **初期値が設定されていません** (``radius=0.5`` のように ``=`` を伴っておらず、ただ ``win`` とだけ書かれている)。 **初期値が設定されていない引数は省略することができません。** 引数 ``win`` には先ほど ``Window()`` で作成したWindowオブジェクトを指定するのですが、このオブジェクトはスクリプトを実行する度に生成するので、初期値を設定することができないのです。 従って、最もシンプルな ``Circle`` の呼び出しは以下のようになります。 .. code-block:: python :emphasize-lines: 4 from psychopy import visual win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win) 引数が ``win=win`` ではなく単に ``win`` とだけ書かれている点に注意してください。これは **位置引数** と呼ばれるもので、公式ドキュメントの引数一覧(正確にはメソッドの定義)に書かれている順番に引数に対応します。引数一覧では最初の引数が ``win`` なので、引数名なしで書かれた最初の引数は ``win`` に対応していると解釈されます。引数名も変数名も ``win`` なのでややこしいと思いますが、サンプルコードなどで引数名と同名の変数が使われていることはよくあるので慣れてください。 ``Circle`` の引数のうち、 ``radius`` は半径、 ``edges`` は頂点数(実は円ではなく頂点数が多い多角形を描いて円に見えるようにしている)に対応していますが、それ以後の引数は ``Rect`` や ``Polygon`` など、他の視覚刺激オブジェクトと共通です。ここでは :numref:`第%s章 ` と合わせてサイズを ``(0.1, 0.1)`` 、 位置を ``(0.7, 0.0)`` 、 塗りつぶしの色を ``'red'`` にしてみましょう。 ``radius`` は半径なので、 ``(0.1, 0.1)`` の大きさの円を描くには半径は ``0.05`` でないといけない点に注意してください。 .. code-block:: python :emphasize-lines: 4 from psychopy import visual win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red') さらに固視点も描いてしまいます。BuilderのPolygonコンポーネントで選択できる「十字」は ``ShapeStim`` で描画します。引数 ``vertices`` には図形の頂点座標を並べたリストを指定しますが、いつかの形状はこの例のように ``'cross'`` と文字列で指定することができます。 位置を表す ``pos`` が省略されているため初期値の ``(0.0, 0.0)`` 、つまりスクリーン中心になる点に注意しましょう。 .. code-block:: python :emphasize-lines: 5 from psychopy import visual win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red') cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white') これで材料が用意できました。あとは盛り付けです。 刺激を描画するには視覚刺激オブジェクトの ``draw()`` メソッドを使用します。 ``draw()`` は通常、引数なしで使用します。 .. code-block:: python :emphasize-lines: 7-12 from psychopy import visual win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red') cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white') stim.draw() cross.draw() win.flip() win.close() ``draw()`` メソッドを実行した順番にスクリーンに描画されますので、上記のコードだとstim(円)が描かれてからcross(十字)が描かれます。 これは **Builderでコンポーネントがルーチンに配置された順番に描画されることに対応しています。** ``draw()`` に続いて ``flip()`` というWindowオブジェクトのメソッドが実行されていますが、これは少し解説が必要でしょう。 :numref:`fig-flip` をご覧ください。PCの画面はバッファーと呼ばれる領域に描画され、モニターに転送するという過程を経て表示されます。 バッファーの情報をモニターに転送している途中でバッファーを書き換えてしまうと、画面の一部分は前の画面、残りの部分は新しい画面といった事態になってしまいます。具体的には、画面がちらついて非常に見づらい状態になります。 これを防ぐため、 :numref:`fig-flip` のように表示用(転送用)と描画用の2枚のバッファーを用意して、表示用バッファーの転送中に描画用バッファーに描き込みを行います。 そして、表示用バッファーの転送が終了すると、一時的に転送が停止するので、その間にすかさず表示用と転送用のバッファーを入れ替えます。 このようにすれば、転送と描画を並行して効率的に実行できるので、ちらつかせることなく滑らかなアニメーションを実現できるというわけです(1秒当たりのフレーム数が多いほどアニメーションは滑らかになる)。 .. _fig-flip: .. figure:: fig_c01/flip.png :width: 80% ``flip()`` の働き。モニターへの転送用示用(=表示用)と描画用のバッファーがあり、描画用バッファーに次のフレームを描いた後、転送が一時停止するタイミングでバッファーを入れ替える。 PsychoPyにおいては、各視覚オブジェクトの ``draw()`` は描画用バッファーにその刺激を描き込む働きをします。 そしてWindowオブジェクトの ``flip()`` は、バッファーの入れ替え可能なタイミングまで待機して、可能になったら入れ替えをおこなう働きをします。 ``draw()`` と ``flip()`` のどちらが欠けても視覚刺激は表示されません。 最後の ``close()`` は「後片付け」にあたるコードで、 ``visual.Window()`` で開いた描画用ウィンドウを閉じます。 Coderから実行する場合、 ``close()`` を呼ばなくてもスクリプトの実行終了後にPythonインタプリタが停止して自動的に描画用ウィンドウは閉じられるので、 ``close()`` の実行は必須ではありません。 しかし、普段からJupyterなどの開発環境を利用していて、そこからスクリプトを実行した場合、スクリプトの実行が終わってもPythonインタプリタ自体は停止しないので、描画用ウィンドウが残り続けてしまいます。 フルスクリーンモードで実行しているときにこの状態になってしまうと、手作業で描画用ウィンドウを閉じるのは非常に面倒です。最悪の場合、PCの再起動が必要になるかもしれません。 「自分はCoderしか使わない」という方でも、自分が書いたスクリプトを後輩か誰かが使うことがあるかも知れません。そういった時に不幸な事故が起きないように、 ``close()`` を書く習慣をつけることをお勧めします。 さて、以上のスクリプトを実行して刺激が表示されるところを確認しましょう。実行すると、ウィンドウ中央に十字、右に赤い丸が一瞬だけ表示されてすぐに消えてしまいます(一瞬なので見落とさないようにしてください)。スクリプトには「バッファーに描画して入れ替えをおこなう」という作業が1回分しか書かれていないので、1回この作業をおこなってすぐにスクリプトが終了したのです。 次節では、一瞬だけ表示するのではなく、指定した時間だけ表示されるようにしてみましょう。 チェックリスト - psychopy.visualに含まれる視覚刺激オブジェクトを位置、色、回転角度などを指定して作成することができる。 - PsychoPyの刺激描画ウインドウにおけるバッファーの役割を説明できる。 - 視覚刺激オブジェクトの ``draw()`` メソッドと Windowオブジェクトの ``flip()`` メソッドの役割を、互いに関連付けて説明することができる。 ルーチンに相当するコードを書く ------------------------------------------------------- :numref:`第%s章 ` の実験では、固視点(十字)は実験の間ずっとスクリーンに描画されていた一方、刺激(円)は各試行が始まってから0.5秒後に表示されていました。 こういったタイミングの制御はBuilderだとルーチンに配置されたコンポーネントの **[開始]** や **[終了]** で制御されてたのでしたよね。 本節ではこれをスクリプトで再現してみましょう。 刺激の出現タイミングを制御するためには、試行が始まってから何秒経過したのかをスクリプト内で知る必要があります。 PsychoPyでは、高精度の時間測定が必要な心理学実験にも使用できる測定機能が提供されています。 この機能はpsychopy.clockというモジュールに含まれているので、このモジュールを追加しましょう。 スクリプトの冒頭部に以下のように書き足してください。 先にも触れた通り **#より右に書かれているのはコメント** なので、入力しなくても動作には影響しません。 コメントは将来自分がスクリプトを読み直すときのためのメモとして便利なので、皆さんも自由に活用してください。 .. code-block:: python :emphasize-lines: 2 from psychopy import visual from psychopy import clock # この行を追加 psychopy.clockモジュールに含まれるClockオブジェクトは、ストップウォッチのように、スタートしてからの時刻を測定します。 Clockオブジェクトを生成するには、以下のように ``Clock()`` を実行します。戻り値はClockオブジェクトです。 .. code-block:: python :emphasize-lines: 4 from psychopy import visual from psychopy import clock routineClock = clock.Clock() # この行を追加 ``Clock()`` が実行されてClockオブジェクトが生成されると、直ちに時間測定が始まります。 Clockオブジェクトの ``reset()`` メソッドで測定をリセット、 ``getTime()`` メソッドで測定を開始してから(またはリセットしてから)の時間を得ることができます(時間の単位は「秒」)。 Clockオブジェクトには他にもいろいろな機能がありますが、とりあえずこの2つを覚えておけば十分です。 さて、この ``getTime()`` を使って時間を確認しながら、 - corssは必ず描く - stimは0.5秒経過したら描く ということを繰り返せば、 :numref:`第%s章 ` の実験と同様な刺激を描画できそうです。 :numref:`第%s章 ` の実験では参加者がキーを押すまで待ちましたが、まだキー押しの検出はできないので、とりあえず10秒経過したら終了することにしましょう。 Pythonで条件を満たすまで同じ作業を繰り返すには、while文を使用します。 while文は以下のように書き、 ``while`` に続けて書かれた条件式が真である間、後続の字下げされた文を繰り返し実行します。 .. code-block:: python while 条件式: 繰り返し実行したい作業 (字下げが元に戻る行までが繰り返しの範囲) 字下げは通常、半角スペース4字を用います。字下げについて詳しくは「:numref:`{number}:{name} ` 」、「:numref:`{number}:{name} ` 」および「:numref:`{number}:{name} ` 」を参考にしてください。 **Coderを使っている場合、字下げしたい行にカーソルがある状態でCtrlキーを押しながら ] キーを押すと1段階(半角スペース4字分)字下げできます。字下げを1段階解除したい場合はCtrlキーを押しながら[キー(Ctrl+[)を押してください。** 複数行を選択している状態でこれらの操作をすると、選択された行をまとめて字下げを増減できます。便利なので覚えておいてください。 本題に戻って、Builderのように ``t`` という変数に描画開始後の時間(単位は秒)が保持されているとして、 .. code-block:: python :emphasize-lines: 1-5 while t < 10: # この行を追加して字下げwin.flip()まで字下げ stim.draw() cross.draw() win.flip() とすれば、10秒間stimとcrossを描くという作業を繰り返すことができるはずです。 しかし、Builderと違って ``t`` の値も自分で測らないといけません。 いくつかの書き方がありますが、まず ``t < 10`` という式を評価する前に ``t`` を用意する必要があります。以下の文では ``t = 0`` を代入しておいてから、直ちにClockオブジェクトをリセット(0秒にする)しています。 .. code-block:: python :emphasize-lines: 1-2 t = 0 # この行と次の行を追加 routineClock.reset() while t < 10: stim.draw() cross.draw() win.flip() 続いて ``t`` の再測定ですが、描画だけを考えるなら ``flip()`` を1回するたびに1度おこなえば十分です。 踏み込んだ議論は「:numref:`{number}:{name} ` 」をご覧ください。 このサンプルでは、Builderが出力するコードに習ってwhile文による繰り返しが始まった直後に実行することにしましょう。 .. code-block:: python :emphasize-lines: 4 t = 0 routineClock.reset() while t < 10: t = routineClock.getTime() # この行を追加 stim.draw() cross.draw() win.flip() 最後に、「stimは0.5秒経過してから描く」ためのコードを追加しましょう。これはif文(「:numref:`{number}:{name} ` 」参照)を使用して、't > 0.5'の場合のみ ``stim.draw()`` を実行すればいいでしょう。 .. code-block:: python :emphasize-lines: 5-6 t = 0 routineClock.reset() while t < 10: t = routineClock.getTime() if t > 0.5: # この行を追加して stim.draw() の行を字下げ stim.draw() cross.draw() win.flip() これで本節の目標を達成できました。変更部分だけを示してきたので現時点でのスクリプト全体を以下にしまします。 実行して、まず画面中央に十字が描画され、一呼吸(=0.5秒)おいて右に赤い円が表示されること、その後しばらく(=円が表示されてから9.5秒)してウィンドウが閉じて終了することを確認してください。 .. code-block:: python from psychopy import visual from psychopy import clock routineClock = clock.Clock() win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red') cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white') t = 0 routineClock.reset() while t < 10.0: t = routineClock.getTime() if t > 0.5: stim.draw() cross.draw() win.flip() win.close() これで刺激の描画はできました。次はキー押しの検出を行いましょう。 チェックリスト - psychopy.clockモジュールを用いて時間の測定ができる。 - while文を用いて、条件が真である間処理を繰り返すコードを書くことができる。 キー押しを検出する ------------------------------------------------------- PsychoPy Builderで「実験設定ダイアログ」の **[キーボードバックエンド]** にはiohub, psychtoolbox, pygletの3つの選択肢がありました(PsychoPy 2025.1.0で確認)。これはPsychoPyでキー押しを検出するための方法が3つあることに対応しています。 非常に大雑把に言うと、iohubは専門的な測定機器との連携を重視したもので、ただキーボードやマウスを使いたいだけの場合にはpsychtoolboxの方が優れています。 psychtoolboxは時間的精度の面で最も優れていますし、キーのリリース(押しているキーを放す)に対応するなど機能的にも充実していますが、32bitのPythonのような古い実行環境には対応してません。 pyglet(pygame)は3つの中で最も古くから実装されていたもので、キー押し検出がフレームレートに制限されるなどの問題がありますが、古い実行環境でも動作します。 これからPsychoPyを使う人は古い実行環境を考慮する必要はないでしょうから、psychtoolboxを使うのが良いでしょう。 psychtoolboxでキー押し検出を行うには、psychopy.hardware.keyboardモジュールを使用します。 keyboardモジュールの ``Keyboard()`` を実行すると、psychtoolboxのKeyboardオブジェクトが作成されます。 psychtoolboxを利用できない環境では自動的にpygletのキーボードに切り替わります。 ここ数年に購入したPCへ普通にPsychoPyの公式サイトから推奨のバージョンをインストールすると、ほぼpsychtoolboxが使える実行環境のはずですので、以下ではpsychtoolboxのKeyboardオブジェクトが作成されたものとして解説を続けます(「psychtoolboxの」と断らずにただ「Keyboardオブジェクト」と表記)。 .. code-block:: python :emphasize-lines: 3,6 from psychopy import visual from psychopy import clock from psychopy.hardware import keyboard # この行を追加 routineClock = clock.Clock() kb = keyboard.Keyboard() # この行を追加 Keyboardオブジェクトは、ウィンドウの描画とは独立してキーの入力を監視して、押されたキーとその時刻を自動的にバッファと呼ばれる領域に記録していきます。 時刻を記録するために、Keyboardオブジェクトの内部に独自のClockオブジェクトが内蔵されています。 :numref:`tbl-keyboard-methods` にKeyboardオブジェクトの主なメソッドとデータ属性を示します。 .. _tbl-keyboard-methods: .. csv-table:: Keyboardオブジェクトの主なメソッドとデータ属性 :header: メソッド/属性, 説明 :widths: 24,76 ``getKeys()``, バッファに記録されているキー押し情報を返す。デフォルトでは ``getKeys()`` で返された情報はバッファから削除される。 ``waitKeys()``, このメソッドが呼び出されると、キー押しが検出されるまでスクリプトの処理が停止する。ただし、ウィンドウのメッセージキューは処理される。描画内容を更新せずに参加者のキー押しを待つときに便利だが、本書では解説を省略する。 ``start()``, バッファへのキー押し記録を開始する。デフォルトでは、Keyboardオブジェクトが作成されると同時に記録が始まるので呼び出す必要はない。次の ``stop()`` で停止させた記録を再開するときに使用する。 ``stop()``, バッファへのキー押し記録を停止する。 ``clearEvents()``, バッファに記録されたキー押し情報をすべて削除する。 ``clock``, Keybooardオブジェクトに内蔵されているClockオブジェクトにアクセスする。通常のClockオブジェクトのように ``reset()`` や ``getTime()`` といったメソッドが使える。 盛りだくさんですが、基本は ``getKeys()`` 、 ``clearEvents()`` メソッドとデータ属性 ``clock`` です。 測定を開始するときに ``clearEvents()`` で余分なキー押しを削除して ``clock.reset()`` で時刻をリセットします。そして刺激を描画しながら定期的に ``getKeys()`` を実行してキーが押されたか確認するという流れです。 さっそくコードを書いてみたいところですが、 ``getKeys()`` はいくつか重要な引数があるので先に確認しておきましょう。 :numref:`tbl-getKeys-arguments` に ``getKeys()`` の主な引数を示します。 .. _tbl-getKeys-arguments: .. csv-table:: ``getKeys()`` の主な引数 :header: 引数, 説明 :widths: 24,76 ``keyList``, 検出するキー名を並べたシーケンス。 ``None`` なら検出可能な全てのキーを検出する。 ``waitRelease``, ``True`` なら押した後放したキー押しのみが報告される。 ``False`` ならまだ放されていないキー押しも含めて報告される。デフォルト値は ``True`` 。 ``clear``, ``True`` なら報告したキーをバッファから削除する。 ``False`` ならバッファに残り続ける。デフォルト値は ``True`` 。 ``keyList`` は BuilderのKeyboardコンポーネントの **[検出するキー $]** に対応する引数で、 **[検出するキー $]** と同様にキー名を列挙したシーケンスを指定します。キー名もすべて **[検出するキー $]** と同じです。 ``waitRelease`` はBuilderのKeyboardコンポーネントにはなかった機能で、 ``getKeys()`` が呼ばれた時点でキーが押しっぱなしの場合に報告するか否かを決定します。例えば、 ``waitRelease=True`` の場合、 ``getKeys()`` を呼んだ時点で押しっぱなしになっているキーがあると、そのキー名は戻り値に含まれません。押しっぱなしにしてきたキーを放した後に最初に呼ばれた ``getKeys()`` の時点でこのキー押しが返ってきます。 これに対して、 ``waitRelease=False`` の場合、 ``getKeys()`` を呼んだ時点で押しっぱなしになっているキーの名前も戻り値に含まれます。その後、キーが放された後に ``getKeys()`` を呼んでもこのキーの名前は返ってきません。 ``clear`` はデフォルト値で ``True`` ですが、これを ``False`` にすると ``getKeys()`` が呼ばれた後もバッファからキー押し情報が削除されません。 したがって、キーが押されるたびに ``getKeys()`` の戻り値はどんどん増えていくことになります(``waitRelease`` の挙動も上記から変化します)。 ``clearEvents()`` した後のキー押し情報をすべて保持しておく必要がある場合は便利かもしれませんが、個人的にはデフォルトの ``clear=True`` で呼び出して、保持しておく必要がある情報は自分でリストを作って保持する方がいいと思います。 以上で :numref:`tbl-getKeys-arguments` に挙げた引数をひととおり見ましたが、コードを書く前にもうひとつだけ確認しておきましょう。 ``getKeys()`` の戻り値についてです。 ``getKeys()`` の戻り値は、KeyPressオブジェクトというキー押し情報を保持するために用意されたものを並べたリストです。KeyPressオブジェクトで主に使用するデータ属性を :numref:`tbl-keypress-properties` に示します。 .. _tbl-keypress-properties: .. csv-table:: KeyPressオブジェクトの主なデータ属性 :header: 属性, 説明 :widths: 24,76 ``name``, 押されたキーの名前(BuilderのKeyboardコンポーネントで使うキー名と同じ) ``rt``, 内蔵のClockオブジェクトがresetされてからキー押しまでの時間(単位は秒)。 ``duration``, キーが押されてから放されるまでの時間(単位は秒)。 ``waitRelease=False`` の場合、キーが押されている最中に ``getKeys()`` を呼ぶとこの値は ``None`` になる。 Builderのキーボードコンポーネントと大きく異なるのは、 ``duration`` の値が得られる点です。 ``waitRelease=False`` を指定していると、押したキーをまだ放していない時にもキー押しの情報は得られますが、 :numref:`tbl-keypress-properties` にも書いてある通り、そのキーの ``duration`` は ``None`` になる点に注意してください。また、 ``getKeys()`` の呼び出しによってこのキー押しの情報がバッファから削除されるので、次に ``getKeys()`` を呼んだ時にはもうこのキー押し情報は得られない(いつ放されたかわからない)点も注意が必要です。 確実にキーが放された時刻の情報が欲しいのなら ``waitRelease=True`` にするか(つまりデフォルトのまま)、 ``clear=False`` で削除されないようにして新たなキー押しがあったかどうかを自分で管理するなどの処理を行う必要があるでしょう。 以上を踏まえて、キー押し検出のコードを追加しましょう。 :numref:`第%s章 ` 同様、xまたはslashキーを検出して、反応時間を記録するものとします。 .. code-block:: python :emphasize-lines: 3-4,9-12 t = 0 routineClock.reset() kb.clearEvents() # 追加 kb.clock.reset() # 追加 while t < 10.0: t = routineClock.getTime() keys = kb.getKeys(keyList=['x', 'slash']) # この行からbreakまで追加 if len(keys) > 0: print(keys[0].name, keys[0].rt) break if t > 0.5: stim.draw() cross.draw() win.flip() まず、刺激を描画する直前に ``clearEvents()`` と ``clock.reset()`` を実行します。 ``clearEvents()`` を実行しておかないと、刺激描画を始める直前に参加者が余計なキーを押していた場合にそれを検出してしまうので必要な処理です(BuilderのKeyboardコンポーネントの **[開始前のキー押しを破棄]** に相当)。 続いて、while文で描画を繰り返している中で ``getKeys()`` を呼び出して戻り値を ``keys`` に代入します。 ``keys`` が空のリストでなければ(=長さが0より大きければ)キーが押されているので、if文で処理を分岐してキーが押された時の処理をします。 今回の実験の場合、複数のキーが同時に押されていることを考慮する必要はないので ``keys[0]`` でリストに保持されている最初のキーを取り出し、その名前( ``name`` )と反応時間( ``rt`` ) を ``print()`` で表示しています。 Coderで実行している場合、 ``print()`` の出力は Runner の「標準出力」タブに表示されます。 キーが押されたら試行は終わりなので、 ``break`` (「 :numref:`{number}:{name} ` 」参照)でwhileループを抜けます。 以上でキー押しの検出(とそれによる刺激描画の終了)ができましたが、このままでは ``rt`` の値がターゲット刺激(円)ではなく注視点(十字)の描画開始を基準にしたものになってしまいます。 注視点の出現と円の出現の時間差(0.5秒)を後から引き算してもいいですが、ターゲット刺激の出現と同時にキーボードをスタートするようにしてみましょう。 素朴に、以下のように「ターゲット描画開始を判定するタイミングでキーボードをスタートすればいい」と思われるかもしれませんが、これではうまくいきません。 簡単な練習問題ですので、なぜダメなのか読者の皆さんも考えてみてください。 .. code-block:: python :emphasize-lines: 13-14 t = 0 routineClock.reset() while t < 10.0: t = routineClock.getTime() keys = kb.getKeys(keyList=['x', 'slash']) if len(keys) > 0: print(keys[0].name, keys[0].rt) break if t > 0.5: kb.clearEvents() # ここへ移動 kb.clock.reset() # (このままでは適切に動作しません!) stim.draw() cross.draw() win.flip() 正解は、直前のif文が判定しているのは「ターゲット **描画を開始する** タイミング」ではなく「ターゲットを **描画する** タイミング」だからです。 「自分が考えていたものと違う」と思った方、もしかしたら同じことを別の表現をしているだけかもしれませんのでもう少し具体的に書きます。 if文の条件は ``t>0.5`` なので、 ``routineClock.reset()`` から0.5秒以上経過していたら毎回 ``True`` になってしまいます。 そのため、際限なく ``eventClear()`` と ``clock.reset()`` を実行してしまい、適切に反応時間を測定することができません。 どうでしょう、思った通りだったでしょうか。 問題がわかったら対策を考えないといけません。 0.5秒以上経過したら毎回 ``True`` になってしまうことが原因なら、 「 ``t`` がぴったり0.5のとき、つまり ``t==0.5`` とすればいいのでは?」と思われるかもしれませんが、これもうまくいきません。 「 :numref:`{number}:{name} ` 」で解説しているように、PsychoPyの時間測定の分解能は数百万分の1秒以上です。 ``t`` が5.0秒から0.000001秒でもずれたら ``t==0.5`` は ``True`` になりません。したがって、奇跡のような幸運に恵まれない限り、 ``t==0.5`` が ``True`` になることはありません。 ではどうすればいいかというと、 ``t>0.5`` の時に **キーボードの検出がスタート済みか確認して、未スタートのときだけスタート** すればよいのです。 通常、このような処理をするときは「スタート済みか否かを保持」するための変数を用意します。これをフラグ(flag)と呼びます。 以下のコードを見てみましょう。 .. code-block:: python :emphasize-lines: 1,14-17 kb_started = False # この行を追加 t = 0 routineClock.reset() while t < 10.0: t = routineClock.getTime() keys = kb.getKeys(keyList=['x', 'slash']) if len(keys) > 0: print(keys[0].name, keys[0].rt) break if t > 0.5: if not kb_started: # この行を追加して reset() まで字下げ kb.clearEvents() kb.clock.reset() kb_started = True # この行を追加 stim.draw() cross.draw() win.flip() 冒頭で ``kb_started=False`` として、フラグ変数 ``kb_started`` を準備します。値は何でもよいのですが、「キーボードの検出がスタート済みか」、つまり二者択一の情報を保持すればいいので ``True`` と ``False`` を使うといいでしょう。細かなことですが、変数名と ``True``, ``False`` が一致するようにしておきましょう。 ``kb_started`` (kbがスタートした)という名前なのに「スタートしていない」という状態に ``True`` を割り当てるのは混乱の元です。最初はスタートしていない状態なので代入すべき値は ``False`` です。 続いて、 ``if t>0.5:`` のif文の中で、さらに ``if not kb_started:`` から始まるif文を挿入しています。条件は ``not kb_started`` ですから、 ``kb_started`` が ``False`` の時に ``True`` になります。 冒頭で ``kb_started`` に ``False`` を代入しているので、0.5秒経過して最初にこのif文に来た時に、このif文の内容が実行されることになります。 そこですかさず ``kb.clearEvents()`` と ``kb.clock.reset()`` を実行し、 ``kb_started`` に ``True`` を代入します。 **この代入が鍵です。** そうすることにって、次にこのif文に処理がまわってきたときに ``not kb_started`` が ``False`` となりif文の内容が実行されません。 これで「0.5秒経過したら一度だけキーボード検出のスタート処理をする」ことが実現できました。 フラグの活用は、汎用性が高い重要テクニックなので覚えておいてください。 最後にコード全体を示しておきます。 .. code-block:: python from psychopy import visual from psychopy import clock from psychopy.hardware import keyboard routineClock = clock.Clock() kb = keyboard.Keyboard() win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red') cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white') kb_started = False t = 0 routineClock.reset() while t < 10.0: t = routineClock.getTime() keys = kb.getKeys(keyList=['x', 'slash']) if len(keys) > 0: print(keys[0].name, keys[0].rt) break if t > 0.5: if not kb_started: kb.clearEvents() kb.clock.reset() kb_started = True stim.draw() cross.draw() win.flip() win.close() チェックリスト - psychopy.hardware.keyboardモジュールを用いてPsychtoolboxのKeyboardオブジェクトを作成できる。 - Keyboardオブジェクトに内蔵されたClockオブジェクトをリセットすることができる。 - Keyboardオブジェクトに保持されているキー押し情報をクリアできる。 - Keyboardオブジェクトの ``getKeys()`` メソッドの引数 ``keyList``, ``waitRelease``, ``clear`` の働きを説明できる。 - Keyboardオブジェクトの ``getKeys()`` の戻り値から押されたキー名、反応時間、キーが押されていた時間を得るコードを書くことができる。 - whileループを開始して一定時間経過した後に KeyboardオブジェクトのClockオブジェクトをリセットしてバッファーをクリアすることができる。 .. _section-create-parameter-list: パラメータを列挙する ------------------------------------------------------- 前節でおおよそ1試行分は完成したので、続いてBuilderで言うところの「ループ」を作って試行毎に刺激の色と位置が変化するようにしてみましょう。 本節ではまず、Builderの条件ファイルの代わりになるものを準備します。 .. code-block:: python conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] 解説無しでもなんとなく想像できるのではないかと思いますが、これは :numref:`第%s章 ` の条件ファイルの内容をリストにしたものです。 ``[]`` の途中での改行は許されますし、 ``,`` の前後に半角スペースを複数入れても構わないので、以下のように書いても構いません。この方がより条件ファイルっぽい見た目になりますが、本書では紙面を節約するために、以後1条件ずつ改行はしません。 .. code-block:: python conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] Builderではループの **[繰り返し回数 $]** で条件ファイルの各行を何度繰り返すかを設定できましたが、同様のことはリストに対する掛け算(``*`` 演算子)で実現できます。リストに整数を掛け算すると、リストの内容を整数回だけ繰り返したリストが得られます。そのリストを元の変数 ``conditions`` に戻すために ``*=`` を使います(「 :numref:`{number}:{name} ` 」参照)。 .. code-block:: python :emphasize-lines: 3 conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] conditions *= 5 # この行を追加 このリストの先頭から順番に要素を取り出していき、刺激の色と位置に代入すれば、刺激の色と位置を変更しながら繰り返す処理が実現できます。 上記のコードを実行した直後の状態では、 ``conditions`` の内容は 5を掛け算する前の ``conditions`` の内容をそのままの順番で5回繰り返したものになっています。 これはBuilderにおいて **[Loopの種類]** を sequential に設定した場合と同じです。 無作為な順序で繰り返すには **numpy** というパッケージのrandomモジュールに含まれる ``shuffle()`` が便利です(「 :numref:`{number}:{name} ` 」参照)。 以下のようにpsychopy.randomモジュールをimportして ``shuffle()`` を適用します。 .. code-block:: python :emphasize-lines: 1,7 from numpy import random # この行を追加 conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] conditions *= 5 random.shuffle(conditions) # この行を追加 よくある誤りとして、最後の行を ``conditions= random.shuffle(conditions)`` という具合に代入文として書いてしまうというものがあります。 ``shuffle()`` は少し変わり者の関数で、それ自体は値を返さずに、引数のシーケンスの要素を並び替えます。 ですから ``shuffle()`` の戻り値を代入してしまうと ``conditons`` の値は ``None`` になり、せっかく作った条件のリストが失われてしまいます。 このコードで並び替えられた ``conditions`` は、Builderにおいて **[Loopの種類]** を fullRandom にした場合と同じになります。Builderにおける **[Loopの種類]** のデフォルト値である random と同じにするには工夫が必要です。 いくつか方法がありますが、直感的なのはfor文(「 :numref:`{number}:{name} ` 」参照)とlistオブジェクトの ``append()`` メソッド(「 :numref:`{number}:{name} ` 」参照)を使うものです。 .. code-block:: python :emphasize-lines: 1,6-10 from numpy import random # この行を追加 conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] randomized = [] # この行以下を追加 for i in range(5): random.shuffle(conditions) randomized.append(conditions.copy()) conditions = randomized # 最終的なリスト名がrandomizedのままでいいなら不要 ``shuffle()`` で ``conditions`` を無作為に並べ替えて ``randomized_conditions`` という新たなリストへ追加する作業をfor文で5回繰り返すというもので、for文を知っていれば何をしているのかはわかると思います。 ``range()`` は「 :numref:`{number}:{name} ` 」で紹介していますが、 ``range(5)`` は ``(0, 1, 2, 3, 4)`` と同じと思っていただいてここでは問題ありません。 注意が必要なのは最後の行の ``conditions.copy()`` で、これは ``conditions`` の複製を返すメソッドです。 ``conditions`` そのものを ``append()`` で追加するのではなく、複製をとって追加しているわけです。 なぜわざわざ複製をとる必要があるのかを説明するには少々踏み込んだ議論が必要なので、興味がある人は「 :numref:`{number}:{name} ` 」をご覧ください。 以上、Builderにおけるrandom相当の処理をするコードはfullRandom相当と比べると少々準備が面倒です。 自分でコードを書くことに慣れていない間は、簡単な書けるfullRandom相当の処理で問題ない場合がほとんどだと思います。 本章ではfullRandom相当のコードを使うことにします。 これで、Builderの条件ファイルの代わりになるリストが作成できました。次節ではこのリストを実験スクリプトに組み込みます。 チェックリスト - 繰り返し毎に変更するパラメータを並べたリストを作ることができる。 - リストの内容を整数倍だけ繰り返したリストを作ることができる。 - Builderにおけるsequential, fullRandomに相当するようにパラメータを並べたリストを作ることができる。 - Builderにおけるrandomに相当するようにパラメータを並べたリストを作ることができる。 ループに相当するコードを書く ------------------------------------------------------- 前節で作成した ``conditions`` リストを使用して、以下のようなfor文を組めばBuilderのループに相当する処理ができるはずです。 .. code-block:: python for params in conditions: # conditionsからひとつずつ要素を取り出しながら繰り返す # 各試行の処理 # この中でparamsから値を取り出して刺激を変更する 問題は、「ループの起点となるこのfor文が実験スクリプトのどこに挿入されるべきか」です。 原則は **繰り返し毎に変更する点に関わる処理はfor文の中へ入れる** ということです。逆に言うと、繰り返し中に変更されないものは出来るかぎりfor文の中へは入れないようにするべきです。 変更されないのに何度も繰り返し実行するのは無駄です。最近のPCは高性能なので少々無駄な作業をさせても問題なく実行できるかもしれませんが、最悪の場合、無駄な処理のせいで刺激描画やキー押し検出のタイミングがくるってしまうかもしれません。 この原則を踏まえたうえで、実験スクリプトの冒頭部分を見てみましょう。 .. code-block:: python from psychopy import visual from psychopy import clock from psychopy.hardware import keyboard routineClock = clock.Clock() kb = keyboard.Keyboard() win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red') cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white') kb_started = False t = 0 routineClock.reset() while t < 10.0: t = routineClock.getTime() # 以下省略 最初のimport文は「使用する材料を列挙」する作業なので、繰り返し実行する意味がありません。 続く ``routineClock`` や ``kb`` 、 ``win`` の作成は、それぞれひとつだけ用意すればいいもので、やはり繰り返し実行する意味がありません。 ``stim`` と ``cross`` の作成はちょっと解説が悩ましいところです。それぞれを作成する ``Circle()`` や ``ShapeStim()`` の引数に、繰り返し毎に変更したい ``'red'`` や ``0.7`` といった値が含まれているので、「繰り返し毎に変更する点に関わる処理はfor文の中へ」の原則に該当するように思えます。 しかし、実は ``Circle()`` や ``ShapeStim()`` などで作成したPsychoPyの視覚刺激オブジェクトには、作成後に色や位置、大きさなどを変更する仕組みが備わっていて、作成作業自体は1回行えば済むように設計されています。 したがって、 ``stim`` と ``cross`` の作成処理も繰り返し実行する必要はありません。 この辺りの判断は「視覚刺激オブジェクトの仕様を知っているかどうか」に依るので、学び始めの方々にとっては難しい点だと思います。 このような知識が前提となる点が、BuilderよりCoderでの実験作成がハードルが高くなる理由だと言えるでしょう。 一方、続く ``kb_started = False`` は、キー押し検出がスタートして ``kb_started`` が ``True`` になった後、次の試行に移行した時に ``False`` に値を戻しておかないと「キー押し検出がスタートした」というフラグが維持されたままになってしまいます。したがって、この処理は繰り返し毎に行う必要があります。 さらにその後に続く ``t=0`` 、 ``routineClock.reset()`` といった処理も、繰り返し毎に行う必要があります。 以上のことから、「 ``kb_started = False`` の直前からfor文を開始する」のが適切だとわかります。 以下のようにコードを変更しましょう。 .. code-block:: python :emphasize-lines: 7-17 # 冒頭部省略 win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red') cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white') for params in conditions: #ここにfor文を挿入 kb_started = False # この行からwin.flip()まで1段(=半角スペース4字分)字下げ t = 0 routineClock.reset() while t < 10.0: t = routineClock.getTime() # 中略 win.flip() # この行まで1段字下げ win.close() # この行は字下げしない!! 最後の行に書いてあるように、 ``win.close()`` の行は字下げしてはいけません。 これは「後片付け」の処理であり、すべての試行が終わってから実行すべきものだからです。 これでfor文の挿入はOKですが、このままではfor文で使用している ``conditions`` が未定義なので、 ``conditions`` を作成するコードを追加しましょう。 for文の前であればどこでも動作に支障はありませんが、Pythonの公式ドキュメントにて「import文は最初にまとめて実行する」ことが推奨されていること、実験で使用するPsychoPyのオブジェクトの作成部分とごちゃ混ぜにならないことなどを考慮して、本章では以下のようにimport文の直後の位置に追加します。 .. code-block:: python :emphasize-lines: 4-9 from psychopy import visual from psychopy import clock from psychopy.hardware import keyboard from numpy import random # この行から追加 conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] conditions *= 5 random.shuffle(conditions) # この行まで追加 routineClock = clock.Clock() kb = keyboard.Keyboard() # 以下略 ちなみに筆者自身が教材用ではなく自分自身のためにこのスクリプトを書いているのなら、 ``conditions`` を作成するコードから先に書いて、その後にfor文を追加していたと思います。 この順番はどちらからでも構わないので、皆さんがスクリプトを書くときは自分にとって自然に作業できる順序でおこなってください。 また、今回のコードでは ``routineClock = clock.Clock()`` の直前で「繰り返す必要がない処理」と「繰り返す必要がある処理」が分かれるようになっていましたが、思いつくままにコードを書いていると両者がごちゃ混ぜになっていることも珍しくありません。 そういう場合は、落ち着いてコードの順番を並び替えましょう。 さて、現在の状態をBuilderで例えると、ループを挿入して条件ファイルも設定したけど、まだ各刺激のプロパティの修正と「繰り返し毎に更新」の設定をしていない状態です。これらの処理を行えば「繰り返し毎に刺激を変化させる」処理が完成します。 繰り返し中に変更する処理を追加するのですから、書き足すべき場所はfor文の中です。 まずfor文に入ってすぐのところに、以下のようなコードを追加します。 .. code-block:: python :emphasize-lines: 2-3 for params in conditions: stim_color = params[0] # この行を追加 stim_xpos = params[1] # この行を追加 kb_started = False t = 0 routineClock.reset() for文が実行されるとき、 ``params`` には ``conditions`` から順番に要素が取り出されて代入されます。今回のコードの場合、具体的には ``['red', 0.7]`` とか ``['green', -0.7]`` といったリストが代入されています。 したがって、 ``params[0]`` と書けば刺激色、 ``params[1]`` と書けば刺激のX座標が得られます。 ただ、コードを書いているときはこれらの対応関係が頭に入っているので問題ないのですが、後日このスクリプトを書き換えて別の実験を作ろうとした時や、研究室の後輩にスクリプトが引き継がれた時などに「 ``param[0]`` ってなんだったっけ?」となりがちです。 上記のように ``stim_color`` や ``stim_xpos`` といった役割を類推しやすい変数に代入しておくと、ずっとコードが読みやすくなります。 ちなみにPythonでは代入演算子の左辺に複数の変数をカンマで並べて、左辺の変数と同じ個数の要素を持つシーケンスを右側に置くと、シーケンスの各要素を一気に左辺の変数へ代入することができます。これを使うと以下のように1行で書くこともできます。 .. code-block:: python stim_color, stim_xpos = params PsychoPyの視覚刺激オブジェクトには色や位置を後から変更する仕組みがあるというのは先ほど述べましたが、もっともシンプルで公式ドキュメントでも推奨されている方法は「オブジェクトのデータ属性に値を代入する」というものです。具体的には以下のように書きます。 データ属性の名前は、視覚刺激オブジェクトを作成するときの引数と同じですので、:numref:`tbl-visualstim-arguments` で大抵の属性はカバーできるのではないかと思います。 .. code-block:: python stim.fillColor = stim_color stim.pos = (stim_xpos, 0) これらの代入文を実行すべきタイミングは「刺激描画を開始する前」で、「一度実行すれば充分」ですので、while文が始まる前に追加するのが妥当です。 現在のPCの実行速度を考えると本当に「無」のような時間ですが、 ``routineClock.reset()`` を実行してストップウォッチを0.0にした後に描画のための下準備を続けるのは気分的に落ち着かないので、 ``routineClock()`` より前に済ませてしまうべきでしょう。 本章では以下のようにしてみました。 .. code-block:: python :emphasize-lines: 5-6 for params in conditions: stim_color = params[0] stim_xpos = params[1] stim.fillColor = stim_color # この行を追加 stim.pos = (stim_xpos, 0) # この行を追加 kb_started = False t = 0 routineClock.reset() この例だと処理が単純すぎて、 ``stim_color`` にいったん代入せずに ``stim.fillColor = params[0]`` のように書いても ``params[0]`` が何だったかわからなくなることはなさそうですが、こういったものは習慣づけるのが大切です。Builderが出力するコードでも同様のことが行われています。 これで「刺激の色と位置を変更しながら試行を繰り返す」処理が完成しました。実行して動作を確認してください。 念のため全体のコードを示しておきます。 .. code-block:: python from psychopy import visual from psychopy import clock from psychopy.hardware import keyboard from numpy import random conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] conditions *= 5 random.shuffle(conditions) routineClock = clock.Clock() kb = keyboard.Keyboard() win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red') cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white') for params in conditions: stim_color = params[0] stim_xpos = params[1] stim.fillColor = stim_color stim.pos = (stim_xpos, 0) kb_started = False t = 0 routineClock.reset() while t < 10.0: t = routineClock.getTime() keys = kb.getKeys(keyList=['x', 'slash']) if len(keys) > 0: print(keys[0].name, keys[0].rt) break if t > 0.5: if not kb_started: kb.clearEvents() kb.clock.reset() kb_started = True stim.draw() cross.draw() win.flip() win.close() チェックリスト - 試行を繰り返すためのfor文を挿入すべき位置を判断できる。 - 視覚刺激オブジェクトの位置や色などのパラメータを更新することができる。 反応を記録する ------------------------------------------------------- 前節までの作業でだいぶ実験っぽくなりましたが、このままでは結果が保存されません。PsychoPy Coderで実行していればRunnerの「標準出力」にキー名と反応時間が表示されていますが、ここからわざわざコピーして保存するのも不便ですし、なにより描画された刺激の色と位置の情報がありません。 結果をファイルに出力する方法は、パッケージを利用するものを含めると本当にたくさんありますが、本章では最も基本的(原始的?)な、Pythonの標準モジュールのみで出力する方法を示します。 ファイルを読んだり書き込んだりできる状態にすることを、一般に「ファイルを開く」と言います。様々なアプリでファイルを読み込むときにメニューやボタンに「開く」と書いているのでご存じの方は多いと思います。 Pythonにおいて、ファイルを開くには ``open()`` という関数を使います。わかりやすいですね。 :numref:`tbl-open-arguments` に ``open()`` の主な引数を示します。 .. _tbl-open-arguments: .. csv-table:: ``open()`` の主な引数 :header: 引数, 説明 :widths: 24,76 ``file``, 開くファイルのパスを指定する。 **省略できない。** ``mode``, ファイルを開くときのモードを指定する。 ``'r'`` なら読み込み用、 ``'w'`` なら書き込み用(同名のファイルがあれば上書き)、 ``'x'`` なら書き込み用(同名のファイルがあればエラー)など、さまざまなモードがある。テキストファイルとして開く(``'t'``)、バイナリファイルとして開く(``'b'``)などのモードを追加指定できる。デフォルト値は ``'rt'`` 。tもbも指定されない場合はテキストファイルとして開く。 ``encoding``, 文字コードを指定する。現在の主要OSなら ``'utf-8'`` を指定しておけば日本語をはじめ各種言語の文字や記号などを扱うことができる。省略すると使用中の実行環境の標準的な文字コードが選ばれる。例えば日本語版Windows11なら ``'cp932'`` という文字コードとなる。 引数 ``file`` は :numref:`第%s章 ` のパスの解説を理解していれば、特に補足する必要はないでしょう。 ``mode`` は多彩な指定ができるのですべて説明しようとすると長くなってしまうのですが、実験の結果をテキスト形式で出力する用途に限るなら ``'w'`` か ``'x'`` のどちらかでOKでしょう。 どちらもデータの書き込みのためにファイルを開きますが、 ``'w'`` は同名のファイルが既に存在していた場合に上書きします。 **開いた瞬間に既存のファイルの内容は失われてしまう** ので注意してください。 ``'x'`` は ``'w'`` と同じですが、同名のファイルが既に存在していた場合にエラーでスクリプトの実行自体が停止します。 大事なデータを誤って上書きしないという意味では ``'x'`` が安全ですが、スクリプトの実行が停止してしまうのも状況によっては問題なので一長一短です。 ``encoding`` は :numref:`tbl-open-arguments` にも書いたように、UTF-8なら多くの言語の文字や記号を含めることができるので、無難な選択だと思います。 以下に ``open()`` でdata.csvという名前のファイルを書き込み用に開く例を示します。 .. code-block:: python data_file = open('data.csv', 'w', encoding='utf-8') 戻り値を ``data_file`` という変数に代入していますが、この中身はストリームと呼ばれるものです。ファイルのモードによって対応するクラスが異なるので、「○○クラスのオブジェクト」という書き方をせず単にストリームと書きます。ストリームが何かを説明するのは踏み込んだ話になるので、ファイルを読み書きするための窓口だと思っておいてください。 ストリームのメソッド ``write()`` を用いると、このストリームにデータを書き込むことができます。 書き込みが終わったら ``close()`` というメソッドを読んで「もう書き込みは終了したよ」とOSに伝えます。 ``close()`` を行わないと、他のアプリでファイルを開こうとしたときに「このファイルは他のアプリが使用中です」とか「ファイルを開くことができません」といったエラーが出て開けません。スクリプトの実行が終了すると自動的に ``close()`` が行われますが、PsychoPyのWindowオブジェクトを ``close()`` する習慣をつけるのをお勧めしたのと同じ理由で、ストリームも必ず ``close()`` することをお勧めします。 それでは、前節までに作成したスクリプトにコードを追加していきましょう。 ``open()`` は最初の試行が始まる前に一度だけ実行すればいいので、for文による繰り返しの前に追加しないといけません。 ファイル名の指定を誤って ``open()`` が失敗したら他の処理が無駄になってしまうので、以下の例ではimportが終わったらまず ``open()`` を実行するようにしてみました。 .. code-block:: python :emphasize-lines: 6-7 from psychopy import visual from psychopy import clock from psychopy.hardware import keyboard from numpy import random data_file = open('data.csv', 'w', encoding='utf-8') # この行を追加 data_file.write('stim_color,stim_xpos,response,rt\n') # この行も追加(続けて解説します) conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] conditions *= 5 random.shuffle(conditions) # 以下省略 ``open()`` の次の行でさっそく ``write()`` メソッドを使って書き込みを行っていますが、これを解説する前に「どのような形式でデータを出力するか」のイメージを固めておきましょう。 ``open()`` の引数に与えらえたファイル名はdata.csv、つまり拡張子として.csvが指定されています。 拡張子.csvはCSV形式(comma-separated value)のデータであることを表していて、Builderのデータ出力形式としても採用されています(:numref:`第%s章 `)。 CSV形式はExcelなどのアプリで開くことができますが、Windowsのメモ帳などのテキストエディタで開くと、その実体は以下のようなテキストファイルであることがわかります。 .. code-block:: stim_color,stim_xpos,response,rt red,0.7,x,0.64858742 green,-0.7,slash,0.712429 red,-0.7,x,0.889213 上記の内容をExcelなどで開くと、1行目にstim_color, stim_xpos, response, rtという見出し(ヘッダー)があって、2行目からデータが並ぶ4列のシートとして表示されます。 これなら ``write()`` メソッドを使ってまず1行目を書いておき、1試行終わるごとに1行ずつ書き出していけば実現できそうです。 以上を踏まえて改めて上記のコードの ``write()`` を確認すると、ヘッダーの行を出力していることがわかります。 引数の文字列の末尾の ``\n`` は改行文字(「 :numref:`{number}:{name} ` 」)で、これが無いと次に ``write()`` を実行した時に、新しい行として書き出されるのではなく今回書き出した行の続きとして書き出されてしまいます。 ヘッダーに続いてデータ本体の書き出しですが、これは「1試行毎に書き込む」のがコードの追加が少なくて楽です。万一実験途中でスクリプトが停止してしまっても、停止までに完了した試行のデータが残る可能性が高いというメリットもあります。 出力したいデータは刺激の色、位置、押したキー、反応時間の4つで、これらがすべて揃うのはキー押しが検出された時ですから、キー押しを検出してwhile文を中断するときに ``write()`` 文を挿入するのがいいでしょう。 Runnerへ出力するために挿入していた ``print()`` を削除して、そこへ ``write()`` を挿入します。 .. code-block:: python :emphasize-lines: 6-8 while t < 10.0: t = routineClock.getTime() keys = kb.getKeys(keyList=['x', 'slash']) if len(keys) > 0: data_file.write('{},{},{},{:.4f}\n'.format( # print()を削除して追加 stim_color, stim_xpos, keys[0].name, keys[0].rt)) # ここまで break 反応時間の桁数を指定するために「 :numref:`{number}:{name} ` 」の ``format()`` メソッドを使っているのですが、 :numref:`第%s章 ` まで読み進めている読者は少ないでしょうから難しいかもしれません。 反応時間の桁数を気にしないのであれば、「 :numref:`{number}:{name} ` 」と「 :numref:`{number}:{name} ` 」のテクニックを使って以下のように書いても構いません。 刺激の色(``stim_color``)とキー名(``keys[0].name``)は文字列ですが、刺激のX座標(``stim_xpos``)と反応時間(``keys[0].rt``)は数値であることに注意してください。 .. code-block:: python data_file.write(stim_color + ',' + str(stim_xpos) + ',' + \ keys[0].name + ',' + str(keys[0].rt) + '\n') 最後に、実験終了時にファイルを ``close()`` するのを忘れないようにしましょう。以下の例では ``win.close()`` の後に追加していますが、 ``win.close()`` の前に追加しても構いません。 ただし ``win.close()`` と同じ字下げでないといけない点に注してください。 .. code-block:: python :emphasize-lines: 2 win.close() data_file.close() # この行を追加 以上でデータファイルが出力できるようになりました。 実行して、カレントフォルダ(Coderから実行しているならスクリプトと同じフォルダ)にdata.csvというファイルができていること、Excelなどで開くと1行目に stim_color, stim_xpos, response, rtというヘッダがある4列のデータとして表示されることを確認してください。 チェックリスト - ファイルを書き込みモードで開くことができる。 - 文字コードを指定してテキストファイルを開くことができる。 - ファイルに書き込む文字列に改行を含めることができる。 いろいろな事態を考える ------------------------------------------------------- 前節でファイルへのデータの出力もできるようになって、あとはデータファイル名を実行時に設定できるようにすれば実験に使えそうな状態…と言いたいところですが、大きな問題が残っていることに読者の皆さんは気づいておられるでしょうか。 試しに、スクリプトを実行して何試行かキーを押さずに待ってください。 スクリプトが終了したら、出力されたdata.csvの内容を確認しましょう。 4条件を5回繰り返しているので20試行のデータがないといけないのですが、何試行分ありますか? キーを押さなかった試行数だけデータが足りないはずです。 なぜそうなるかは、スクリプトを落ち着いて読み返せばすぐにわかります。 キー押しが検出されたら ``write()`` が実行されますが、もしキーが押されないまま10秒が経過してしまったら、次のwhile文の繰り返しに入ってしまいます。キーが押されなかった場合に実行される ``write()`` がないのですから、何も出力されないのは当然です。 今回はスクリプトが単純なのですぐに気が付きますが、より複雑な実験のスクリプトを書いていると、ついうっかり特定のケースの動作を見落としてしまうことは珍しくありません。 ですから、自分で実験のスクリプトを書くときは必ず **動作確認** をしてください。 特に、 **教示通り適切に課題をおこなっていたらしないであろう操作をしたときにどうなるか** を確認しておくことが重要です。 実験参加者はこちらの教示を勘違いするかもしれませんし、真面目に課題をしてくれないかもしれませんし、うっかり間違えたりするかもしれません。 そのせいで「スクリプトが停止してしまった」とか「必要なデータが保存されていなかった」といった事態に陥るのはなんとしても避けたいところです。 今回のスクリプトの場合、以下の二つの修正案が考えられます。 1. 10秒で繰り返しを終了せず、キーが押されるまで待ち続ける 2. キーが押されなかった場合は刺激の色、位置と「反応がなかった」ことを示す値を出力する まず1.は、Builderで **[終了]** を空欄にしてキーが押されるまで待ち続けることに相当します。これはwhile文の条件を以下のように書き換えるだけで実現できます。 .. code-block:: python :emphasize-lines: 1 while True: # "< 10" を True に変更 t = routineClock.getTime() 「 ``True`` である間?」と思われるかもしれませんが、これは条件式が常に ``True`` なので「無限に繰り返す」ことを意味しています。 キー押しが検出されると ``break`` が実行されて繰り返しを中断することができますが、どこかで ``break`` されるようになっていなければ外部から強制的にスクリプトの実行を停止させない限り本当に終わらないので注意してください。 続いて2.は、Pythonのwhile文におけるelseの働きを知っていれば、コードを数行書き足すだけで実現できます。 .. code-block:: python :emphasize-lines: 7-9 while t < 10.0: t = routineClock.getTime() #中略 win.flip() else: #この行から追加 data_file.write('{},{},None,\n'.format( # stim_color, stim_xpos)) #ここまで追加 win.close() ``while`` と ``else`` が同じ字下げになっている点にご注意ください。このようにwhile文にelseを伴わせると、 **while文の条件が偽(False)になることによって終了した時のみelseの内容が実行されます** 。 このコードでwhile文の条件式 ``t < 10.0`` が偽になるというと、10秒間キー押しが無かった場合に他なりません。 ですから、while文に伴うelseに「キーが押されなかった場合」のデータを出力するコードを書いておけば、キー押しが無かった試行も記録に残ります。 ここではキー名としてNoneと出力しています。 ``'{},{},None,\n'`` の ``None`` の ``,`` と ``\n`` の間に何も書かれていないことに注意してください。 本来、この位置には反応時間を出力しますが、何も書かないことによって、Excel等で開いたときに反応時間が空欄となります。 以上でキーが押されなかった場合の対策ができましたが、本章の締めくくりの節のために布石を打っておきたいと思います。2.の修正案で、whileに伴うelseを使わずに、while文が終了してからキー押しの有無に応じた書き込みを行うようにしてみましょう。 .. code-block:: python :emphasize-lines: 6,15-20 while t < 10.0: t = routineClock.getTime() keys = kb.getKeys(keyList=['x', 'slash']) if len(keys) > 0: break # write()をカットして、下に追加するif文へ貼り付ける if t > 0.5: # 中略 win.flip() if len(keys) > 0: data_file.write('{},{},{},{:.4f}\n'.format( # ここに貼り付けて stim_color, stim_xpos, # 字下げを調節する keys[0].name, keys[0].rt)) else: #この行から追加 data_file.write('{},{},None,\n'.format( # stim_color, stim_xpos)) #ここまで追加 win.close() 作業としては、まずwhile文が終わった直後にif文を追加して、キー押しを検出した時の ``write()`` をこの新たなif文のところへカット&ペーストします。 そのまま貼り付けると字下げが合わないので、字下げを1段階減らして調整しましょう。 そしてこの新たなif文にelse伴わせて、そこへキー押しが無かった時のための ``write()`` を追加します。 できたらスクリプトを実行して、何試行かわざとキーを押さずに進めてください。 データファイルには、キーを押さなかった試行の記録もきちんと残っているはずです。 チェックリスト - 参加者が教示通り適切に課題をおこなっていたらしないであろう操作をしたときの動作確認の必要性を説明できる。 .. _section-use-psychopy-dialog: PsychoPyのダイアログを使う ------------------------------------------------------- 少し寄り道しましたが、いよいよ本章も大詰めです。 ここまで作成してきたスクリプトでは、結果を出力するファイル名がdata.csvに固定されているので、毎回スクリプトを書き換えてファイル名を変更するか、実験が終わった後にすかさずdata.csvの名前を変更するなり別の場所へ移動させるなりしないと、実行する度にファイルが上書きされてしまいます。 コマンドラインインターフェースに慣れた人ならコマンドライン引数として与えた方が楽だと思いますが、本章はCoderから実行することを想定しているのでトピックとして取り上げます(「 :numref:`{number}:{name} ` 」)。 では本節ではどうするかというと、PsychoPyのguiモジュールを使ってBuilderの実験情報ダイアログ風のものを実現したいと思います。 以下のコードをご覧ください。 .. code-block:: python from psychopy import gui expInfo = { 'データファイル名':'data.csv', '条件':['上下','左右'] } dlg = gui.DlgFromDict(expInfo, title='サイモン効果 実験') print(expInfo['データファイル名'], expInfo['条件']) まず ``from psychopy import gui`` でpsychopy.guiモジュールをimportしています。これはもう馴染みですね。 続いて変数 ``expInfo`` ですが、代入されているのは辞書(dict)オブジェクトです。 dictオブジェクトについては「 :numref:`{number}:{name} ` 」で簡単に触れていますが、ここまできちんと解説していませんでした。 listやtupleが「要素を順番に並べて保持し、インデックスを使って要素を取り出せる」データ型であるのに対して、dictは「キー(key)と呼ばれる見出し語の対として要素を保持し、キーを使って要素を取り出せる」データ型です。現実の辞書を使う時と似ているのでこのように呼ばれます。他のプログラミング言語では「連想配列」や「ハッシュ」などと呼ばれることもあります。 Pythonにおいてdictオブジェクトを作る時は、 ``{ }`` で要素を囲みます。listなら ``[ ]`` 、tupleなら ``( )`` でした。 dictオブジェクトの要素は、必ずキーと値を対にして ``:`` で区切って書かないといけません。 ``:`` の左がキー、右が値です。 上記の例では、 ``'データファイル名'`` と ``'条件'`` という2つのキーが定義されていて、 ``'データファイル名'`` に対応する値が ``'data.csv'`` 、 ``'条件'`` に対応する値が ``['上下','左右']`` です。 この ``expInfo`` に代入された辞書オブジェクトから ``'データファイル名'`` に対応する値を取り出すには ``expInfo['データファイル名']`` という具合に ``[ ]`` 演算子を使います。 もう読者の皆さんはお気づきだと思いますが、これはBuilderの実験情報ダイアログから値を取り出すときの書き方そのものですね。 続く ``gui.DlgFromDict()`` 関数は、なんとなく名前から想像できるように、dictオブジェクトを項目としたダイアログを作る働きをします。最初の引数は省略不可で、dictオブジェクトを指定します。 ``title`` はダイアログのタイトルの指定で、不要なら省略して構いません。この例には示していないですが、 ``show`` という引数に ``True`` を渡すとダイアログの作成と表示を一気に行います。デフォルト値は ``True`` です。実験のためのパラメータを入力する用途ならデフォルト値の ``show=True`` で問題ないでしょうから、この例のように省略して構いません。 戻り値の ``dlg`` には DlgFromDict オブジェクトというダイアログを制御するためのオブジェクト(以下ダイアログオブジェクトと表記)が代入されます。 ``DlgFromDict()`` はダイアログが閉じられるまでスクリプトの実行をブロックするので、次の行の ``print()`` を実行するときにはダイアログはすでに閉じられています。 ダイアログオブジェクトのデータ属性 ``OK`` には、ダイアログがOKボタンを押して閉じられていれば ``True`` 、キャンセルボタンやダイアログの「閉じるボタン」などで閉じられていれば ``False`` が代入されています。 上記のコードでは ``OK`` の値とダイアログの「データファイル名」、「条件」の値を表示していますので、何度か実行してOKボタンで閉じたり、キャンセルボタンで閉じたりして確かめてください。 また、「条件」の項目が「上下」と「左右」から選択する形式になることも確認してください。 「項目の値としてリストを設定しておくと、ダイアログ上では選択式になる」という動作は「 :numref:`{number}:{name} ` 」で取り上げた実験情報ダイアログのテクニックに対応しています。 では、このダイアログを実験のスクリプトに組み込んでみましょう。ファイルを開く前に実行しないと意味がありませんから、挿入場所はimport文の後、 ``open()`` の前しかありません。 なお、 **PsychoPy の刺激描画ウィンドウがフルスクリーンモードで表示されているときは、ダイアログを開いても描画ウインドウに隠れて見えなくなるので注意してください。最悪、強制的にPCを再起動するしかなくなる可能性があります。** .. code-block:: python :emphasize-lines: 5-8,10 from psychopy import visual from psychopy import clock from psychopy.hardware import keyboard from numpy import random from psychopy import gui # この行から追加 expInfo = {'データファイル名':'data.csv'} dlg = gui.DlgFromDict(expInfo, title='サイモン効果 実験') # この行まで追加 data_file = open(expInfo['データファイル名'], 'w', encoding='utf-8') # データファイル名を変更 data_file.write('stim_color,stim_xpos,response,rt\n') 先ほどの例では ``DlgFromDict()`` の解説のために「条件」という項目を設けていましたが、ここでは不要なので「データファイル名」という項目のみのダイアログを作成しています。 さらに、 ``open()`` 関数のファイル名を指定する引数を ``expInfo['データファイル名']`` とすることによって、ダイアログの「データファイル名」に入力されている文字列をファイル名としてデータファイルを開きます。 以上で最小限の目的は達成しましたが、このままではダイアログのキャンセルボタンを押した場合もスクリプトの残りがそのまま実行されてしまいます。 いろいろな考え方があるでしょうが、「キャンセル」を押したらデータファイル名を入力しなかったことになってほしいですし、データファイル名を入力しなかったのなら実験を実験しないでもらいたいというのが一般的だと思います。 キャンセルされた場合はデータ属性 ``OK`` が ``False`` になるのですから、if文を使ってスクリプトの実行を中断すればいいでしょう。 スクリプトの中断にはsysというPythonの標準モジュールの ``exit()`` という関数を使います。 .. code-block:: python :emphasize-lines: 10-11 from psychopy import visual from psychopy import clock from psychopy.hardware import keyboard from numpy import random from psychopy import gui import sys expInfo = {'データファイル名':'data.csv'} dlg = gui.DlgFromDict(expInfo, title='サイモン効果 実験') if not dlg.OK: # この行を追加 sys.exit() # この行を追加 data_file = open(expInfo['データファイル名'], 'w', encoding='utf-8') data_file.write('stim_color,stim_xpos,response,rt\n') ``not dlg.OK`` というのがピンとこないかも知れませんが、ダイアログでOKボタンが押されなかったときに ``dlg.OK`` は ``False`` になるので、 ``not dlg.OK`` は ``True`` になります。 ですから ``if not dlg.OK:`` とすればOKボタンが押されなかったときの処理を書くことができます。 ついでなのでもう少しpsychpy.guiの関数を紹介しましょう。 ``infoDlg()`` を使うと、ユーザーにメッセージを表示するためのOKボタンのみがあるダイアログを表示されます。 以下のようにすると、データファイル名を入力するダイアログをOKボタン以外で閉じた時に「キャンセルされました」というメッセージが書かれたダイアログが表示されます。 ``DlgFromDict()`` と同様、メッセージダイアログが表示されている間はスクリプトの処理がブロックされます。 一方、 ``infoDlg()`` は ``DlgFromDict()`` と異なり戻り値がありませんので、 ``DlgFromDict()`` のように戻り値を変数に代入する必要はありません。 .. code-block:: python :emphasize-lines: 4 expInfo = {'データファイル名':'data.csv'} dlg = gui.DlgFromDict(expInfo, title='サイモン効果 実験') if not dlg.OK: gui.infoDlg('サイモン効果 実験','キャンセルされました') # この行を追加 sys.exit() ``infoDlg()`` と似た関数に ``criticalDlg()`` というものがあります。これは同様にメッセージを表示する関数ですが、名前の通り致命的なエラーメッセージを表示するために用います。 OSによって、ダイアログに表示されるアイコンが異なったり、エラー音が鳴ったりするなど、エラーであることを強調するようにデザインされています。 例として、ファイル名が不正であったなどの理由でファイルが開けなかった場合にエラーメッセージを表示するようにしてみます。 難しいのは「 ``open()`` の実行に失敗した」ときにスクリプトの実行自体が中断されてしまうので、先ほどの「ダイアログがOKボタン以外で終わった」ときと同じ方法でダイアログを開くことができない点です。 これには ``try`` ~ ``except`` 文というものを用います。以下のコードを見てください。 .. code-block:: python try: data_file = open(expInfo['データファイル名'], 'w', encoding='utf-8') except OSError: gui.infoDlg('サイモン効果 実験','{}を開けません'.format(expInfo['データファイル名'])) sys.exit() スクリプト中に ``try:`` が登場すると、まず ``try:`` に伴う字下げされたコードを実行します。 そこでエラーが起きなければ良いのですが、エラーが生じたら ``except:`` に伴う字下げされたコードに処理が自動的に移行します。この ``except:`` ブロックでエラーに対処するための処置をおこないます。 ここでは ``criticalDlg()`` で何が起きたのかをユーザーに示して、そののちに ``exit()`` でスクリプトを終了しています。 ``except OSError:`` の ``OSError`` というのは、「OSに関連するエラーが生じた時に」と限定するための指定で、ファイルを開く以外の何かのエラーが万一生じてしまった場合に誤って「xxxを開けません」というメッセージを表示してしまうのを防ぐ役割があります。 OSError以外のエラーによって止まった場合は、ただスクリプトの実行が中断されてRunnerの出力などにPythonの標準のエラーメッセージが出力されます。 まあ、このスクリプトを実験に使う場合は実験者がその場で実行するのでしょうから、エラーダイアログを表示しなくても対処に困ることはないでしょう。本書の守備範囲ではありませんが、オンライン実験のように実験者が立ち会わずに実験をする場合は、こういったエラーメッセージは親切すぎるほど表示した方がよいです。 以上で本節の内容はおしまいですが、最後にpsychopy.guiの関数をあと2つ紹介しておきます。 ``fileOpenDlg()`` と ``fileSaveDlg()`` です。 これらの関数は、スクリプトを実行中のシステムに組み込まれているファイルダイアログを開きます。他のアプリでファイルを開いたり保存したりするときに表示されるおなじみのダイアログが表示されるので、ファイル名を得るだけでしたら ``DlgFromDict()`` よりこちらの方が良いでしょう。 以下に例を示します。 .. code-block:: python # fileOpenDlgはそでに存在しているファイルを開くときに使う filenames = gui.fileOpenDlg(prompt='条件ファイルを選択してください') if filenames is not None: cnd_file = open(filenames[0], 'r', encoding='utf-8') # 現時点で存在していないファイルも開く可能性がある場合はfileSaveDlgを使う filenames = gui.fileSaveDlg(prompt='データを出力するファイルを指定してください') if filenames is not None: data_file = open(filenames[0], 'w', encoding='utf-8') ``fileOpenDlg()`` と ``fileSaveDlg()`` の最大の違いは、 ``fileOpenDlg()`` は既に存在しているファイルしか選択できないことです。実験の実行に必要なファイルを読み取り用に開く場合はこちらがよいでしょう。 実験データを保存するために書き込み用に開く場合は、存在していないファイル名を指定できる ``fileSaveDlg()`` が適しています。 ダイアログに最初に表示されるフォルダを指定したり、特定の拡張子を持つファイルだけを表示するなどの設定をおこなう引数もありますが、ここでは省略します。 どちらの関数も、選択したファイルのパスを表す文字列を並べたリストが戻り値です(複数ファイルの選択に対応しているのでリストになる)。ファイルが選択されずにダイアログが閉じられた場合は ``None`` になります。 したがって、戻り値が ``None`` であるかで処理を分岐することができます。 データ出力用のファイル名を得るためなら、通常はファイル名は1つでしょうから、この例の ``filenames[0]`` のように戻り値の最初の要素を ``open()`` に渡せばよいでしょう。複数選択したファイルをすべて処理する必要がある場合はfor文などと組み合わせる必要があります。 チェックリスト - ``DlgFromDict()`` でダイアログを作成するためのdictオブジェクトを作成することができる。 - ``DlgFromDict()`` ダイアログで選択式の項目を作成することができる。 - ``DlgFromDict()`` ダイアログがOKボタンを押して閉じられたか否かに応じて処理を分けるコードを書くことができる。 - ``DlgFromDict()`` に入力された値を取得するコードを書くことができる。 - スクリプトを途中で終了するコードを書くことができる。 - OKボタンのみを持つ、メッセージを表示するためのダイアログを開くことができる。 - PsychoPyの刺激描画ウィンドウをフルスクリーンモードにしているときにダイアログを表示してはいけない理由を説明できる。 Builderとの対応関係を理解する ------------------------------------------------------- 本章の締めくくりとして、ここまで書いてきたスクリプトの全体を改めて確認しましょう。ただし前節の ``try`` ~ ``except`` 文以降の内容は含んでいません。 ただ掲載するだけだとおもしろくないので、「この実験がBuilderで作られていたら」を想定して、Codeコンポーネントの6つの区分「実験初期化中」「実験開始前」「Routine開始時」「フレーム毎」「Routine終了時」「実験終了時」がどこにあたるかをコメントとして追加してみました。 スクリプトの後に解説を加えますので、まず目を通してみてください。 .. code-block:: python from psychopy import visual from psychopy import clock from psychopy.hardware import keyboard from numpy import random from psychopy import gui import sys ################ ここから Builder の「実験初期化中」および「実験開始前」 ################ expInfo = {'データファイル名':'data.csv'} dlg = gui.DlgFromDict(expInfo, title='サイモン効果 実験') if not dlg.OK: gui.infoDlg('サイモン効果 実験','キャンセルされました') sys.exit() try: data_file = open(expInfo['データファイル名'], 'w', encoding='utf-8') except: gui.criticalDlg('サイモン効果 実験','{}をオープンできません'.format(expInfo['データファイル名'])) sys.exit() data_file.write('stim_color,stim_xpos,response,rt\n') conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] conditions *= 5 random.shuffle(conditions) routineClock = clock.Clock() kb = keyboard.Keyboard() win = visual.Window(fullscr=False, size=(1280, 720), units='height') stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red') cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white') ################ ここまで Builder の「実験初期化中」および「実験開始前」 ################ ################ Builder のループの起点 ################ for params in conditions: ################ ここから Builder の「Routine開始時」 ################ stim_color = params[0] stim_xpos = params[1] stim.fillColor = stim_color stim.pos = (stim_xpos, 0) kb_started = False t = 0 routineClock.reset() ################ ここまで Builder の「Routine開始時」 ################ while t < 10.0: ################ このループ内は Builder の「フレーム毎」 ################ t = routineClock.getTime() keys = kb.getKeys(keyList=['x', 'slash']) if len(keys) > 0: break if t > 0.5: if not kb_started: kb.clearEvents() kb.clock.reset() kb_started = True stim.draw() cross.draw() win.flip() ################ ここまで Builder の「フレーム毎」 ################ ################ ここから Builder の「Routine終了時」 ################ if len(keys) > 0: data_file.write('{},{},{},{:.4f}\n'.format( stim_color, stim_xpos, keys[0].name, keys[0].rt)) else: data_file.write('{},{},None,\n'.format( stim_color, stim_xpos)) ################ ここまで Builder の「Routine終了時」 ################ ################ Builder のループの終点 ################ ################ ここから Builder の「実験終了時」 ################ win.close() data_file.close() # この行を追加 ################ ここまで Builder の「実験終了時」 ################ いかがだったでしょうか。 以下に要点を箇条書きします。 1. 内部で ``flip()`` を実行する繰り返し文(今回のスクリプトではwhile文)がルーチンに相当する。このループ内がBuilderの「フレーム毎」に相当する。 2. Builderにおいてルーチンを繰り返すためのループは、ルーチンに相当するコードを内部に持つfor文に相当する。 3. Builderにおけるループの条件ファイルは、for文に与えるパラメータを並べたシーケンス(今回のスクリプトではlist)に相当する。 4. ループに対応するfor文のなかで、ルーチンに対応する繰り返し文(今回のスクリプトではfor文)の前後にあって、ルーチンの処理を実行するための準備や、次のルーチンの実行のために後始末する部分がBuilderの「Routine開始時」と「Routine終了時」に相当する。 5. スクリプトの冒頭から、ループに対応するfor文が始まる直前までの部分が、Builderの「実験初期化中」および「実験開始前」に相当する(今回のスクリプトでこの二つを厳密に区別するのは不可能)。 6. ループに対応するfor文が終わってから、スクリプトの終わりまでが、Builderの「実験終了時」に対応する。 今回のスクリプトにはルーチンに相当するものとループに相当するものがそれぞれひとつずつしかなかったですが、複数のルーチンやループがあるものについては以下のことが言えます。 7. ループに対応するfor文の中で、内部で ``flip()`` を実行する繰り返し文が2つ以上含まれている場合は、ルーチンを複数個含むループに対応する。 8. ループに対応するforr文を内側に含むようにさらにfor文が存在する場合、Builderにおける多重ループに対応する。 この対応関係を知っていれば、BuilderでCodeコンポーネントを使う時にどのようなコードを書けばよいかイメージしやすいでしょうし、Builderで実験を作成していた人がCoderへ移行するのも容易になるでしょう。Coderに限らず、JavaScriptやMatlabを用いてスクリプトを作成して実験を作成するような開発環境への移行の参考にもなると思います。 練習問題 ------------------------------------------------------- 前節の要点が理解できていれば、 :numref:`第%s章 ` で取り上げた多重ループによるブロック化を実現するのは難しくありません。 本章のスクリプトを改造して、刺激が ``(0.7,0)`` または ``(-0.7,0)`` に出現する「左右条件」と、 ``(0,0.15)`` または ``(0,-0.15)`` に出現する「上下条件」を1ブロックずつ実行するようにしてください。「左右条件」は本章で作成したスクリプトの課題と同一です。「上下条件」の試行数は「左右条件」と同じとします。「左右条件」と「上下条件」のどちらを先に実施するかは実行時にランダムに決定するようにしてください。 ヒント: :numref:`第%s章 ` で外側のループ用に条件ファイルをひとつ、内側のループ用に条件ファイルをふたつ用意しましたが、この練習問題でも同じです。外側のforループでどちらのブロックを実行するか決定し、それに応じて内側のforループで使用するシーケンスを切り替えればよいのです。 この章のトピックス --------------------------------------------- .. _topic-time-control: 時間制御に関する踏み込んだ議論 (上級) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 現代のマルチタスクOSが搭載されたPC上で正確な時間制御を行うのは非常に困難で、時間制御に関して細かいことを言い出したら本当にたくさんの話題があります。 ここでは、本文の方法で刺激を意図した時間だけ提示できることについての確認と、刺激描画と他のイベントの同期について少しだけ補足します。 まず刺激の提示時間についてですが、「 :numref:`{number}:{name} ` 」でも触れたように、モニターを使う以上はモニターの1フレームの時間の定数倍でしか提示できません。それを踏まえたうえで、60Hzのモニターで100ミリ秒間刺激を描画するケースを考えます。100ミリ秒は1/10秒、1秒間に60フレームですから1/10秒は60/10=6フレームです。 :numref:`fig-frame-timing` の左から右へ描かれた大きな矢印が時間の流れを示していて、黒い縦線はバッファー入れ替えのタイミングを示しているとします。便宜上、図の一番左側の(つまり図中で最も早い)入れ替えの時刻を0.0msとします。 .. _fig-frame-timing: .. figure:: fig_c01/frame-timing.png :width: 80% ``reset()`` と ``flip()`` のタイミング。緑色のf1、f2…は刺激提示の第1フレーム、第2フレーム…を表す。提示時間の制御だけを考えるなら、1フレームに近いかそれ以上の時間を要する割り込みが入らない限り、 ``reset()`` のタイミングを考慮する必要はない。 本章の実験スクリプトを実行するにあたって、実行開始のタイミングとバッファー入れ替えタイミングを合わせる方法はありません。ですから、刺激描画ループを開始するときのClockオブジェクトの ``reset()`` はバッファー入れ替えのタイミングとずれていると考えるべきです。 ``reset()`` の後に刺激描画処理が行われて ``flip()`` が実行されますが、 ``flip()`` が完了する時刻が ``reset()`` から何ミリ秒離れているかはわかりません。 そんなことで正確に時間制御ができるのかと心配になりますが、 ``flip()`` がwhileループの最後、 ``getTime()`` がwhileループの冒頭に置かれているので、 ``flip()`` が完了した後本当にごくわずかな遅延で ``getTime()`` が実行されます。そして1フレーム目、2フレーム目と刺激が提示された後、 ``reset()`` から数えて6回目の ``flip()`` (つまり6フレームの提示開始)が始まった直後の ``getTime()`` ではまだ戻り値は100ミリ秒未満のはずです。そしてさらに次の7回目の ``flip()`` が行われた直後の ``getTime()`` で戻り値が100ミリ秒を超えますが、ここですかさず ``break`` でwhileループを終了すれば結果的に目的通り6フレーム描かれます。 :numref:`fig-frame-timing` のようにスクリプトが動作する条件は、 ``reset()`` からその直後の ``flip()`` 終了までの時間が1フレーム分(ここでは16.7ミリ秒)未満におさまっていることと、6フレーム目を表示する ``flip()`` が完了してから次の ``getTime()`` までがごく短い遅延で実行されることにあります。 通常、これらの条件は満たされるはずですが、マルチタスクOSでは複数のアプリケーションやバックグラウンドサービスが動作しているため、そちらで重い処理が発生すると条件が満たされない可能性があります。 ですから、厳密な時間制御が求められる実験では **余計なアプリケーションはすべて終了させ、バックグランドサービスは出来るかぎり停止させるべきです** 。他のアプリケーションやバックグラウンドサービスの影響は、OSによってはスクリプトの実行優先度を上昇させることで多少押さえることはできますが、スクリプトの書き方で回避するのは困難です。 以上のようなわけで、刺激描画だけを考えるのならば基本的に本章のスクリプトの書き方で問題はありません。しかし、音声刺激を再生したり、シリアルポートから測定機器にトリガーを出したりといった他のイベントと視覚刺激を同期させたい場合は、注意しないといけない点があります。 :numref:`fig-callonflip` 左は、 ``getTime()`` で得た時刻を基準にして音刺激を再生するという想定で処理の流れを示しています。 ``flip()`` の後に ``getTime()`` が行われ、その戻り値に基づいて音楽の再生を開始するのですから、再生開始のタイミングは次の ``flip()`` のかなり前になります。次の ``flip()`` までの時間はモニターのフレームレートがわかっていれば見当がつくのだから、ギリギリまで待ってから再生を開始したらいいじゃないかと思われるかもしれませんが、先に述べたように他のアプリケーションやバックグラウンドサービスから横槍が入るかもしれないので危険です。 そこで登場するのがWindowオブジェクトの ``callOnFlip()`` というメソッドです。 ``callOnFlip()`` は :numref:`fig-callonflip` 右のように、引数として渡された作業を保持しておき、 ``flip()`` が行われたときに実行します。BuilderでいうとButtonコンポーネント(「 :numref:`{number}:{name} ` 」)の **[コールバック関数]** に似ていますね。 ``callOnFlip()`` を使うと比較的高い時間制度でバッファー入れ替えに同期した再生開始が可能です。 .. _fig-callonflip: .. figure:: fig_c01/callonflip.png :width: 80% サウンドとの同期問題。サウンドを鳴らす処理を実行してから ``flip()`` が完了するまでの時間は予測しがたく、高精度で両者を同期させないといけない実験で問題となり得る。 ``callOnFlip()`` を使えば時間差が安定する。 そんな便利なメソッドがあるなら、なぜ本文で ``callOnFlip()`` 紹介しないんだと言われそうですが、その理由はまずPsychoPy以外の心理実験用のライブラリで ``callOnFlip()`` に相当する関数がない場合があり、あまり一般性のないテクニックであること、そしてイベントとの同期が ``callOnFlip()`` で全て解決!とはいかないことです。 というのも、「再生を開始する」といったコードは実行されたからと言って即座に音がなるものではなく、オーディオドライバによる処理などいくつかの段階を経て最終的に音が鳴ります。これらの段階に要する時間はハードウェアやドライバに依存していて、スクリプトからは干渉できません。 ``flip()`` にしても、モニターによってはPCから信号を受け取った後に処理を行ってから実表示をおこなうものがあるなど、実際に画面が変化するまでには遅延があります。そうすると、スクリプトで可能な範囲でぴったり同期させたとしても、実際に光や音として発せられるタイミングは同期していないかもしれません。本当に高い精度で同期させるには、本番の実験に使用するハードウェアに直接センサーを取り付けるなどして外部的に測定をおこなって補正しなければいけません。こういったレベルの話は、いずれ取り上げるかもしれませんが少なくとも本章で想定している難易度を大きく超えるものです。 .. _topic-reference-and-copy: 代入とコピーに関する踏み込んだ議論 (上級) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 「変数」は箱、「変数名」は箱につけた名前、「変数に代入する」とは箱の中へ入れること、という例え話はよく使われますし、本書でも用いました。 しかし、よくよく考えると不思議な点があります。例えば ``a`` に ``'りんご'`` という値が入っていて、 ``b = a`` を実行して ``b`` に代入したとします。例え話に従えば、 ``a`` の箱に入っていたものを ``b`` へ移したわけですが、それでは ``a`` の箱は空っぽになったのでしょうか? 確認するために以下のコードを実行してみましょう。 .. code-block:: python a = 'りんご' b = a print('aの中身:{}, bの中身:{}'.format(a,b)) Coderで実行している人はRunnerの「標準出力」を見てください。 結果は「aの中身:りんご, bの中身:りんご」となります。どちらにも ``'りんご'`` が入っているので、例え話に沿って考えるなら「 ``'りんご'`` が複製されて2つになった」ということになるでしょうか。 では、続いて以下のコードを実行してみます。 .. code-block:: python a = ['りんご', 'みかん'] b = a print('aの中身:{}, bの中身:{}'.format(a,b)) 結果は「aの中身:['りんご', 'みかん'], bの中身:['りんご', 'みかん']」です。aとbの中身は同じで、「複製された」という解釈で問題ないように思われます。ここまではいいですね。 さらに続いて、次のコードを実行してみましょう。 最初の3行は同じで、 ``b`` に代入した後に ``a`` の第1要素を ``'ぶどう'`` に置き換えています。 .. code-block:: python a = ['りんご', 'みかん'] b = a print('aの中身:{}, bの中身:{}'.format(a,b)) a[0] = 'ぶどう' print('ぶどうを代入した後: aの中身:{}, bの中身:{}'.format(a,b)) 結果は以下のようになります。 :: aの中身:['りんご', 'みかん'], bの中身:['りんご', 'みかん'] ぶどうを代入した後: aの中身:['ぶどう', 'みかん'], bの中身:['ぶどう', 'みかん'] なんと、 ``a[0]`` に ``'ぶどう'`` を代入したら ``b[0]`` まで ``'ぶどう'`` になってしまいました。 ということは、 ``b = a`` は ``a`` の中身を複製したのではなく、 ``b`` と ``a`` は同じ「箱」を表しているのでしょうか? しかし、以下のコードを実行すると、単純にそういうわけではないことがわかります。 .. code-block:: python a = 'りんご' b = a print('aの中身:{}, bの中身:{}'.format(a,b)) a = 'ぶどう' print('ぶどうを代入した後: aの中身:{}, bの中身:{}'.format(a,b)) 結果は以下の通りです。 :: aの中身:りんご, bの中身:りんご ぶどうを代入した後: aの中身:ぶどう, bの中身:りんご ``a`` の中身は ``'ぶどう'`` に変わっていますが、 ``b`` の中身は ``'りんご'`` のままです。 いったいどうなっているのでしょうか。 ここから先を推理していくのは難しいので結論を言うと、 **Pythonにおける代入とは「箱」に名前を付けること** なのです。 PC上で扱っているデータは、基本的にはPCのメモリ上に保存されています。 メモリは膨大な部屋数を持つマンションのようなもので、それぞれの部屋に順番に番号がついています。データはそれぞれの部屋に保存されていて、データを参照する必要がある時にはその部屋番号を頼りにアクセスします。この部屋番号の事をアドレスと呼びます。 Pythonの世界では、PCに搭載されているメモリの実際のアドレスを確認することはできないのですが、Pythonが内部的に割り振っているIDという値を ``id()`` という関数を使って確認することができます。 早速試してみましょう。 .. code-block:: python a = 'りんご' b = a print('aのID:{:X} bのID:{:X}'.format(id(a), id(b))) 書式指定の ``{:X}`` は16進数表示の指示です。実行すると以下のようになります。 :: aのID:2A3BAAE2A90 bのID:2A3BAAE2A90 IDの数値は実行毎に変わるので、みなさんが実行すると違い値になるでしょうが、 **aとbのIDが同じ** であるはずです。 上記の例では、 ``a`` という変数と ``b`` という変数はどちらもID:2A3BAAE2A90のアドレスを指しているので、いわば **ひとつの「箱」にaというラベルとbというラベルが貼られている状態** です。 ここで ``a`` に別の値を代入してみます。 .. code-block:: python a = 'りんご' b = a print('aのID:{:X} bのID:{:X}'.format(id(a), id(b))) a = 'ぶどう' print('ぶどうを代入した後: aのID:{:X} bのID:{:X}'.format(id(a), id(b))) 実行結果の例を示します。 :: aのID:2A3BAAE2B50 bのID:2A3BAAE2B50 ぶどうを代入した後: aのID:2A3BAAE2CD0 bのID:2A3BAAE2B50 ``a`` に ``'ぶどう'`` を代入した後、 ``a`` のIDは変化していますが、 ``b`` のIDは変化していません。つまり、 ``'ぶどう'`` というデータが新たに用意され、ID:2A3BAAE2CD0のアドレスに格納された後、 ``a`` というラベルがID:2A3BAAE2CD0を指すように更新されたのです。 これがPythonの代入において実際に生じていることなのです。 同じことを ``['りんご', 'みかん']`` の代入で試してみましょう。 .. code-block:: python a = ['りんご', 'みかん'] b = a print('aのID:{:X} bのID:{:X}'.format(id(a), id(b))) a[0] = 'ぶどう' print('ぶどうを代入した後: aのID:{:X} bのID:{:X}'.format(id(a), id(b))) 結果の例は以下の通りでです。 :: aのID:2A3BAB5F1C0 bのID:2A3BAB5F1C0 ぶどうを代入した後: aのID:2A3BAB5F1C0 bのID:2A3BAB5F1C0 ``a[0]`` に値を代入したあとでも、 ``a`` のIDは変化していません。もちろん ``b`` のIDも変化していません。つまり ``a`` と ``b`` はメモリ上の同じデータを指しているのです。そうであれば、 ``print()`` で表示した時に ``b`` と ``a`` の内容が一致するのは当然です。 残された謎は、なぜ ``a[0]`` に値を代入しても ``a`` のIDが変化しないかです。 最初、 ``a`` には ``['りんご', 'みかん']`` というlistオブジェクトが代入されています。 listオブジェクトは内容を後から変更できるように設計されているので、最初は ``'みかん'`` のような3文字しかない文字列が要素として入っていても、その要素が数千文字ある文章に置き換えられてしまうかもしれません。どのような大きさのデータが渡されても対応しないといけないため、 **要素としてデータそのものを持っているのではなく、データの置き場所(ID)を書いたメモを持っています** 。 ``a[0]`` に新しい値を代入すると、 ``a[0]`` に格納するための新たなデータをどこかのIDに格納したあと、 ``a[0]`` の置き場所のメモを新しい場所のものに差し替えます。このとき、 **aそのものは新たに作り直されません。** 結果として ``a`` のIDは更新されないのです。 確認してみましょう。 .. code-block:: python a = ['りんご', 'みかん'] print('aのID:{:X} a[0]のID:{:X} a[1]のID:{:X}'.format(id(a),id(a[0]), id(a[1]))) a[0] = 'ぶどう' print('ぶどうを代入した後:aのID:{:X} a[0]のID:{:X} a[1]のID:{:X}'.format(id(a),id(a[0]), id(a[1]))) 結果は以下の通りです。 ``a[0]`` のIDは変わっていますが ``a`` や ``a[1]`` のIDは変わっていません。 :: aのID:2A3BAB4A640 a[0]のID:2A3BAAE2BB0 a[1]のID:2A3BAAE2D30 ぶどうを代入した後:aのID:2A3BAB4A640 a[0]のID:2A3BAAE3090 a[1]のID:2A3BAAE2D30 普段、こういった動作を意識する必要はほとんどありません。しかし、「 :numref:`{number}:{name} ` 」の例のように、listオブジェクトの値を変更しながら他のlistへ ``append()`` する状況では問題になります。以下のコードを実行してみましょう。 コメントにあるように、 ``condtions.copy()`` を ``conditions`` にして最後に ``print()`` を追加した以外は「 :numref:`{number}:{name} ` 」の「Builderのrandomループに相当するコード」と同一です。 .. code-block:: python from numpy import random conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] randomized = [] for i in range(5): random.shuffle(conditions) randomized.append(conditions) # condition.copy() を conditions にした conditions = randomized print(conditions) # conditions の中身を確認 結果の例を以下に示します(見やすいように4要素ずつ改行してあります)。 :: [[['red', -0.7], ['green', -0.7], ['red', 0.7], ['green', 0.7]], [['red', -0.7], ['green', -0.7], ['red', 0.7], ['green', 0.7]], [['red', -0.7], ['green', -0.7], ['red', 0.7], ['green', 0.7]], [['red', -0.7], ['green', -0.7], ['red', 0.7], ['green', 0.7]], [['red', -0.7], ['green', -0.7], ['red', 0.7], ['green', 0.7]]] 並び順は ``shuffle()`` により毎回変化しますが、5回の繰り返しがすべて同じ順序であることは何度実行しても変わらないはずです。 これは ``shuffle()`` が **各要素を指すIDを入れ替えているだけで、入れ物であるlistオブジェクトそのもののIDを変更しないので、全く同じIDのものが5回追加されている** からに他なりません。 このことは簡単に確かめることができます。「各要素のID」をprintする行に本書で未解説のテクニックを使っていますが、 ``conditons`` の各要素に ``id`` を適用したものを ``format()`` の引数にするという意味です。 .. code-block:: python from numpy import random conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] print('conditonsのID:{:X}'.format(id(conditions))) print('各要素のID:{:X},{:X},{:X},{:X}'.format(*map(id,conditions))) random.shuffle(conditions) print('conditonsのID:{:X}'.format(id(conditions))) print('各要素のID:{:X},{:X},{:X},{:X}'.format(*map(id,conditions))) 実行例を示します。IDが長くて読みにくいですが、 ``shuffle()`` の実行前後で「各要素のID」の順序が変化していますが、IDそのものは変わっていないことがわかります。 本文で「 ``shuffle()`` が値を返さない」ことに注意しましょうと書きましたが、この動作から ``shuffle()`` は何も新しいものを作り出していないことがわかります。値を返さないのも納得です。 :: conditonsのID:275FAE20A80 各要素のID:275D3A34180,275D3A7D180,275FAE21040,275FAE21240 conditonsのID:275FAE20A80 各要素のID:275D3A7D180,275FAE21240,275FAE21040,275D3A34180 ずいぶん長くなってしまいましたが、このトピックもようやく終着点です。 本文で使用した ``copy()`` メソッドは、listオブジェクトの複製を作るものです。 .. code-block:: python conditions = [['red', 0.7], ['red', -0.7], ['green', 0.7], ['green', -0.7]] copied = conditions.copy() print('conditionsのID:{:X} copiedのID:{:X}'.format(id(conditions), id(copied))) このコードの実行結果の例を示します。確かに ``conditions`` と ``copied`` のIDが異なり、別のリストであることがわかります。 これなら次々と ``append()`` していっても同じパラメータの順序の繰り返しになりません。 :: conditionsのID:275F9D46600 copiedのID:275D39FD600 以上、本文において ``copy()`` メソッドがなにをするものなのか、なぜ必要なのかを長々と書いてきました。 コピーが必要ない用途でコピーを行うと、実行速度の観点からもメモリ効率の観点からも不利になるので、コピーが必要な時にはプログラマが明示的にそう書くべきだというプログラミング言語の設計思想の表れなのでしょう。 一見複雑な仕様ですが、なぜそのように設計されているのかがわかってくるとプログラミングへの理解がぐっと深まります。 最後にひとつ補足ですが、listオブジェクトの要素がさらにリストである時(多重リスト)、 ``copy()`` は一番外側の階層の要素にのみ適用されます。多重リストの奥の階層にまでわたってコピーを作る必要がある場合はcopyモジュールの ``deepcopy()`` という関数を用います。以下のコードを実行してみてください。通常のコピーでは ``x[0][0]`` を変更すると ``normal_copy[0][0]`` も変化してしまいますが、 ``deep_copy[0][0]`` は影響を受けません。 .. code-block:: python from copy import deepcopy x = [[[1,2],[3,4]],[[5,6],[7,8]]] # 多重リスト normal_copy = x.copy() # 通常のcopy (shallow copy) deep_copy = deepcopy(x) # 深いcopy (deep copy) x[0][0] = 9 # コピー後にxの2番目の階層の要素を変更 print('通常のコピー: {}'.format(normal_copy)) print('深いコピー: {}'.format(deep_copy)) .. _topic-commandline-arguments: コマンドライン引数でファイル名を与える ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PsychoPy Coderから実験を実行するなら、本文のようにダイアログを表示した方がデータファイル名などの指定がしやすいでしょうが、普段からコマンドシェルや類似したインターフェースを使用している人なら、コマンドラインから指定出来たほうが便利だと思われることでしょう。 Pythonのスクリプト内でコマンドライン引数を参照するにはsysモジュールを使用します。 以下のスクリプトを argv_test.py という名前で保存してコマンドラインから ``python argv_test.py foo bar baz`` と実行してみましょう。Windowsでpy launcherを使っているなら ``python`` の部分を ``py`` にしてください。 .. code-block:: python import sys print(sys.argv) 実行結果は以下のようになるはずです。 :: ['argv_test.py', 'foo', 'bar', 'baz'] 以上からわかるように、 ``sys.argv`` という変数に、スクリプト名と引数が順番に格納されます。第1引数をデータファイル名、第2引数を参加者間要因を表すラベル…などと割り振っておけば、スクリプト内で簡単に参照することができます。 もっと柔軟なパラメータ設定をしたいのであれば、標準モジュールのargparseが非常に便利です。詳しい解説はここでは省略しますが、簡単な例を示します。argparse_test.pyという名前で保存してください。 .. code-block:: python import argparse parser = argparse.ArgumentParser(description='サイモン効果 実験') parser.add_argument('filename', help='データファイル名') parser.add_argument('--condition', '-c', default='LR', help='条件ラベル (LRまたはUD, 省略時はLR)') parser.add_argument('--overwrite', '-o', action='store_true', help='既存のデータファイルを上書き') args = parser.parse_args() print(args.filename, args.condition, args.overwrite) ``argparse.ArgumentParser()`` でArgumentParserオブジェクトを作成し、 ``add_argument()`` で引数を定義していきます。 ``add_argument()`` の位置引数は引数名で、 ``-`` や ``--`` で始まるものは省略可能、そうでないものは省略不可です。この例の ``'--condition', '-o'`` のように複数の名前を割り振ることもできます。 引数 ``help`` はその引数についてのヘルプメッセージ、 ``default`` は省略可能な引数の初期値、 ``action`` はさまざまな指定ができますが ``'store_true'`` は当該引数が指定されていれば ``True`` 、されていなければ ``False`` を返すといった働きをします。 引数の定義を終えたら ``parse_args()`` メソッドを実行し、コマンドラインの解析を行います。戻り値はNamespaceというオブジェクトで、引数の定義で引数名として指定した名前のデータ属性を持っています。 文章で説明しようとするとわかりにくいですが、要するにこの例の最終行のように ``args.filename`` でfilenameというパラメータの値、 ``args.conditon`` でconditionというパラメータの値…といった風にアクセスできます。 では、コマンドラインから以下のように、いろいろな引数をつけてargparse_test.pyを実行してみてください。 :: python argparse_test.py python argparse_test.py -h python argparse_test.py data.csv python argparse_test.py data.csv -c=UD -o python argparse_test.py data.csv --overwrite filenameにあたる引数をつけなければエラーとして簡単な使用方法のメッセージが表示されます。また、-hをつけると ``add_argument()`` の引数 ``help`` でつけたヘルプメッセージが表示されます。 ``-c=UD`` とか ``--condition=LR`` のように書くと、省略可能な引数に値を渡すことができます。 自分で作成した実験スクリプトでも、しばらく使わなければ「あれ、このスクリプトのコマンドライン引数はどう指定するんだったかな」とわからなくなってしまうものです。argparseを使って引数を定義しておけば、必要な引数が書けていれば使い方を表示したうえで停止してくれますし、-hオプションを使えば詳しい説明(過去の自分が書いていれば!)を見ることもできます。 パラメータの列挙でlistの代わりにdictを使う ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 本文ではパラメータの列挙に多重リストを用いましたが、dictを使うのも一つの手です。 .. code-block:: python conditions = [{'color':'red', 'xpos':0.7}, {'color':'red', 'xpos':-0.7}, {'color':'green', 'xpos':0.7}, {'color':'green', 'xpos':-0.7}] conditions *= 5 random.shuffle(conditions) このようにすれば、for文でこの ``conditions`` から要素を ``param`` という変数に取り出して使っているとして ``param['color']`` 、 ``param['xpos']`` で値を参照することができます。listオブジェクトの場合、インデックス0が色、インデックス1がX座標といった具合に何番目が何のパラメータだったか覚えておかないといけないいので、本文ではわざわざ ``stim_color = params[0]`` といった具合に変数に代入していましたね。 まあどちらでも好きな方法を使えばいいのですが、 **変数に代入する場合は他の変数と名前が重複しないようにしないといけない** ので、 ``stim_`` とつけるなどの工夫が必要です。それに比べるとdictオブジェクトはそのオブジェクト内でキーが重複しなければ自由につけることができます。 ESCキーで実験を中断する ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Builderで作成した実験は、フルスクリーンモードで実行していてもESCキーを押せば実験を中断することができました(実験設定ダイアログの「基本」タブの **ESCキーによる中断** を解除していない限り!)。 しかしCoderで作成した実験では、この機能が欲しければ自分でコードを書かないといけません。 とはいえ、キーボードを使用している実験なら作業は簡単で、以下のように ``getKeys()`` で検出するキーに ``'escape'`` を付け加えて、ESCキーが押されていたら ``sys.exit()`` を呼び出すだけです。 ESCキーが押されているかどうかの判定はin演算子を使うと簡単です。 ESCキーが押されていると判定されたらいきなり ``sys.exit()`` するのではなく、ウィンドウやファイルを閉じたり、変数の値をファイルに出力しておくなどの後片付けをしておきましょう。 .. code-block:: python :emphasize-lines: 4-8 while t < 10.0: t = routineClock.getTime() keys = kb.getKeys(keyList=['x', 'slash', 'escape']) # ESCキーを追加 if 'escape' in keys: win.close() # 後片付けをしておく data_file.close() sys.exit() # exit()で終了 .. _topic-coder-pilot-mode: ploitモードを有効にする(中級) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Coderのウィンドウのリボンにはpilot/runモード切替ボタンがありますが、モードの切り替えも自分で対応するコードを書かない限り反映されません。 どちらのモードで実行されているかをスクリプト内で判定するにはpsychopy.coreの ``setPilotModeFromArgs()`` を使用します。 ``setPilotModeFromArgs()`` は、Coderで実行する際にpilotモードに設定されているか、コマンドラインから実行したときに --pilot というオプションが指定されてる場合に ``True`` 、そうでなければ ``False`` を返します。 Pilotモードで実行されたときにどのように動作するかは、すべて自分でコードを書いて定義しなければいけません。以下の例では、pilotモードの時にはウィンドウを ``fullscr=False`` 、 runモードのときには ``fullscr=True`` で作成するように処理を分岐しています。 他にも刺激描画の確認用に補助線を引いたり、刺激画像のファイル名や現在の試行数、マウスカーソルの座標などの情報画を面に描画したり、参加者の反応待ちをスキップしたりするなど、動作確認用のコードを追加するといでしょう。 .. code-block:: python # coreモジュールをimportする from psyhcopy import core # setPilotModeFromArgs()の戻り値でウィンドウを開くときの # 処理を分岐する if core.setPilotModeFromArgs(): # Trueならpilotモードなのでウィンドウモード win = visual.Window(fullscr=False) else: # Falseならrunモードなのでフルスクリーンモード win = visual.Window(fullscr=True)