【Coder】PsychoPyでじゃんけんプログラム(Python)

このじゃんけんプログラムは、呈示されるじゃんけんの手に続いて「G(グー)」「C(チョキ)」「P(パー)」のキーを押すことで勝敗を競うゲームです(なので正確には、後出しジャンケンのプログラムです)。
「じゃん、」「けん、」から手の画像が呈示されるまでの遅延(ISI)がランダムに割り当てられています。勝敗を文字でフィードバックし、反応時間とともに記録します。
まだパラメータを設定できるようにしていませんが、3つの手×繰り返し3回の9試行遊べます。

心理実験に必要そうな、試行・ISIのランダマイズ、実験開始・終了時刻の取得、画像の表示、キー押しと反応時間の取得、条件分岐によるフィードバック、結果のCSVファイルへの出力などの機能を網羅していると思います。
ここでは、実行した最初だけは適当に'title.jpg'という画像を数秒呈示しています。
画像には教示か何かを書けばよいかと思いますし、TextBox()やTextStim()で文字列を表示してもよいと思います。
3つの手に対応する画像も'Gu.jpg'・'Choki.jpg'・'Pah.jpg'としていますが、同じようにテキスト表示に置き換えてもよいでしょう。
詳細はソースコード以降の解説を御覧ください。

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

from __future__ import division, unicode_literals
from psychopy import visual, event, core
import os, random, datetime, csv, codecs
import numpy as np

path = os.getcwd()
win = visual.Window()
clock = core.Clock()

title = visual.ImageStim(win, 'title.jpg')
janken = ['Gu.jpg', 'Choki.jpg','Pah.jpg'] 
trials = range(0,len(janken)) * 3
random.shuffle(trials)

txtfont = ''	# システムのデフォルトフォントを使う。
#txtfont = 'ヒラギノ角ゴ Pro W6' # Mac用
calltxt = visual.TextStim(win, height=0.2, color='white', font = txtfont)
wintxt = 'あなたの勝ち!'
losetxt = 'あなたの負け!'
drawtxt = 'あいこです!'
fb = visual.TextStim(win, text='あなたの勝ち!', height=0.2, color='red', font = txtfont)

r = np.array(range(1,10))
random.shuffle(r)
ISI = 1 + r * 0.1

foldername = 'results'
d = datetime.datetime.today()
starttime = '%s_%s_%s_%s_%s_%s' % (d.year, d.month, d.day, d.hour, d.minute, d.second)
if os.path.exists(foldername) == 0:
    os.mkdir(foldername)

DAT = np.empty((0,5), str) # 各試行の結果保存用

clock.reset()
title.draw()
win.flip()
core.wait(3)
print(['Trial order: ' + str(trials)])
print(starttime)
print(['TrialNum', 'Hands', 'ISI'])
win.flip()

for i in range(0, len(trials)):
    tmp = [i+1, janken[trials[i]].split('.')[0], str(ISI[i])]
    print(tmp)
    core.wait(2) #ITI
    
    calltxt.setText('じゃん、'); calltxt.draw(); win.flip(); core.wait(0.7)
    calltxt.setText('けん、'); calltxt.draw(); win.flip(); core.wait(ISI[i])
    
    clock.reset()
    event.getKeys()
    waiting_keypress=True
    
    stim = visual.ImageStim(win, janken[trials[i]])
    while clock.getTime()<3 and waiting_keypress:
        stim.draw()
        win.flip()
        keys = event.getKeys(timeStamped=clock)
        for key in keys:
            if key[0] in {'g', 'c', 'p'}:
                if key[0] == 'g':
                    if trials[i] == 0:
                        fb.setText(drawtxt); fb.color='yellow'; res = 'draw'
                    elif trials[i] == 1:
                        fb.setText(wintxt); fb.color='red'; res = 'win'
                    elif trials[i] == 2:
                        fb.setText(losetxt); fb.color='aqua'; res = 'lose'
                        
                elif key[0] == 'c':
                    if trials[i] == 0:
                        fb.setText(losetxt); fb.color='aqua'; res = 'lose'
                    elif trials[i] == 1:
                        fb.setText(drawtxt); fb.color='yellow'; res = 'draw'
                    elif trials[i] == 2:
                        fb.setText(wintxt); fb.color='red'; res = 'win'
                        
                elif key[0] == 'p':
                    if trials[i] == 0:
                        fb.setText(wintxt); fb.color='red'; res = 'win'
                    elif trials[i] == 1:
                        fb.setText(losetxt); fb.color='aqua'; res = 'lose'
                    elif trials[i] == 2:
                        fb.setText(drawtxt); fb.color='yellow'; res = 'draw'
                        
                fb.draw()
                win.flip()
                del keys
                waiting_keypress=False
                core.wait(1.5)
                break
            elif key[0] == 'escape':
                print('Session aborted.')
                print(DAT)
                win.close()
                core.quit()
    if waiting_keypress:
        tmp.extend(['N/A', 'N/A'])
    else:
        tmp.extend([res, key[1]])
    DAT = np.append(DAT, np.array([tmp]), axis=0)
    
    win.flip()

d2 = datetime.datetime.today()
endtime = '%s_%s_%s_%s_%s_%s' % (d2.year, d2.month, d2.day, d2.hour, d2.minute, d2.second)
print(endtime)
print(DAT)
str1 = 'janken' + endtime + '.csv'
os.chdir(foldername)

header=['TrialNum', 'Hands', 'ISI', 'Result', 'RTs']
to=["trial order:"]
to.extend(trials)

with open(str1, 'w') as f:
    writer=csv.writer(f, lineterminator='\n')
    writer.writerow(['////////// Janken program //////////'])
    for i in range(0,len(janken)):
        writer.writerow([janken[i].split(".")[0], '=', i])
    writer.writerow("")
    writer.writerow(to)
    writer.writerow("")
    writer.writerow(["Start time:", starttime])
    writer.writerow(["End time:", endtime])
    writer.writerow(header)
    writer.writerows(DAT)

os.chdir(path)


解説

本プログラムの仕様

プログラムを実行するとタイトル画面が表示され、その3秒後に消失、実験が開始されます。
各試行はfor文のループによって実施しています。出力画面にその試行の条件を表示し、2秒間のITIが挿入されます。
直後、テキストで「じゃん、」と表示、その0.7秒の遅延後、「けん、」と表示され、1.1 ~ 1.9までのいずれかのISIによる遅延が表示されます。
じゃんけんの手のいずれかの画像呈示が挿入されます。ポーリングとして、while文でキー押し反応か3秒の時間経過を監視します。
3秒以内にキー押しがあれば、押されたキー(被験者の手)に応じて「勝ち」「負け」「あいこ」を判定します。
テキストとして判定を表示し、1.5秒間の遅延後に消失してその試行は終了します。
勝敗や反応時間をリストに追加してfor文の最初に戻り、次の試行が始まります。
全試行終了後、終了時間やローデータを行列で出力画面表示し、結果のCSVファイルの書き出しを行います。

Pythonに一般的な各部の解説

とりいそぎ実験の流れを制御する箇所について説明をしていきます。

janken = ['Gu.jpg', 'Choki.jpg','Pah.jpg'] 
trials = range(0,len(janken)) * 3
random.shuffle(trials)

ここでは、刺激である相手の「手」について設定しています。
「グー」「チョキ」「パー」に対応する刺激画像のファイル名を"janken"というリストに格納しています。
関数range()によって数値0~2(リスト"janken"の長さマイナス1)が反復されます。
15行目、shuffle()する前のtrialsの中身は次のようになります。

>>> trials
[0, 1, 2, 0, 1, 2, 0, 1, 2]

ただし、Python3環境でのrange()の使い方には注意が必要です。リストのように演算ができません。
なので、list()を使ってrange()が返したオブジェクト(イテレータ)をリスト化しなくてはいけないそうです。

trials = list(range(0,len(janken))) * 3 # 上の結果に同じ。

リストtrialsは、16行目でrandomモジュールのshuffle()でランダマイズされています。 同じ条件が3回以上連続して出現しないような疑似ランダム系列の作成には、少し工夫が要ります。

1.1~1.9までのISIは、Numpyモジュールのarray()を使って行列を作り、float型の数値を使った行列演算によって設定しています。

r = np.array(range(1,10))
random.shuffle(r)
ISI= 1 + r * 0.1

33・34行目では実験結果保存用のフォルダを設定しています。
osモジュールのpath.exist()で対象とするパス名が存在しない場合に、mkdir()でその同名フォルダを作成しています。
36行目では、全試行の結果保存用の文字配列を作成しています。
ここに、最終的には、各試行の番号、機械側の手、ISI、被験者側の手、反応時間の5列×9行が収まります。
ですが36行目の初期化では、最初に1行分のみ作成しています。

DAT = np.empty((0,5), str) # 各試行の結果保存用

1回の試行では、これら5つは48行目で作った"tmp"に一時的に納められ、一回のループ(試行)の終わりごとにDATの行末尾へ追加されていきます。
本プログラムには未実装ですが、何らかの失敗試行やプローブテストが挿入される場合は、このような方法がいいかもしれません。

tmp = [i+1, janken[trials[i]].split('.')[0], str(ISI[i])] # 3列目までの情報を格納
DAT = np.append(DAT, np.array([tmp]), axis=0) # 試行の最後に行の末尾へ追加

48行目での最初のリストtmpの初期化では、3列までを設定しています。残り2列は、被験者の反応取得後にtmp.extend()で追加しています。
ちなみに各試行の機械側の手は、出される手の順番のリスト"trials"から、手の種類に対応する番号を取得して決定しています。
1試行目なら、リストの0番に格納された番号を取得します。今回は、リスト"janken"内で0をグー、1をチョキ、2をパーに設定しています。janken内にはこれら3つの手がこの順番で格納されていますから、リストtrialsの0番の番号が1なら、機械側で出す手はチョキになります。
48行目のリストtmp内では、2列目で、リストjankenに納められている各手に対応する画像ファイル名をそのまま使っています。
リストtmpは試行の終わりにそのまま行列DATへ追加されます。拡張子が邪魔ですから、文字列を区切ってリストにするsplit()を使って".拡張子"までの文字列のみをその列へ納めています。

>>> janken = ['Gu.jpg', 'Choki.jpg','Pah.jpg']
>>> janken[0]
'Gu.jpg'
>>> janken[0].split('.')
['Gu', 'jpg']
>>> janken[0].split('.')[0]
'Gu'

もちろん、画像ファイルを呼び出す際に、拡張子を後でくっつける形でもいいと思います。

○CSVファイルへ読み書き
プログラムの最後あたりでは、行列DATに収めた結果等の外部ファイルへの書き出しを行っています。
110行目で全試行が終了した時刻を秒単位まで取得し、113行目でCSVのファイル名に使用しています。
被験者名を取得する仕組みをinput()関数なんかで作っておけば、被験者名を含むファイルにすることもできます。
本プログラムでは、open関数とwith構文を使ってファイル作成&書き込みを行っています。
次の例では、空のcsvファイルを現在のディレクトリに作成します。

>>> import csv
>>> csvfile = 'test.csv'
>>> with open(csvfile, 'w') as f:
...	writer = csv.writer(f)

組み込み関数open()の2番目の引数modeでは、下記のような文字により操作の種類を指定できます。

  • 'r':読み込み用に開く (デフォルト)
  • 'w':書き込み用に開き、まずファイルを切り詰める
  • 'a':書き込み用に開き、ファイルが存在する場合は末尾に追記する

次の例では、先に作成したCSVファイル"test.csv"へ文字列を書き込んでいきます。
csv.writer()の2番目の引数lineterminatorで各行の終端の文字列を指定しています。

>>> with open(csvfile, 'a') as f:
...	writer = csv.writer(f, lineterminator='\n')
...	writer.writerow(['File open'])
...	writer.writerow(['Filename:', csvfile])
...	writer.writerow(['File close'])

上の例では、1行目と3行目には1列、2行目には2列書き込まれると思います。

PsychoPy特有の機能紹介

ここからは、本プログラム用いているPsychoPyの特有の機能について詳細に説明していきます。
○Window
10行目では、PsychoPyのvisualモジュールにあるWindow関数によって、描画用ウィンドウを初期化しています。
ウィンドウの背景色以外は、デフォルト値かPsychoPy上で設定した値を用いるようになっています。

win = visual.Window()	#800×600ピクセルの一様な灰色ウィンドウ

この上へ、ImageStim関数やTextStim関数で作成した刺激を表示していくことになります。
18~24行目では、visual.TextStim()でじゃんけんの掛け声や勝敗などのフィードバック文字を設定しています。

txtfont = ''	# システムのデフォルトフォントを使う。
#txtfont = 'ヒラギノ角ゴ Pro W6' # Mac用
calltxt = visual.TextStim(win, height=0.2, color='white', font = txtfont)
wintxt = 'あなたの勝ち!'
losetxt = 'あなたの負け!'
drawtxt = 'あいこです!'
fb = visual.TextStim(win, text='あなたの勝ち!', height=0.2, color='red', font = txtfont)

このときの引数textは表示するテキスト、heightは文字サイズ、colorは文字色です。もちろんfontはフォント。
ここでは混乱の回避のために掛け声とFBを表示するオブジェクトを分けています。もちろん、勝敗と引き分けそれぞれで作ってもいいかと思います。メモリの節約やパラメータを後で変更することを考え始めると、どう書けば良いのかは何とも言えませんが・・・・
Macを使われる方は日本語フォントに注意が必要です。 Builder Tips:Textコンポーネントで日本語が「欠ける」問題について(Mac版)
2018年5月時点でのv1.90.2でも、切れてしまうのは相変わらず。上の小川先生のサイトのようにヒラギノ角ゴシック W5でやってみても切れたままだったので、他のシステムフォントから選びました。
Macでは、/システム/ライブラリ/Fonts
か、/ライブラリ/Fonts
で見ることが出来ると思います。あるいはプログラム上でプラットフォームを判別しても良いかもしれません。
OS(プラットフォーム)の判別(python)

実際に画像刺激を呈示するためのオブジェクトは、試行を反復するwhile文内で作っています。
刺激はvisual.ImageStim()でオブジェクトを作成し、画像は、そのときの試行に対応する画像名をリストjankenから呼び出しています。

stim = visual.ImageStim(win, janken[trials[i]])

基本的にPsychoPyでの刺激の描画には、
TextStim()やImageStim()で描画内容を設定

draw()で描画ウィンドウへ配置

flip()で実際に描画ウィンドウ上へ表示
という手順を踏みます。
たとえば、52・53行目では、「じゃん、」という文字を呈示し、0.7秒後、「けん、」と呈示し任意のISIを挿入します。

calltxt.setText('じゃん、'); calltxt.draw(); win.flip(); core.wait(0.7)
calltxt.setText('けん、'); calltxt.draw(); win.flip(); core.wait(ISI[i])

ここではセミコロン(;)で各関数を区切っていますが、長い行になるので1行にまとめたまでで、本来は非推奨だそうです。
「じゃん、」と「けん、」の間のインターバルはcore.wait()で設定しています。
秒単位で設定した期間中、プログラムがスリープします。Pythonの組み込み関数time.sleep()は特に正確ではないそう。
wait()中に、pygletを使うことでキー押しの監視もできるそうです。今回の例ではwhile文での無限ループ中に画像の連続flip()とキー押しチェックを行うことで制限時間内の絶え間ない画像呈示と反応取得を実現しています。
PsychoPyの公式リファレンス十河先生の翻訳ページ

2020.11.17 更新:手の画像のリンクを追加。 2020.12.7 更新:Builder版と差別化。

↑ PAGE TOP